flowchart TD
A["Qual mecanismo de persistência usar?"] --> B{"Os dados têm\nestrutura relacional\n(entidades com relações)?"}
B -- Sim --> C["sqflite\n(banco SQLite)"]
B -- Não --> D{"São arquivos\ngerados pelo app\n(PDFs, logs, exports)?"}
D -- Sim --> E["path_provider\n(sistema de arquivos)"]
D -- Não --> F{"São configurações\nou preferências\nsimples?"}
F -- Sim --> G["SharedPreferences\n(chave-valor)"]
F -- Não --> H{"O volume de dados\né grande ou cresce\nindefinidamente?"}
H -- Sim --> C
H -- Não --> I{"Precisa serializar\nobjetos complexos\ncomo JSON?"}
I -- Sim --> E
I -- Não --> G
Módulo 08 — Persistência Local de Dados
Você chegou ao módulo que transforma o seu aplicativo em algo verdadeiramente funcional mesmo sem conexão com a internet. Nos módulos anteriores, você construiu a interface do Projeto Integrador, configurou a navegação com go_router, criou formulários com validação robusta e aprendeu a gerenciar o estado da aplicação com Provider. Mas toda essa beleza tem um ponto fraco: ao fechar e reabrir o aplicativo, tudo que o usuário fez — pedidos adicionados, preferências configuradas, produtos consultados — some. Neste módulo, você vai aprender a guardar dados no dispositivo de forma permanente, utilizando três mecanismos complementares: o SharedPreferences para configurações simples, o path_provider para arquivos no sistema de arquivos e o sqflite para dados relacionais complexos. Ao final, você terá um Projeto Integrador capaz de funcionar offline, sincronizando com o backend quando a conexão for restabelecida. Estude com atenção, execute todos os exemplos e chegue à aula presencial com perguntas sobre como aplicar essas técnicas no contexto específico do seu projeto.
Por que Todo Aplicativo Precisa Guardar Dados
Pense por um momento no que acontece quando você abre um aplicativo de delivery no seu celular sem conexão com a internet. Em um aplicativo bem construído, você ainda consegue ver o cardápio que consultou na última vez que estava online, seus pedidos anteriores aparecem no histórico, seu endereço de entrega preferido já está preenchido e o tema escuro que você configurou permanece ativo. Em um aplicativo mal construído, você vê uma tela de erro ou, pior ainda, uma tela em branco que simplesmente não faz nada. A diferença entre esses dois comportamentos é exatamente o que você aprenderá neste módulo.
A persistência local de dados é o mecanismo pelo qual um aplicativo armazena informações no próprio dispositivo do usuário, de forma que essas informações sobrevivam ao fechamento do aplicativo, à reinicialização do dispositivo e, em muitos casos, à ausência de conexão com a rede. Sem persistência, cada sessão de uso do aplicativo começa do zero — o que é não apenas frustrante para o usuário, mas também contrário aos princípios básicos de usabilidade de software.
No contexto do Projeto Integrador, o aplicativo de delivery de comida, você pode identificar pelo menos três categorias distintas de dados que precisam ser guardados localmente. A primeira categoria é a das preferências do usuário: o tema visual escolhido (claro ou escuro), o endereço de entrega padrão, as configurações de notificação. Esses dados são pequenos, simples e mudam raramente — são o candidato natural para o mecanismo mais leve de persistência. A segunda categoria é a dos dados de aplicação: o catálogo de produtos do restaurante, o histórico de pedidos do usuário, os itens que estão atualmente no carrinho. Esses dados têm estrutura relacional — um pedido tem itens, um item tem um produto associado — e podem crescer indefinidamente ao longo do tempo. A terceira categoria é a dos arquivos gerados pela aplicação: um comprovante de pedido em formato de texto, um log de sincronização para depuração, um arquivo de configuração exportado. Cada categoria de dado tem um mecanismo de persistência mais adequado, e a escolha errada traz consequências concretas em performance, manutenibilidade e complexidade de código.
Existe também uma dimensão de experiência do usuário que vai além da simples conveniência. Quando um usuário está em um local com sinal instável — um metrô, um elevador, uma área remota — e abre o aplicativo para fazer um pedido, ele espera que o cardápio carregue instantaneamente a partir de um cache local, sem depender de uma requisição de rede que pode falhar. Ele espera que, ao finalizar o pedido e não ter conexão naquele momento, o aplicativo guarde o pedido localmente e o envie automaticamente quando a internet for restabelecida. Esse comportamento offline-first não é um luxo — é cada vez mais uma expectativa básica do usuário moderno. E ele só é possível com uma estratégia bem pensada de persistência local combinada com sincronização remota.
Há também uma dimensão de performance que você deve considerar. Fazer uma requisição de rede para buscar o catálogo de produtos toda vez que o usuário abre a tela de listagem consome dados móveis do usuário, impõe uma latência perceptível na interface e sobrecarrega o servidor desnecessariamente. Se o catálogo raramente muda, faz muito mais sentido buscar os dados uma vez, guardá-los localmente e exibir a versão local nas próximas sessões, fazendo uma atualização discreta em segundo plano. Essa estratégia, que você estudará em detalhes na seção de cache, pode reduzir o tempo de carregamento de segundos para milissegundos.
As Diferentes Formas de Guardar Dados Localmente
O ecossistema Flutter oferece várias opções para persistência local, e a escolha entre elas depende da natureza dos dados que você precisa guardar, do volume esperado, da complexidade estrutural e dos padrões de acesso. Não existe uma opção universalmente superior — cada mecanismo tem seu domínio de aplicação ideal.
Os três mecanismos que você estudará neste módulo formam uma progressão natural de complexidade. O SharedPreferences é o mais simples: ele guarda pares chave-valor de tipos primitivos em um arquivo de configuração gerenciado pelo sistema operacional. É extremamente fácil de usar, mas estritamente limitado a dados simples e sem estrutura relacional. O sistema de arquivos, acessado através do path_provider, permite guardar dados em arquivos arbitrários — texto, binário, JSON serializado — com total controle sobre o formato e o conteúdo. É mais flexível que o SharedPreferences mas não oferece nenhuma capacidade de consulta estruturada: para encontrar um dado específico, você precisaria ler o arquivo inteiro e processá-lo manualmente. O sqflite é um banco de dados SQLite embutido diretamente no aplicativo, oferecendo todas as capacidades de um banco de dados relacional — tabelas, índices, consultas SQL, transações, junções — com a vantagem de funcionar sem nenhum servidor externo.
A tabela a seguir sintetiza as principais características de cada mecanismo para ajudá-lo a tomar decisões rápidas durante o desenvolvimento:
| Característica | SharedPreferences | Arquivos (path_provider) | SQLite (sqflite) |
|---|---|---|---|
| Tipo de dado | Primitivos (int, String, bool, double, List<String>) | Qualquer conteúdo serializável | Dados estruturados e relacionais |
| Estrutura | Chave-valor plana | Hierarquia de diretórios | Tabelas com colunas e tipos |
| Capacidade | Pequena (dezenas de pares) | Limitada pelo armazenamento | Limitada pelo armazenamento |
| Velocidade de leitura | Muito rápida (memória) | Depende do tamanho do arquivo | Rápida com índices |
| Consultas estruturadas | Não | Não (leitura total) | Sim (SQL completo) |
| Casos de uso | Preferências, configurações, flags | Arquivos gerados, cache JSON, logs | Entidades de domínio, histórico, catálogos |
O diagrama a seguir apresenta uma árvore de decisão para ajudá-lo a escolher o mecanismo certo durante o desenvolvimento do Projeto Integrador:
Ao longo das seções seguintes, você estudará cada um desses mecanismos em profundidade, sempre com exemplos concretos usando as entidades do Projeto Integrador.
path_provider: Acessando o Sistema de Arquivos
Os Diretórios que o Flutter Pode Usar
Cada sistema operacional móvel organiza o armazenamento interno em diretórios com finalidades e políticas de acesso distintas. O Android e o iOS reservam diretórios específicos para cada aplicativo, e o Flutter não tem acesso irrestrito a todo o sistema de arquivos — e nem deveria ter, pois isso seria um risco de segurança. O pacote path_provider expõe esses diretórios de forma padronizada e multiplataforma.
O pacote oferece vários métodos para obter caminhos, mas os três mais relevantes para o desenvolvimento de aplicativos são descritos na tabela a seguir:
| Método | Android | iOS | Quando usar |
|---|---|---|---|
getTemporaryDirectory() |
Cache do app (/data/data/<app>/cache) |
NSCachesDirectory |
Arquivos temporários que podem ser apagados pelo SO |
getApplicationDocumentsDirectory() |
Documentos do app | NSDocumentDirectory |
Arquivos que o usuário gera e que devem persistir |
getApplicationSupportDirectory() |
files/ do app |
NSApplicationSupportDirectory |
Arquivos de suporte da aplicação (banco de dados, configs) |
O diretório temporário é o lugar certo para arquivos de cache de imagens ou dados baixados que podem ser regenerados se necessário — o sistema operacional pode apagá-los automaticamente quando o espaço em disco estiver escasso. O diretório de documentos é o lugar certo para arquivos que o usuário gerou e que não devem ser apagados sem consentimento, como comprovantes de pedido ou relatórios exportados. O diretório de suporte é onde o sqflite coloca os arquivos de banco de dados por padrão.
Lendo e Gravando Arquivos
Para trabalhar com arquivos, você combinará o path_provider com a classe File do pacote dart:io. O path_provider fornece o diretório base, e você constrói o caminho completo do arquivo usando o pacote path (que faz parte da biblioteca padrão do Dart) para garantir que os separadores de diretório sejam corretos em cada plataforma.
import 'dart:io';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
class ComprovanteService {
// Gera e salva um comprovante de pedido em arquivo de texto
Future<File> salvarComprovante({
required int pedidoId,
required String nomeUsuario,
required String enderecoEntrega,
required double valorTotal,
required List<String> nomesItens,
required DateTime criadoEm,
}) async {
// Obtém o diretório de documentos do app
final diretorioDocumentos = await getApplicationDocumentsDirectory();
// Constrói o caminho do arquivo usando join para compatibilidade multiplataforma
final caminhoArquivo = path.join(
diretorioDocumentos.path,
'comprovantes',
'pedido_$pedidoId.txt',
);
// Cria o diretório de comprovantes se não existir
final diretorioComprovantes = Directory(
path.join(diretorioDocumentos.path, 'comprovantes'),
);
if (!await diretorioComprovantes.exists()) {
await diretorioComprovantes.create(recursive: true);
}
// Monta o conteúdo do comprovante
final conteudo = StringBuffer()
..writeln('=== COMPROVANTE DE PEDIDO ===')
..writeln('Pedido #$pedidoId')
..writeln('Cliente: $nomeUsuario')
..writeln('Endereço: $enderecoEntrega')
..writeln('Data: ${criadoEm.toLocal()}')
..writeln()
..writeln('--- ITENS ---');
for (final item in nomesItens) {
conteudo.writeln(' • $item');
}
conteudo
..writeln()
..writeln('Total: R\$ ${valorTotal.toStringAsFixed(2)}')
..writeln('============================');
// Escreve o arquivo
final arquivo = File(caminhoArquivo);
await arquivo.writeAsString(conteudo.toString());
return arquivo;
}
// Lê o conteúdo de um comprovante salvo
Future<String?> lerComprovante(int pedidoId) async {
final diretorioDocumentos = await getApplicationDocumentsDirectory();
final caminhoArquivo = path.join(
diretorioDocumentos.path,
'comprovantes',
'pedido_$pedidoId.txt',
);
final arquivo = File(caminhoArquivo);
if (!await arquivo.exists()) {
return null;
}
return arquivo.readAsString();
}
}import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
final class ComprovanteService {
Future<Directory> get _dirComprovantes async {
final base = await getApplicationDocumentsDirectory();
final dir = Directory(p.join(base.path, 'comprovantes'));
if (!await dir.exists()) await dir.create(recursive: true);
return dir;
}
Future<File> salvarComprovante({
required int pedidoId,
required String nomeUsuario,
required String enderecoEntrega,
required double valorTotal,
required List<String> nomesItens,
required DateTime criadoEm,
}) async {
final dir = await _dirComprovantes;
final arquivo = File(p.join(dir.path, 'pedido_$pedidoId.txt'));
final conteudo = [
'=== COMPROVANTE DE PEDIDO ===',
'Pedido #$pedidoId',
'Cliente: $nomeUsuario',
'Endereço: $enderecoEntrega',
'Data: ${criadoEm.toLocal()}',
'',
'--- ITENS ---',
...nomesItens.map((i) => ' • $i'),
'',
'Total: R\$ ${valorTotal.toStringAsFixed(2)}',
'============================',
].join('\n');
return arquivo.writeAsString(conteudo);
}
Future<String?> lerComprovante(int pedidoId) async {
final dir = await _dirComprovantes;
final arquivo = File(p.join(dir.path, 'pedido_$pedidoId.txt'));
return await arquivo.exists() ? arquivo.readAsString() : null;
}
}Casos de Uso no Projeto Integrador
No contexto do aplicativo de delivery, o sistema de arquivos é o mecanismo adequado para três tipos de dados que não se encaixam bem nem no SharedPreferences nem no SQLite. O primeiro são os comprovantes de pedido: quando um pedido é finalizado, o aplicativo pode gerar um arquivo de texto ou HTML formatado com todos os detalhes da transação — itens, valores, endereço de entrega — e salvá-lo no diretório de documentos do usuário. Esse arquivo pode ser compartilhado via Share (usando o pacote share_plus) ou exibido na tela de histórico.
O segundo são os logs de sincronização. Quando o aplicativo opera em modo offline e acumula operações para sincronizar posteriormente, manter um log em arquivo é uma prática saudável: permite auditar o que aconteceu, identificar erros de sincronização e facilitar a depuração. O arquivo de log cresce ao longo do tempo e pode ser truncado periodicamente quando atingir um tamanho limite.
O terceiro caso são arquivos de cache de dados JSON. Embora o SQLite seja superior para dados relacionais, há situações onde um simples arquivo JSON com dados do servidor é suficiente e mais rápido de implementar. Por exemplo, as configurações gerais do restaurante — horário de funcionamento, taxa de entrega, tempo estimado — podem ser baixadas do servidor, salvas como um arquivo JSON no diretório de cache e lidas diretamente na próxima sessão.
SQLite: O Banco de Dados Relacional do Dispositivo
O Que é SQLite e Por Que Ele é Ideal para Mobile
O SQLite é um banco de dados relacional completo que não depende de um servidor separado para funcionar. Toda a engine de banco de dados — incluindo o parser SQL, o otimizador de consultas, o mecanismo de armazenamento e o gerenciador de transações — está compilada dentro do próprio aplicativo. O arquivo de banco de dados é um único arquivo no sistema de arquivos do dispositivo, o que o torna trivialmente simples de copiar, mover ou fazer backup.
O SQLite está presente em praticamente todos os dispositivos móveis modernos: ele é parte do sistema operacional Android, parte do iOS, parte do macOS e parte do Windows. Ele é o banco de dados mais amplamente implantado do mundo, presente em bilhões de dispositivos. Para o desenvolvimento mobile, suas características o tornam uma escolha natural: ele é leve, rápido para o volume de dados típico de um aplicativo móvel, não requer configuração de servidor, funciona offline por definição e suporta transações ACID — Atomicidade, Consistência, Isolamento e Durabilidade — garantindo a integridade dos dados mesmo em caso de falha do sistema.
O diagrama a seguir ilustra como o SQLite se posiciona dentro da arquitetura do aplicativo de delivery:
graph TD
subgraph App["Aplicativo Flutter"]
UI["Camada de Apresentação\n(Widgets + Provider)"]
DOM["Camada de Domínio\n(Entidades + Repositórios Abstratos)"]
INFRA["Camada de Infraestrutura\n(Repositórios Concretos)"]
DB["sqflite\n(SQLite Database)"]
end
UI -->|"chama métodos do notifier"| DOM
DOM -->|"interface abstrata"| INFRA
INFRA -->|"lê/escreve"| DB
DB -->|"arquivo .db"| FS["Sistema de Arquivos\ndo Dispositivo"]
style DB fill:#f9a825,stroke:#f57f17,color:#000
style FS fill:#e0e0e0,stroke:#9e9e9e
A diferença entre o SQLite e um banco de dados como PostgreSQL ou MySQL é que não há separação entre cliente e servidor: o “servidor” está dentro do próprio processo do aplicativo. Isso significa que não há latência de rede nas consultas — tudo acontece dentro do mesmo processo, na mesma memória. Para o volume de dados típico de um aplicativo mobile, o SQLite oferece performance mais que suficiente: ele consegue processar centenas de milhares de operações por segundo em hardware moderno.
Adicionando sqflite ao Projeto
O sqflite é a biblioteca mais popular e madura para acesso ao SQLite no Flutter. Além do próprio sqflite, você precisará do pacote path para construir o caminho do arquivo de banco de dados de forma compatível com todas as plataformas. Ambos já estão na lista de dependências da disciplina.
Depois de adicionar as dependências ao pubspec.yaml e executar flutter pub get, você terá acesso à API do sqflite através do import package:sqflite/sqflite.dart.
Abrindo (ou Criando) o Banco de Dados
O ponto de entrada do sqflite é a função openDatabase. Ela recebe o caminho do arquivo de banco de dados e opcionalmente um mapa de callbacks para criar e migrar o schema. Se o arquivo não existir, o sqflite o cria automaticamente. Se o arquivo já existir e a versão do banco for inferior à versão especificada, o callback onUpgrade é chamado para realizar a migração.
import 'package:path/path.dart' as path;
import 'package:sqflite/sqflite.dart';
class DatabaseHelper {
// Versão atual do banco de dados — incrementar ao fazer migrações
static const int _versao = 1;
static const String _nomeBanco = 'delivery.db';
// Instância única do banco (singleton)
static Database? _db;
// Obtém ou cria a instância do banco
static Future<Database> obterBanco() async {
// Se já foi aberto, retorna a instância existente
if (_db != null) {
return _db!;
}
// Obtém o diretório padrão para bancos de dados no dispositivo
final diretorioBancos = await getDatabasesPath();
// Constrói o caminho completo do arquivo
final caminhoBanco = path.join(diretorioBancos, _nomeBanco);
// Abre (ou cria) o banco de dados
_db = await openDatabase(
caminhoBanco,
version: _versao,
onCreate: _criarTabelas,
onUpgrade: _migrarTabelas,
);
return _db!;
}
// Chamado quando o banco é criado pela primeira vez
static Future<void> _criarTabelas(Database db, int versao) async {
// Cria as tabelas em uma única transação para garantir atomicidade
await db.transaction((txn) async {
await txn.execute(_sqlCriarProdutos);
await txn.execute(_sqlCriarPedidos);
await txn.execute(_sqlCriarItensPedido);
});
}
// Chamado quando a versão do banco é incrementada
static Future<void> _migrarTabelas(
Database db,
int versaoAntiga,
int versaoNova,
) async {
// Migrações são tratadas por versão — ver seção sobre migrações
}
// SQL para criar a tabela de produtos
static const String _sqlCriarProdutos = '''
CREATE TABLE produtos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
categoria TEXT NOT NULL,
preco REAL NOT NULL,
descricao TEXT NOT NULL,
disponivel INTEGER NOT NULL DEFAULT 1,
sincronizado_em TEXT
)
''';
// SQL para criar a tabela de pedidos
static const String _sqlCriarPedidos = '''
CREATE TABLE pedidos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
usuario_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pendente',
valor_total REAL NOT NULL,
criado_em TEXT NOT NULL,
sincronizado INTEGER NOT NULL DEFAULT 0
)
''';
// SQL para criar a tabela de itens de pedido
static const String _sqlCriarItensPedido = '''
CREATE TABLE itens_pedido (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pedido_id INTEGER NOT NULL,
produto_id INTEGER NOT NULL,
nome_produto TEXT NOT NULL,
preco_unitario REAL NOT NULL,
quantidade INTEGER NOT NULL,
FOREIGN KEY (pedido_id) REFERENCES pedidos (id) ON DELETE CASCADE
)
''';
}import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
final class DatabaseHelper {
DatabaseHelper._();
static final DatabaseHelper instance = DatabaseHelper._();
static const _versao = 1;
static const _nomeBanco = 'delivery.db';
Database? _db;
Future<Database> get db async => _db ??= await _open();
Future<Database> _open() async {
final dir = await getDatabasesPath();
return openDatabase(
p.join(dir, _nomeBanco),
version: _versao,
onCreate: (db, _) => db.transaction(
(txn) => Future.wait([
txn.execute(_sqlProdutos),
txn.execute(_sqlPedidos),
txn.execute(_sqlItens),
]),
),
);
}
static const _sqlProdutos = '''
CREATE TABLE produtos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
categoria TEXT NOT NULL,
preco REAL NOT NULL,
descricao TEXT NOT NULL,
disponivel INTEGER NOT NULL DEFAULT 1,
sincronizado_em TEXT
)
''';
static const _sqlPedidos = '''
CREATE TABLE pedidos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
usuario_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pendente',
valor_total REAL NOT NULL,
criado_em TEXT NOT NULL,
sincronizado INTEGER NOT NULL DEFAULT 0
)
''';
static const _sqlItens = '''
CREATE TABLE itens_pedido (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pedido_id INTEGER NOT NULL,
produto_id INTEGER NOT NULL,
nome_produto TEXT NOT NULL,
preco_unitario REAL NOT NULL,
quantidade INTEGER NOT NULL,
FOREIGN KEY (pedido_id) REFERENCES pedidos (id) ON DELETE CASCADE
)
''';
}Definindo o Schema com SQL
O schema das tabelas define a estrutura do banco de dados. Para o aplicativo de delivery, o schema precisa representar três entidades principais — Produto, Pedido e ItemPedido — e as relações entre elas. Observe que o SQLite não tem um tipo BOOLEAN nativo: booleans são representados como INTEGER com valores 0 (falso) e 1 (verdadeiro). Datas e timestamps são representados como texto no formato ISO 8601 ('2025-03-09T14:30:00.000Z'), o que permite ordenação lexicográfica correta.
CREATE TABLE produtos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
categoria TEXT NOT NULL,
preco REAL NOT NULL,
descricao TEXT NOT NULL,
disponivel INTEGER NOT NULL DEFAULT 1,
sincronizado_em TEXT
);
CREATE TABLE pedidos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
usuario_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pendente',
valor_total REAL NOT NULL,
criado_em TEXT NOT NULL,
sincronizado INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE itens_pedido (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pedido_id INTEGER NOT NULL,
produto_id INTEGER NOT NULL,
nome_produto TEXT NOT NULL,
preco_unitario REAL NOT NULL,
quantidade INTEGER NOT NULL,
FOREIGN KEY (pedido_id) REFERENCES pedidos (id) ON DELETE CASCADE
);
-- Índices para acelerar as consultas mais comuns
CREATE INDEX idx_pedidos_usuario ON pedidos (usuario_id);
CREATE INDEX idx_itens_pedido_id ON itens_pedido (pedido_id);A cláusula FOREIGN KEY (pedido_id) REFERENCES pedidos (id) ON DELETE CASCADE estabelece a relação entre as tabelas: cada ItemPedido pertence a um Pedido, e se o pedido for deletado, todos os seus itens são deletados automaticamente em cascata. Note que o SQLite requer que você habilite as foreign keys explicitamente com PRAGMA foreign_keys = ON — o sqflite não faz isso automaticamente. Você pode habilitá-las no callback onConfigure do openDatabase.
Modelando Entidades com toMap e fromMap
A comunicação entre o seu código Dart e o banco de dados SQLite acontece através de mapas (Map<String, dynamic>). O sqflite representa cada linha do banco como um Map onde as chaves são os nomes das colunas e os valores são os dados. Para converter entre as suas classes de domínio Dart e esse formato de mapa, a convenção estabelecida é implementar dois métodos: toMap(), que converte um objeto para um mapa compatível com o SQLite, e o construtor de fábrica fromMap(), que cria um objeto a partir de um mapa lido do banco.
// Classe Produto com serialização para SQLite
class Produto {
final int? id;
final String nome;
final String categoria;
final double preco;
final String descricao;
final bool disponivel;
final DateTime? sincronizadoEm;
Produto({
this.id,
required this.nome,
required this.categoria,
required this.preco,
required this.descricao,
required this.disponivel,
this.sincronizadoEm,
});
// Converte o objeto para um Map compatível com o SQLite
Map<String, dynamic> toMap() {
return {
// Não inclui 'id' pois é AUTOINCREMENT — o banco gera automaticamente
if (id != null) 'id': id,
'nome': nome,
'categoria': categoria,
'preco': preco,
'descricao': descricao,
// Converte bool para int (0 ou 1) pois SQLite não tem tipo bool nativo
'disponivel': disponivel ? 1 : 0,
// Converte DateTime para String ISO 8601 para armazenamento
'sincronizado_em': sincronizadoEm?.toIso8601String(),
};
}
// Cria um Produto a partir de um Map lido do SQLite
factory Produto.fromMap(Map<String, dynamic> map) {
return Produto(
id: map['id'] as int?,
nome: map['nome'] as String,
categoria: map['categoria'] as String,
preco: (map['preco'] as num).toDouble(),
descricao: map['descricao'] as String,
// Converte int (0 ou 1) de volta para bool
disponivel: (map['disponivel'] as int) == 1,
// Converte String ISO 8601 de volta para DateTime, se existir
sincronizadoEm: map['sincronizado_em'] != null
? DateTime.parse(map['sincronizado_em'] as String)
: null,
);
}
// Método copyWith para criar cópias com alterações pontuais
Produto copyWith({
int? id,
String? nome,
String? categoria,
double? preco,
String? descricao,
bool? disponivel,
DateTime? sincronizadoEm,
}) {
return Produto(
id: id ?? this.id,
nome: nome ?? this.nome,
categoria: categoria ?? this.categoria,
preco: preco ?? this.preco,
descricao: descricao ?? this.descricao,
disponivel: disponivel ?? this.disponivel,
sincronizadoEm: sincronizadoEm ?? this.sincronizadoEm,
);
}
}
// Classe Pedido com serialização para SQLite
class Pedido {
final int? id;
final String usuarioId;
final String status;
final double valorTotal;
final DateTime criadoEm;
final bool sincronizado;
final List<ItemPedido> itens;
Pedido({
this.id,
required this.usuarioId,
required this.status,
required this.valorTotal,
required this.criadoEm,
this.sincronizado = false,
this.itens = const [],
});
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'usuario_id': usuarioId,
'status': status,
'valor_total': valorTotal,
'criado_em': criadoEm.toIso8601String(),
'sincronizado': sincronizado ? 1 : 0,
// 'itens' NÃO é incluído pois é uma tabela separada
};
}
factory Pedido.fromMap(Map<String, dynamic> map, {List<ItemPedido>? itens}) {
return Pedido(
id: map['id'] as int?,
usuarioId: map['usuario_id'] as String,
status: map['status'] as String,
valorTotal: (map['valor_total'] as num).toDouble(),
criadoEm: DateTime.parse(map['criado_em'] as String),
sincronizado: (map['sincronizado'] as int) == 1,
itens: itens ?? const [],
);
}
Pedido copyWith({
int? id,
String? usuarioId,
String? status,
double? valorTotal,
DateTime? criadoEm,
bool? sincronizado,
List<ItemPedido>? itens,
}) {
return Pedido(
id: id ?? this.id,
usuarioId: usuarioId ?? this.usuarioId,
status: status ?? this.status,
valorTotal: valorTotal ?? this.valorTotal,
criadoEm: criadoEm ?? this.criadoEm,
sincronizado: sincronizado ?? this.sincronizado,
itens: itens ?? this.itens,
);
}
}
// Classe ItemPedido com serialização para SQLite
class ItemPedido {
final int? id;
final int pedidoId;
final int produtoId;
final String nomeProduto;
final double precoUnitario;
final int quantidade;
ItemPedido({
this.id,
required this.pedidoId,
required this.produtoId,
required this.nomeProduto,
required this.precoUnitario,
required this.quantidade,
});
double get subtotal => precoUnitario * quantidade;
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'pedido_id': pedidoId,
'produto_id': produtoId,
'nome_produto': nomeProduto,
'preco_unitario': precoUnitario,
'quantidade': quantidade,
};
}
factory ItemPedido.fromMap(Map<String, dynamic> map) {
return ItemPedido(
id: map['id'] as int?,
pedidoId: map['pedido_id'] as int,
produtoId: map['produto_id'] as int,
nomeProduto: map['nome_produto'] as String,
precoUnitario: (map['preco_unitario'] as num).toDouble(),
quantidade: map['quantidade'] as int,
);
}
}import 'package:meta/meta.dart';
@immutable
final class Produto {
const Produto({
this.id,
required this.nome,
required this.categoria,
required this.preco,
required this.descricao,
required this.disponivel,
this.sincronizadoEm,
});
final int? id;
final String nome;
final String categoria;
final double preco;
final String descricao;
final bool disponivel;
final DateTime? sincronizadoEm;
Map<String, dynamic> toMap() => {
if (id != null) 'id': id,
'nome': nome,
'categoria': categoria,
'preco': preco,
'descricao': descricao,
'disponivel': disponivel ? 1 : 0,
'sincronizado_em': sincronizadoEm?.toIso8601String(),
};
factory Produto.fromMap(Map<String, dynamic> m) => Produto(
id: m['id'] as int?,
nome: m['nome'] as String,
categoria: m['categoria'] as String,
preco: (m['preco'] as num).toDouble(),
descricao: m['descricao'] as String,
disponivel: m['disponivel'] == 1,
sincronizadoEm: m['sincronizado_em'] != null
? DateTime.parse(m['sincronizado_em'] as String)
: null,
);
Produto copyWith({
int? id,
String? nome,
String? categoria,
double? preco,
String? descricao,
bool? disponivel,
DateTime? sincronizadoEm,
}) =>
Produto(
id: id ?? this.id,
nome: nome ?? this.nome,
categoria: categoria ?? this.categoria,
preco: preco ?? this.preco,
descricao: descricao ?? this.descricao,
disponivel: disponivel ?? this.disponivel,
sincronizadoEm: sincronizadoEm ?? this.sincronizadoEm,
);
}
@immutable
final class Pedido {
const Pedido({
this.id,
required this.usuarioId,
required this.status,
required this.valorTotal,
required this.criadoEm,
this.sincronizado = false,
this.itens = const [],
});
final int? id;
final String usuarioId;
final String status;
final double valorTotal;
final DateTime criadoEm;
final bool sincronizado;
final List<ItemPedido> itens;
Map<String, dynamic> toMap() => {
if (id != null) 'id': id,
'usuario_id': usuarioId,
'status': status,
'valor_total': valorTotal,
'criado_em': criadoEm.toIso8601String(),
'sincronizado': sincronizado ? 1 : 0,
};
factory Pedido.fromMap(Map<String, dynamic> m, {List<ItemPedido>? itens}) =>
Pedido(
id: m['id'] as int?,
usuarioId: m['usuario_id'] as String,
status: m['status'] as String,
valorTotal: (m['valor_total'] as num).toDouble(),
criadoEm: DateTime.parse(m['criado_em'] as String),
sincronizado: m['sincronizado'] == 1,
itens: itens ?? const [],
);
Pedido copyWith({
int? id,
String? usuarioId,
String? status,
double? valorTotal,
DateTime? criadoEm,
bool? sincronizado,
List<ItemPedido>? itens,
}) =>
Pedido(
id: id ?? this.id,
usuarioId: usuarioId ?? this.usuarioId,
status: status ?? this.status,
valorTotal: valorTotal ?? this.valorTotal,
criadoEm: criadoEm ?? this.criadoEm,
sincronizado: sincronizado ?? this.sincronizado,
itens: itens ?? this.itens,
);
}
@immutable
final class ItemPedido {
const ItemPedido({
this.id,
required this.pedidoId,
required this.produtoId,
required this.nomeProduto,
required this.precoUnitario,
required this.quantidade,
});
final int? id;
final int pedidoId;
final int produtoId;
final String nomeProduto;
final double precoUnitario;
final int quantidade;
double get subtotal => precoUnitario * quantidade;
Map<String, dynamic> toMap() => {
if (id != null) 'id': id,
'pedido_id': pedidoId,
'produto_id': produtoId,
'nome_produto': nomeProduto,
'preco_unitario': precoUnitario,
'quantidade': quantidade,
};
factory ItemPedido.fromMap(Map<String, dynamic> m) => ItemPedido(
id: m['id'] as int?,
pedidoId: m['pedido_id'] as int,
produtoId: m['produto_id'] as int,
nomeProduto: m['nome_produto'] as String,
precoUnitario: (m['preco_unitario'] as num).toDouble(),
quantidade: m['quantidade'] as int,
);
}Operações CRUD com sqflite
As quatro operações fundamentais de um banco de dados — Create, Read, Update, Delete, normalmente abreviadas como CRUD — correspondem às instruções SQL INSERT, SELECT, UPDATE e DELETE. O sqflite oferece métodos de conveniência para cada uma dessas operações, além de suporte a SQL bruto (raw) para consultas mais complexas.
Inserindo Registros
O método insert do sqflite recebe o nome da tabela e um mapa com os dados a inserir. Ele retorna o id gerado pelo banco para o novo registro — o valor AUTOINCREMENT atribuído pelo SQLite. Esse retorno é valioso porque permite que você associe o novo ID ao objeto Dart correspondente sem precisar fazer uma consulta adicional.
import 'package:sqflite/sqflite.dart';
class ProdutoDao {
final Database _db;
ProdutoDao(this._db);
// Insere um produto e retorna o ID gerado pelo banco
Future<int> inserir(Produto produto) async {
final id = await _db.insert(
'produtos',
produto.toMap(),
// Se já existir um produto com o mesmo id, substitui (útil na sincronização)
conflictAlgorithm: ConflictAlgorithm.replace,
);
return id;
}
}
class PedidoDao {
final Database _db;
PedidoDao(this._db);
// Insere um pedido (sem os itens — eles são inseridos separadamente em uma transação)
Future<int> inserir(Pedido pedido) async {
final id = await _db.insert(
'pedidos',
pedido.toMap(),
conflictAlgorithm: ConflictAlgorithm.fail,
);
return id;
}
// Insere um item de pedido e retorna o ID gerado
Future<int> inserirItem(ItemPedido item) async {
final id = await _db.insert(
'itens_pedido',
item.toMap(),
conflictAlgorithm: ConflictAlgorithm.fail,
);
return id;
}
}import 'package:sqflite/sqflite.dart';
final class ProdutoDao {
const ProdutoDao(this._db);
final Database _db;
Future<int> inserir(Produto produto) =>
_db.insert('produtos', produto.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
}
final class PedidoDao {
const PedidoDao(this._db);
final Database _db;
Future<int> inserir(Pedido pedido) =>
_db.insert('pedidos', pedido.toMap(),
conflictAlgorithm: ConflictAlgorithm.fail);
Future<int> inserirItem(ItemPedido item) =>
_db.insert('itens_pedido', item.toMap(),
conflictAlgorithm: ConflictAlgorithm.fail);
}Consultando Dados
O sqflite oferece dois caminhos para consultas. O método query é uma API de alto nível orientada a parâmetros que constrói a instrução SQL internamente — você especifica o nome da tabela, quais colunas retornar, a cláusula WHERE e a ordenação. O método rawQuery aceita SQL puro, o que é necessário para consultas mais complexas envolvendo JOIN, subconsultas ou funções de agregação.
class ProdutoDao {
final Database _db;
ProdutoDao(this._db);
// Busca todos os produtos disponíveis
Future<List<Produto>> buscarDisponiveis() async {
// O método query constrói o SQL automaticamente
final mapas = await _db.query(
'produtos',
where: 'disponivel = ?',
whereArgs: [1],
orderBy: 'nome ASC',
);
// Converte cada mapa para um objeto Produto
return mapas.map((mapa) => Produto.fromMap(mapa)).toList();
}
// Busca produtos por categoria
Future<List<Produto>> buscarPorCategoria(String categoria) async {
final mapas = await _db.query(
'produtos',
where: 'categoria = ? AND disponivel = ?',
whereArgs: [categoria, 1],
orderBy: 'preco ASC',
);
return mapas.map((mapa) => Produto.fromMap(mapa)).toList();
}
// Busca um produto pelo ID
Future<Produto?> buscarPorId(int id) async {
final mapas = await _db.query(
'produtos',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (mapas.isEmpty) return null;
return Produto.fromMap(mapas.first);
}
}
class PedidoDao {
final Database _db;
PedidoDao(this._db);
// Busca todos os pedidos de um usuário com seus itens — usa rawQuery para o JOIN
Future<List<Pedido>> buscarPedidosComItens(String usuarioId) async {
// Primeiro, busca os pedidos do usuário
final pedidosMapas = await _db.query(
'pedidos',
where: 'usuario_id = ?',
whereArgs: [usuarioId],
orderBy: 'criado_em DESC',
);
// Para cada pedido, busca os itens correspondentes
final pedidos = <Pedido>[];
for (final pedidoMapa in pedidosMapas) {
final pedidoId = pedidoMapa['id'] as int;
final itensMapas = await _db.query(
'itens_pedido',
where: 'pedido_id = ?',
whereArgs: [pedidoId],
);
final itens = itensMapas.map((m) => ItemPedido.fromMap(m)).toList();
pedidos.add(Pedido.fromMap(pedidoMapa, itens: itens));
}
return pedidos;
}
// Busca pedidos não sincronizados usando rawQuery com condição simples
Future<List<Pedido>> buscarNaoSincronizados() async {
final pedidosMapas = await _db.rawQuery(
'SELECT * FROM pedidos WHERE sincronizado = 0 ORDER BY criado_em ASC',
);
final pedidos = <Pedido>[];
for (final pedidoMapa in pedidosMapas) {
final pedidoId = pedidoMapa['id'] as int;
final itensMapas = await _db.query(
'itens_pedido',
where: 'pedido_id = ?',
whereArgs: [pedidoId],
);
final itens = itensMapas.map((m) => ItemPedido.fromMap(m)).toList();
pedidos.add(Pedido.fromMap(pedidoMapa, itens: itens));
}
return pedidos;
}
}final class ProdutoDao {
const ProdutoDao(this._db);
final Database _db;
Future<List<Produto>> buscarDisponiveis() async => (await _db.query(
'produtos',
where: 'disponivel = ?',
whereArgs: const [1],
orderBy: 'nome ASC',
))
.map(Produto.fromMap)
.toList();
Future<List<Produto>> buscarPorCategoria(String categoria) async =>
(await _db.query(
'produtos',
where: 'categoria = ? AND disponivel = ?',
whereArgs: [categoria, 1],
orderBy: 'preco ASC',
))
.map(Produto.fromMap)
.toList();
Future<Produto?> buscarPorId(int id) async {
final rows = await _db.query(
'produtos',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
return rows.isEmpty ? null : Produto.fromMap(rows.first);
}
}
final class PedidoDao {
const PedidoDao(this._db);
final Database _db;
Future<List<ItemPedido>> _itens(int pedidoId) async =>
(await _db.query('itens_pedido',
where: 'pedido_id = ?', whereArgs: [pedidoId]))
.map(ItemPedido.fromMap)
.toList();
Future<List<Pedido>> buscarPedidosComItens(String usuarioId) async {
final rows = await _db.query('pedidos',
where: 'usuario_id = ?',
whereArgs: [usuarioId],
orderBy: 'criado_em DESC');
return Future.wait(rows.map(
(m) async => Pedido.fromMap(m, itens: await _itens(m['id'] as int))));
}
Future<List<Pedido>> buscarNaoSincronizados() async {
final rows = await _db.rawQuery(
'SELECT * FROM pedidos WHERE sincronizado = 0 ORDER BY criado_em ASC');
return Future.wait(rows.map(
(m) async => Pedido.fromMap(m, itens: await _itens(m['id'] as int))));
}
}Atualizando Registros
O método update recebe o nome da tabela, o mapa com os campos a atualizar, a cláusula WHERE e seus argumentos. Ele retorna o número de linhas afetadas pela operação — zero significa que nenhum registro correspondeu ao critério de busca.
class PedidoDao {
final Database _db;
PedidoDao(this._db);
// Atualiza o status de um pedido
Future<int> atualizarStatus(int pedidoId, String novoStatus) async {
final linhasAfetadas = await _db.update(
'pedidos',
{'status': novoStatus},
where: 'id = ?',
whereArgs: [pedidoId],
);
return linhasAfetadas;
}
// Marca um pedido como sincronizado com o backend
Future<void> marcarComoSincronizado(int pedidoId) async {
await _db.update(
'pedidos',
{'sincronizado': 1},
where: 'id = ?',
whereArgs: [pedidoId],
);
}
// Atualiza todos os campos de um pedido existente
Future<int> atualizar(Pedido pedido) async {
if (pedido.id == null) {
throw ArgumentError('Pedido sem ID não pode ser atualizado.');
}
return _db.update(
'pedidos',
pedido.toMap(),
where: 'id = ?',
whereArgs: [pedido.id],
);
}
}
class ProdutoDao {
final Database _db;
ProdutoDao(this._db);
// Alterna a disponibilidade de um produto
Future<void> alternarDisponibilidade(int produtoId, bool disponivel) async {
await _db.update(
'produtos',
{'disponivel': disponivel ? 1 : 0},
where: 'id = ?',
whereArgs: [produtoId],
);
}
}final class PedidoDao {
const PedidoDao(this._db);
final Database _db;
Future<int> atualizarStatus(int id, String status) =>
_db.update('pedidos', {'status': status},
where: 'id = ?', whereArgs: [id]);
Future<void> marcarComoSincronizado(int id) =>
_db.update('pedidos', {'sincronizado': 1},
where: 'id = ?', whereArgs: [id]);
Future<int> atualizar(Pedido pedido) {
assert(pedido.id != null, 'Pedido sem ID não pode ser atualizado.');
return _db.update('pedidos', pedido.toMap(),
where: 'id = ?', whereArgs: [pedido.id]);
}
}
final class ProdutoDao {
const ProdutoDao(this._db);
final Database _db;
Future<int> alternarDisponibilidade(int id, bool disponivel) =>
_db.update('produtos', {'disponivel': disponivel ? 1 : 0},
where: 'id = ?', whereArgs: [id]);
}Excluindo Registros
O método delete recebe o nome da tabela, a cláusula WHERE e seus argumentos. Assim como o update, ele retorna o número de linhas deletadas. Se você não passar nenhuma cláusula WHERE, todas as linhas da tabela serão deletadas — o equivalente a um DELETE FROM tabela sem condição.
class ProdutoDao {
final Database _db;
ProdutoDao(this._db);
// Deleta um produto pelo ID
Future<int> deletar(int produtoId) async {
final linhasAfetadas = await _db.delete(
'produtos',
where: 'id = ?',
whereArgs: [produtoId],
);
return linhasAfetadas;
}
// Deleta todos os produtos de uma categoria
Future<int> deletarPorCategoria(String categoria) async {
return _db.delete(
'produtos',
where: 'categoria = ?',
whereArgs: [categoria],
);
}
}
class PedidoDao {
final Database _db;
PedidoDao(this._db);
// Deleta um pedido (os itens são deletados em cascata pela FOREIGN KEY ON DELETE CASCADE)
Future<int> deletar(int pedidoId) async {
return _db.delete(
'pedidos',
where: 'id = ?',
whereArgs: [pedidoId],
);
}
// Deleta pedidos entregues com mais de 30 dias para liberar espaço
Future<int> deletarAntigos(DateTime antes) async {
return _db.delete(
'pedidos',
where: "status = 'entregue' AND criado_em < ?",
whereArgs: [antes.toIso8601String()],
);
}
}final class ProdutoDao {
const ProdutoDao(this._db);
final Database _db;
Future<int> deletar(int id) =>
_db.delete('produtos', where: 'id = ?', whereArgs: [id]);
Future<int> deletarPorCategoria(String categoria) =>
_db.delete('produtos', where: 'categoria = ?', whereArgs: [categoria]);
}
final class PedidoDao {
const PedidoDao(this._db);
final Database _db;
Future<int> deletar(int id) =>
_db.delete('pedidos', where: 'id = ?', whereArgs: [id]);
Future<int> deletarAntigos(DateTime antes) => _db.delete(
'pedidos',
where: "status = 'entregue' AND criado_em < ?",
whereArgs: [antes.toIso8601String()],
);
}Transações: Garantindo Consistência
Uma transação é um conjunto de operações de banco de dados que é executado de forma atômica: ou todas as operações são concluídas com sucesso e confirmadas no banco, ou nenhuma delas é aplicada. Essa propriedade, chamada de atomicidade, é fundamental para garantir a integridade dos dados em situações onde múltiplas operações dependem umas das outras.
No Projeto Integrador, o exemplo mais evidente de operação que requer uma transação é a criação de um pedido com seus itens. Um pedido não existe sem seus itens, e itens não existem sem um pedido. Se o aplicativo inserir o pedido com sucesso mas falhar ao inserir algum dos itens — devido a uma queda de energia, uma exceção Dart ou qualquer outra falha —, o banco ficaria com um pedido incompleto. Com uma transação, se qualquer operação falhar, o banco reverte automaticamente para o estado anterior à transação, como se nada tivesse acontecido.
O sqflite expõe o método transaction que recebe um callback assíncrono. Dentro do callback, você recebe um objeto txn do tipo Transaction, que implementa a mesma interface da Database — você pode usar txn.insert, txn.query, txn.update e txn.delete da mesma forma que usaria no objeto Database diretamente. Se o callback lançar uma exceção, a transação é revertida automaticamente (rollback). Se o callback completar sem exceção, a transação é confirmada (commit).
class PedidoDao {
final Database _db;
PedidoDao(this._db);
// Insere um pedido completo com todos os seus itens em uma única transação
Future<Pedido> inserirCompleto(Pedido pedido) async {
late int pedidoId;
await _db.transaction((txn) async {
// 1. Insere o pedido e obtém o ID gerado
pedidoId = await txn.insert(
'pedidos',
pedido.toMap(),
conflictAlgorithm: ConflictAlgorithm.fail,
);
// 2. Insere cada item do pedido com o ID do pedido recém-criado
for (final item in pedido.itens) {
// Cria uma nova instância do item com o pedidoId correto
final itemComPedidoId = ItemPedido(
pedidoId: pedidoId,
produtoId: item.produtoId,
nomeProduto: item.nomeProduto,
precoUnitario: item.precoUnitario,
quantidade: item.quantidade,
);
await txn.insert(
'itens_pedido',
itemComPedidoId.toMap(),
conflictAlgorithm: ConflictAlgorithm.fail,
);
}
// Se qualquer operação acima lançar uma exceção, toda a transação é revertida
});
// Retorna o pedido com o ID atribuído pelo banco
return pedido.copyWith(id: pedidoId);
}
}final class PedidoDao {
const PedidoDao(this._db);
final Database _db;
Future<Pedido> inserirCompleto(Pedido pedido) async {
final pedidoId = await _db.transaction((txn) async {
final id = await txn.insert('pedidos', pedido.toMap(),
conflictAlgorithm: ConflictAlgorithm.fail);
await Future.wait(pedido.itens.map((item) => txn.insert(
'itens_pedido',
ItemPedido(
pedidoId: id,
produtoId: item.produtoId,
nomeProduto: item.nomeProduto,
precoUnitario: item.precoUnitario,
quantidade: item.quantidade,
).toMap(),
conflictAlgorithm: ConflictAlgorithm.fail,
)));
return id;
});
return pedido.copyWith(id: pedidoId);
}
}É importante entender que o sqflite usa um executor serial para as operações de banco de dados: todas as operações são enfileiradas e executadas uma por vez. Isso significa que não há risco de condições de corrida entre diferentes operações sendo executadas simultaneamente. A transação garante a atomicidade em nível lógico; o executor serial garante a segurança em nível de concorrência.
Migrações de Schema: Evoluindo o Banco de Dados
O banco de dados de um aplicativo em produção raramente permanece estático. À medida que o aplicativo evolui e novas funcionalidades são adicionadas, o schema precisa evoluir também: novas colunas são adicionadas, índices são criados, tabelas antigas são renomeadas. O desafio é que os usuários já têm o aplicativo instalado, com dados reais no banco. Você não pode simplesmente recriar o banco do zero sem perder os dados deles.
O mecanismo de migração do sqflite funciona através do parâmetro version da função openDatabase. Quando o banco é criado pela primeira vez, o callback onCreate é chamado com a versão especificada. Quando o aplicativo é atualizado e detecta que a versão do banco instalado é inferior à versão especificada no código, o callback onUpgrade é chamado com a versão antiga e a nova versão como parâmetros. Você usa esses valores para aplicar as alterações de schema de forma incremental.
-- Exemplo de migração: versão 1 para versão 2
-- Adicionando coluna 'imagem_url' na tabela produtos
ALTER TABLE produtos ADD COLUMN imagem_url TEXT;
-- Exemplo: versão 2 para versão 3
-- Adicionando tabela de avaliações de pedidos
CREATE TABLE avaliacoes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pedido_id INTEGER NOT NULL,
nota INTEGER NOT NULL CHECK(nota BETWEEN 1 AND 5),
comentario TEXT,
criado_em TEXT NOT NULL,
FOREIGN KEY (pedido_id) REFERENCES pedidos (id) ON DELETE CASCADE
);import 'package:sqflite/sqflite.dart';
class DatabaseHelper {
// Incrementar esta versão a cada novo release que altere o schema
static const int _versao = 3;
static Future<Database> abrir(String caminho) async {
return openDatabase(
caminho,
version: _versao,
onConfigure: (db) async {
// Habilita suporte a foreign keys (necessário explicitamente no SQLite)
await db.execute('PRAGMA foreign_keys = ON');
},
onCreate: (db, versao) async {
// Cria o schema completo da versão atual
await db.transaction((txn) async {
await txn.execute(_sqlProdutos);
await txn.execute(_sqlPedidos);
await txn.execute(_sqlItens);
await txn.execute(_sqlAvaliacoes); // adicionado na versão 3
await txn.execute(_sqlIndicesPedidos);
});
},
onUpgrade: (db, versaoAntiga, versaoNova) async {
// Aplica migrações incrementais baseado na versão antiga
if (versaoAntiga < 2) {
// Migração da versão 1 para 2: adiciona imagem_url nos produtos
await db.execute(
'ALTER TABLE produtos ADD COLUMN imagem_url TEXT',
);
}
if (versaoAntiga < 3) {
// Migração da versão 2 para 3: adiciona tabela de avaliações
await db.execute(_sqlAvaliacoes);
}
},
);
}
static const String _sqlProdutos = '''
CREATE TABLE produtos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
categoria TEXT NOT NULL,
preco REAL NOT NULL,
descricao TEXT NOT NULL,
disponivel INTEGER NOT NULL DEFAULT 1,
imagem_url TEXT,
sincronizado_em TEXT
)
''';
static const String _sqlPedidos = '''
CREATE TABLE pedidos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
usuario_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pendente',
valor_total REAL NOT NULL,
criado_em TEXT NOT NULL,
sincronizado INTEGER NOT NULL DEFAULT 0
)
''';
static const String _sqlItens = '''
CREATE TABLE itens_pedido (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pedido_id INTEGER NOT NULL,
produto_id INTEGER NOT NULL,
nome_produto TEXT NOT NULL,
preco_unitario REAL NOT NULL,
quantidade INTEGER NOT NULL,
FOREIGN KEY (pedido_id) REFERENCES pedidos (id) ON DELETE CASCADE
)
''';
static const String _sqlAvaliacoes = '''
CREATE TABLE avaliacoes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pedido_id INTEGER NOT NULL,
nota INTEGER NOT NULL,
comentario TEXT,
criado_em TEXT NOT NULL,
FOREIGN KEY (pedido_id) REFERENCES pedidos (id) ON DELETE CASCADE
)
''';
static const String _sqlIndicesPedidos = '''
CREATE INDEX idx_pedidos_usuario ON pedidos (usuario_id)
''';
}import 'package:sqflite/sqflite.dart';
final class DatabaseHelper {
DatabaseHelper._();
static final instance = DatabaseHelper._();
static const _versao = 3;
Database? _db;
Future<Database> get db async => _db ??= await _open();
Future<Database> _open() async {
final dir = await getDatabasesPath();
return openDatabase(
'$dir/delivery.db',
version: _versao,
onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: (db, _) => db.transaction((t) => Future.wait([
t.execute(_sqlProdutos),
t.execute(_sqlPedidos),
t.execute(_sqlItens),
t.execute(_sqlAvaliacoes),
t.execute('CREATE INDEX idx_pedidos_usuario ON pedidos (usuario_id)'),
])),
onUpgrade: (db, old, _) async {
if (old < 2) await db.execute('ALTER TABLE produtos ADD COLUMN imagem_url TEXT');
if (old < 3) await db.execute(_sqlAvaliacoes);
},
);
}
static const _sqlProdutos = '''CREATE TABLE produtos (
id INTEGER PRIMARY KEY AUTOINCREMENT, nome TEXT NOT NULL,
categoria TEXT NOT NULL, preco REAL NOT NULL, descricao TEXT NOT NULL,
disponivel INTEGER NOT NULL DEFAULT 1, imagem_url TEXT, sincronizado_em TEXT)''';
static const _sqlPedidos = '''CREATE TABLE pedidos (
id INTEGER PRIMARY KEY AUTOINCREMENT, usuario_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pendente', valor_total REAL NOT NULL,
criado_em TEXT NOT NULL, sincronizado INTEGER NOT NULL DEFAULT 0)''';
static const _sqlItens = '''CREATE TABLE itens_pedido (
id INTEGER PRIMARY KEY AUTOINCREMENT, pedido_id INTEGER NOT NULL,
produto_id INTEGER NOT NULL, nome_produto TEXT NOT NULL,
preco_unitario REAL NOT NULL, quantidade INTEGER NOT NULL,
FOREIGN KEY (pedido_id) REFERENCES pedidos (id) ON DELETE CASCADE)''';
static const _sqlAvaliacoes = '''CREATE TABLE avaliacoes (
id INTEGER PRIMARY KEY AUTOINCREMENT, pedido_id INTEGER NOT NULL,
nota INTEGER NOT NULL, comentario TEXT, criado_em TEXT NOT NULL,
FOREIGN KEY (pedido_id) REFERENCES pedidos (id) ON DELETE CASCADE)''';
}Nunca altere uma migração que já foi lançada em produção. Se você percebeu que cometeu um erro em uma migração que já chegou aos dispositivos dos usuários, escreva uma nova migração para corrigir o problema — não modifique a migração existente. Modificar uma migração existente não afetará os usuários que já a aplicaram, e pode causar inconsistências entre dispositivos com diferentes versões.
O Padrão Repository Aplicado à Persistência Local
A Interface de Repositório no Domínio
Um dos princípios centrais da arquitetura que você está aprendendo nesta disciplina é a separação entre o que o sistema precisa fazer (domínio) e como ele faz (infraestrutura). Esse princípio se manifesta de forma muito clara na persistência de dados através do padrão Repository.
A ideia é simples: em vez de ter o seu Provider ou o seu caso de uso chamando diretamente _db.query('produtos', ...), você define uma interface abstrata — um contrato — que especifica quais operações de persistência existem, sem dizer como elas são implementadas. Essa interface vive na camada de domínio, que é pura Dart e não conhece nada sobre SQLite, HTTP ou qualquer outro detalhe de infraestrutura.
// lib/features/cardapio/domain/repositories/i_produto_repository.dart
// Interface abstrata que define o contrato de persistência de produtos
// Esta classe vive no domínio — não importa nada de sqflite, http, etc.
abstract class IProdutoRepository {
// Retorna todos os produtos disponíveis
Future<List<Produto>> buscarDisponiveis();
// Retorna produtos de uma categoria específica
Future<List<Produto>> buscarPorCategoria(String categoria);
// Retorna um produto por ID, ou null se não encontrar
Future<Produto?> buscarPorId(int id);
// Salva um produto (insere se sem ID, atualiza se com ID)
Future<Produto> salvar(Produto produto);
// Remove um produto pelo ID
Future<void> remover(int id);
}
// lib/features/pedidos/domain/repositories/i_pedido_repository.dart
abstract class IPedidoRepository {
// Retorna os pedidos de um usuário, incluindo os itens
Future<List<Pedido>> buscarPorUsuario(String usuarioId);
// Cria um pedido completo (com todos os itens) de forma atômica
Future<Pedido> criar(Pedido pedido);
// Atualiza o status de um pedido
Future<void> atualizarStatus(int pedidoId, String novoStatus);
// Retorna pedidos pendentes de sincronização com o backend
Future<List<Pedido>> buscarNaoSincronizados();
// Marca um pedido como sincronizado
Future<void> marcarSincronizado(int pedidoId);
}// lib/features/cardapio/domain/repositories/i_produto_repository.dart
abstract interface class IProdutoRepository {
Future<List<Produto>> buscarDisponiveis();
Future<List<Produto>> buscarPorCategoria(String categoria);
Future<Produto?> buscarPorId(int id);
Future<Produto> salvar(Produto produto);
Future<void> remover(int id);
}
// lib/features/pedidos/domain/repositories/i_pedido_repository.dart
abstract interface class IPedidoRepository {
Future<List<Pedido>> buscarPorUsuario(String usuarioId);
Future<Pedido> criar(Pedido pedido);
Future<void> atualizarStatus(int pedidoId, String novoStatus);
Future<List<Pedido>> buscarNaoSincronizados();
Future<void> marcarSincronizado(int pedidoId);
}A palavra-chave abstract interface class disponível a partir do Dart 3.0 é a forma idiomática de definir um contrato puro: uma classe que só pode ser implementada (usando implements), não estendida (usando extends), e que não fornece nenhuma implementação padrão. Isso reforça que a interface é exclusivamente um contrato.
A Implementação Concreta na Infraestrutura
Com a interface definida no domínio, você cria a implementação concreta na camada de infraestrutura. Essa classe conhece os detalhes do SQLite, usa os DAOs que você criou anteriormente e implementa todos os métodos definidos na interface.
// lib/features/cardapio/infrastructure/repositories/sqflite_produto_repository.dart
import 'package:sqflite/sqflite.dart';
// Importa a interface do domínio — a infraestrutura conhece o domínio, não vice-versa
import '../../domain/repositories/i_produto_repository.dart';
class SqfliteProdutoRepository implements IProdutoRepository {
final Database _db;
SqfliteProdutoRepository(this._db);
@override
Future<List<Produto>> buscarDisponiveis() async {
final mapas = await _db.query(
'produtos',
where: 'disponivel = ?',
whereArgs: [1],
orderBy: 'categoria ASC, nome ASC',
);
return mapas.map((m) => Produto.fromMap(m)).toList();
}
@override
Future<List<Produto>> buscarPorCategoria(String categoria) async {
final mapas = await _db.query(
'produtos',
where: 'categoria = ? AND disponivel = ?',
whereArgs: [categoria, 1],
orderBy: 'nome ASC',
);
return mapas.map((m) => Produto.fromMap(m)).toList();
}
@override
Future<Produto?> buscarPorId(int id) async {
final mapas = await _db.query(
'produtos',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (mapas.isEmpty) return null;
return Produto.fromMap(mapas.first);
}
@override
Future<Produto> salvar(Produto produto) async {
if (produto.id == null) {
// Novo produto: insere e retorna com o ID gerado
final id = await _db.insert(
'produtos',
produto.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
return produto.copyWith(id: id);
} else {
// Produto existente: atualiza e retorna o próprio objeto
await _db.update(
'produtos',
produto.toMap(),
where: 'id = ?',
whereArgs: [produto.id],
);
return produto;
}
}
@override
Future<void> remover(int id) async {
await _db.delete(
'produtos',
where: 'id = ?',
whereArgs: [id],
);
}
}// lib/features/cardapio/infrastructure/repositories/sqflite_produto_repository.dart
final class SqfliteProdutoRepository implements IProdutoRepository {
const SqfliteProdutoRepository(this._db);
final Database _db;
@override
Future<List<Produto>> buscarDisponiveis() async => (await _db.query(
'produtos',
where: 'disponivel = ?',
whereArgs: const [1],
orderBy: 'categoria ASC, nome ASC',
))
.map(Produto.fromMap)
.toList();
@override
Future<List<Produto>> buscarPorCategoria(String categoria) async =>
(await _db.query(
'produtos',
where: 'categoria = ? AND disponivel = ?',
whereArgs: [categoria, 1],
orderBy: 'nome ASC',
))
.map(Produto.fromMap)
.toList();
@override
Future<Produto?> buscarPorId(int id) async {
final rows = await _db.query('produtos',
where: 'id = ?', whereArgs: [id], limit: 1);
return rows.isEmpty ? null : Produto.fromMap(rows.first);
}
@override
Future<Produto> salvar(Produto produto) async {
if (produto.id == null) {
final id = await _db.insert('produtos', produto.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
return produto.copyWith(id: id);
}
await _db.update('produtos', produto.toMap(),
where: 'id = ?', whereArgs: [produto.id]);
return produto;
}
@override
Future<void> remover(int id) =>
_db.delete('produtos', where: 'id = ?', whereArgs: [id]);
}Registrando o Repositório no GetIt
Com a interface e a implementação definidas, o último passo é configurar o container de injeção de dependência — o GetIt — para que, quando qualquer parte do código solicitar um IProdutoRepository, o GetIt entregue uma instância de SqfliteProdutoRepository. Essa configuração acontece uma única vez, na inicialização do aplicativo.
// lib/core/di/injection.dart
import 'package:get_it/get_it.dart';
import 'package:path/path.dart' as path;
import 'package:sqflite/sqflite.dart';
// A instância global do GetIt — o ponto central de acesso ao container de DI
final getIt = GetIt.instance;
// Configura todas as dependências do aplicativo
// Deve ser chamado em main(), antes de runApp()
Future<void> configurarDependencias() async {
// 1. Configura o banco de dados como um singleton — uma única instância para todo o app
final diretorioBancos = await getDatabasesPath();
final caminhoBanco = path.join(diretorioBancos, 'delivery.db');
final db = await DatabaseHelper.abrir(caminhoBanco);
getIt.registerSingleton<Database>(db);
// 2. Registra os repositórios — o GetIt resolve a dependência do Database automaticamente
getIt.registerSingleton<IProdutoRepository>(
SqfliteProdutoRepository(getIt<Database>()),
);
getIt.registerSingleton<IPedidoRepository>(
SqflitePedidoRepository(getIt<Database>()),
);
// 3. Registra o serviço de preferências
getIt.registerSingleton<PreferenciasService>(PreferenciasService());
}// lib/core/di/injection.dart
import 'package:get_it/get_it.dart';
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
final sl = GetIt.instance;
Future<void> configurarDependencias() async {
final db = await DatabaseHelper.instance.db;
sl
..registerSingleton<Database>(db)
..registerSingleton<IProdutoRepository>(SqfliteProdutoRepository(db))
..registerSingleton<IPedidoRepository>(SqflitePedidoRepository(db))
..registerSingleton<PreferenciasService>(PreferenciasService());
}Estratégias de Cache: Combinando Dados Locais e Remotos
O verdadeiro poder da persistência local no desenvolvimento mobile não está apenas em guardar dados — está em combiná-los inteligentemente com dados remotos para criar uma experiência fluida e resiliente à instabilidade de rede. Essa combinação é chamada de estratégia de cache, e existem diferentes abordagens com diferentes compensações.
A necessidade de uma estratégia de cache surge naturalmente do contexto de uso de um aplicativo mobile. O usuário pode estar em um ambiente com conexão excelente, com conexão lenta, com conexão intermitente ou completamente offline. O aplicativo precisa funcionar bem em todos esses cenários, e a estratégia de cache é o mecanismo que permite isso.
O Padrão Cache-Aside
O padrão Cache-Aside (também chamado de Lazy Loading) é a abordagem mais comum e mais fácil de entender. A lógica é: quando o aplicativo precisa de um dado, ele primeiro verifica se existe uma versão local válida. Se existir e for suficientemente recente, usa a versão local. Se não existir ou estiver desatualizada, busca do servidor, atualiza o cache local e retorna o dado. O diagrama a seguir ilustra esse fluxo:
flowchart TD
A["Usuário abre a tela\nde cardápio"] --> B{"Existe cache\nlocal de produtos?"}
B -- Não --> C["Busca produtos\nno servidor remoto"]
B -- Sim --> D{"O cache foi\natualizado há\nmenos de 30 min?"}
D -- Sim --> E["Exibe produtos\ndo cache local\n(instantâneo)"]
D -- Não --> F["Exibe produtos\ndo cache local\n(enquanto atualiza)"]
F --> G["Atualiza cache\nem segundo plano"]
C --> H{"Requisição\nbem-sucedida?"}
H -- Sim --> I["Salva produtos\nno SQLite local"]
H -- Não --> J["Exibe erro\nou cache antigo"]
I --> K["Exibe produtos\nda resposta do servidor"]
G --> L["Atualiza a\ninterface silenciosamente"]
A variação do Cache-Aside chamada de Stale-While-Revalidate é particularmente elegante: você exibe imediatamente os dados do cache (mesmo que possam estar um pouco desatualizados) para dar uma resposta instantânea ao usuário, e ao mesmo tempo dispara uma requisição em segundo plano para buscar os dados mais recentes. Quando a resposta chega, você atualiza silenciosamente a interface. Essa abordagem maximiza a percepção de velocidade do aplicativo — o usuário nunca vê uma tela de carregamento para dados que já estão no cache.
No Projeto Integrador, você pode aplicar o Stale-While-Revalidate ao catálogo de produtos. Quando a tela de listagem é aberta, o Provider carrega imediatamente os produtos do banco local e notifica a interface. Simultaneamente, ele inicia uma requisição ao servidor. Quando a resposta chega, ele atualiza o banco local e notifica a interface novamente com os dados frescos.
class CatalogoNotifier extends ChangeNotifier {
final IProdutoRepository _repositorioLocal;
final IProdutoRemoteDataSource _fonteDadosRemota;
final PreferenciasService _preferencias;
CatalogoNotifier({
required IProdutoRepository repositorioLocal,
required IProdutoRemoteDataSource fonteDadosRemota,
required PreferenciasService preferencias,
}) : _repositorioLocal = repositorioLocal,
_fonteDadosRemota = fonteDadosRemota,
_preferencias = preferencias;
List<Produto> _produtos = [];
bool _carregando = false;
String? _erro;
List<Produto> get produtos => _produtos;
bool get carregando => _carregando;
String? get erro => _erro;
// Carrega produtos usando a estratégia Stale-While-Revalidate
Future<void> carregarProdutos() async {
// Passo 1: Carrega do banco local imediatamente (resposta instantânea)
final produtosLocais = await _repositorioLocal.buscarDisponiveis();
if (produtosLocais.isNotEmpty) {
_produtos = produtosLocais;
notifyListeners(); // Interface já pode exibir os dados
} else {
// Se o cache está vazio, mostra indicador de carregamento
_carregando = true;
notifyListeners();
}
// Passo 2: Tenta buscar dados frescos do servidor em segundo plano
try {
final produtosRemotos = await _fonteDadosRemota.buscarProdutos();
// Passo 3: Atualiza o banco local com os dados frescos
for (final produto in produtosRemotos) {
await _repositorioLocal.salvar(produto);
}
// Passo 4: Recarrega do banco local (agora com dados atualizados)
_produtos = await _repositorioLocal.buscarDisponiveis();
_erro = null;
} catch (e) {
// Se falhou, mantém os dados do cache local sem mostrar erro
// (a menos que o cache também estivesse vazio)
if (_produtos.isEmpty) {
_erro = 'Sem conexão e sem dados em cache. Tente novamente.';
}
} finally {
_carregando = false;
notifyListeners();
}
}
}final class CatalogoNotifier extends ChangeNotifier {
CatalogoNotifier({
required IProdutoRepository repositorioLocal,
required IProdutoRemoteDataSource fonteDadosRemota,
}) : _local = repositorioLocal,
_remoto = fonteDadosRemota;
final IProdutoRepository _local;
final IProdutoRemoteDataSource _remoto;
var _produtos = <Produto>[];
var _carregando = false;
String? _erro;
List<Produto> get produtos => _produtos;
bool get carregando => _carregando;
String? get erro => _erro;
Future<void> carregarProdutos() async {
final cache = await _local.buscarDisponiveis();
if (cache.isNotEmpty) {
_produtos = cache;
notifyListeners();
} else {
_carregando = true;
notifyListeners();
}
try {
final remotos = await _remoto.buscarProdutos();
await Future.wait(remotos.map(_local.salvar));
_produtos = await _local.buscarDisponiveis();
_erro = null;
} catch (_) {
if (_produtos.isEmpty) _erro = 'Sem conexão e sem dados em cache.';
} finally {
_carregando = false;
notifyListeners();
}
}
}Sincronização com o Backend Após Reconexão
O modo offline-first exige não apenas ler dados do cache, mas também salvar operações do usuário localmente quando não há conexão e sincronizá-las quando a conexão for restabelecida. No Projeto Integrador, isso significa que quando o usuário finaliza um pedido sem internet, o pedido deve ser salvo localmente com o flag sincronizado = 0 e enviado ao servidor quando a conexão for restabelecida.
Essa sincronização pode ser disparada de duas formas: ativamente, quando o usuário volta a uma tela que exige dados remotos, ou reativamente, monitorando o estado da conectividade com o pacote connectivity_plus. A lógica de sincronização percorre os pedidos com sincronizado = 0, tenta enviá-los ao servidor e, em caso de sucesso, marca cada um como sincronizado.
class SincronizacaoService {
final IPedidoRepository _repositorioLocal;
final IPedidoRemoteDataSource _fonteDadosRemota;
SincronizacaoService({
required IPedidoRepository repositorioLocal,
required IPedidoRemoteDataSource fonteDadosRemota,
}) : _repositorioLocal = repositorioLocal,
_fonteDadosRemota = fonteDadosRemota;
// Sincroniza todos os pedidos pendentes com o backend
// Retorna o número de pedidos sincronizados com sucesso
Future<int> sincronizarPedidosPendentes() async {
// Busca todos os pedidos que ainda não foram enviados ao servidor
final pedidosPendentes = await _repositorioLocal.buscarNaoSincronizados();
int sincronizados = 0;
for (final pedido in pedidosPendentes) {
try {
// Tenta enviar o pedido ao servidor remoto
await _fonteDadosRemota.criarPedido(pedido);
// Se o envio foi bem-sucedido, marca como sincronizado
await _repositorioLocal.marcarSincronizado(pedido.id!);
sincronizados++;
} catch (e) {
// Se falhou para este pedido, continua tentando os próximos
// O pedido permanece com sincronizado = 0 para nova tentativa
continue;
}
}
return sincronizados;
}
}final class SincronizacaoService {
const SincronizacaoService({
required IPedidoRepository repositorioLocal,
required IPedidoRemoteDataSource fonteDadosRemota,
}) : _local = repositorioLocal,
_remoto = fonteDadosRemota;
final IPedidoRepository _local;
final IPedidoRemoteDataSource _remoto;
Future<int> sincronizarPedidosPendentes() async {
final pendentes = await _local.buscarNaoSincronizados();
var count = 0;
for (final pedido in pendentes) {
try {
await _remoto.criarPedido(pedido);
await _local.marcarSincronizado(pedido.id!);
count++;
} catch (_) {
continue;
}
}
return count;
}
}Integrando Persistência com o Provider
Você aprendeu no Módulo 07 que os ChangeNotifier são o veículo do estado de aplicação no Flutter com Provider. A integração com a persistência local é direta: o ChangeNotifier usa o repositório para carregar dados ao ser inicializado e para persistir alterações quando o usuário realiza ações. O Provider garante que todos os widgets interessados sejam notificados quando o estado muda, independentemente de onde a mudança ocorreu — seja de uma operação local, seja de uma atualização remota.
O exemplo a seguir mostra um PedidoNotifier completo que gerencia o ciclo de vida dos pedidos: carregamento inicial, criação de novos pedidos, atualização de status e sincronização com o backend.
// lib/features/pedidos/presentation/notifiers/pedido_notifier.dart
class PedidoNotifier extends ChangeNotifier {
final IPedidoRepository _repositorio;
final SincronizacaoService _sincronizacao;
PedidoNotifier({
required IPedidoRepository repositorio,
required SincronizacaoService sincronizacao,
}) : _repositorio = repositorio,
_sincronizacao = sincronizacao;
List<Pedido> _pedidos = [];
bool _carregando = false;
String? _erro;
List<Pedido> get pedidos => _pedidos;
bool get carregando => _carregando;
String? get erro => _erro;
// Carrega todos os pedidos do usuário
Future<void> carregarPedidos(String usuarioId) async {
_carregando = true;
_erro = null;
notifyListeners();
try {
_pedidos = await _repositorio.buscarPorUsuario(usuarioId);
} catch (e) {
_erro = 'Erro ao carregar pedidos: $e';
} finally {
_carregando = false;
notifyListeners();
}
}
// Cria um novo pedido e o salva localmente
// A sincronização com o servidor é feita em segundo plano
Future<void> criarPedido(Pedido novoPedido) async {
try {
// Salva o pedido localmente (com sincronizado = false)
final pedidoCriado = await _repositorio.criar(novoPedido);
// Adiciona o novo pedido à lista local imediatamente
_pedidos = [pedidoCriado, ..._pedidos];
notifyListeners();
// Tenta sincronizar em segundo plano (não bloqueia a UI)
_sincronizacao.sincronizarPedidosPendentes();
} catch (e) {
_erro = 'Erro ao criar pedido: $e';
notifyListeners();
}
}
// Atualiza o status de um pedido
Future<void> atualizarStatus(int pedidoId, String novoStatus) async {
try {
await _repositorio.atualizarStatus(pedidoId, novoStatus);
// Atualiza o status na lista local sem precisar recarregar tudo
_pedidos = _pedidos.map((p) {
if (p.id == pedidoId) {
return p.copyWith(status: novoStatus);
}
return p;
}).toList();
notifyListeners();
} catch (e) {
_erro = 'Erro ao atualizar status: $e';
notifyListeners();
}
}
}// lib/features/pedidos/presentation/notifiers/pedido_notifier.dart
final class PedidoNotifier extends ChangeNotifier {
PedidoNotifier({
required IPedidoRepository repositorio,
required SincronizacaoService sincronizacao,
}) : _repo = repositorio,
_sync = sincronizacao;
final IPedidoRepository _repo;
final SincronizacaoService _sync;
var _pedidos = <Pedido>[];
var _carregando = false;
String? _erro;
List<Pedido> get pedidos => List.unmodifiable(_pedidos);
bool get carregando => _carregando;
String? get erro => _erro;
Future<void> carregarPedidos(String usuarioId) async {
(_carregando, _erro) = (true, null);
notifyListeners();
try {
_pedidos = await _repo.buscarPorUsuario(usuarioId);
} catch (e) {
_erro = 'Erro ao carregar pedidos: $e';
} finally {
_carregando = false;
notifyListeners();
}
}
Future<void> criarPedido(Pedido pedido) async {
try {
final criado = await _repo.criar(pedido);
_pedidos = [criado, ..._pedidos];
notifyListeners();
unawaited(_sync.sincronizarPedidosPendentes());
} catch (e) {
_erro = 'Erro ao criar pedido: $e';
notifyListeners();
}
}
Future<void> atualizarStatus(int id, String status) async {
try {
await _repo.atualizarStatus(id, status);
_pedidos = [
for (final p in _pedidos)
if (p.id == id) p.copyWith(status: status) else p,
];
notifyListeners();
} catch (e) {
_erro = 'Erro ao atualizar status: $e';
notifyListeners();
}
}
}A conexão entre o PedidoNotifier e os widgets da interface é feita com o Provider que você já conhece do Módulo 07. O ChangeNotifierProvider envolve a subárvore de widgets que precisam acessar os pedidos, e o Consumer ou context.watch<PedidoNotifier>() dentro de cada widget permite reagir às mudanças de estado. A novidade deste módulo é que o estado agora tem persistência: ele sobrevive ao fechamento e reabertura do aplicativo.
Boas Práticas e Considerações Importantes
A persistência de dados é uma das áreas onde erros de implementação têm consequências duradouras. Ao contrário de um bug na interface que o usuário pode fechar e reabrir, um dado corrompido ou perdido no banco permanece corrompido ou perdido. As práticas a seguir são fruto de experiência acumulada com aplicativos em produção.
Nunca execute operações de banco de dados ou de arquivo na thread principal da UI. Todas as operações de I/O devem ser async/await para não bloquear a interface. O sqflite já é intrinsecamente assíncrono, mas você deve estar atento a chamadas síncronas acidentais — por exemplo, nunca chame métodos de banco dentro de um build(). Se uma operação de banco travar a thread de UI por mais de 16ms, o Flutter vai dropar frames e o usuário perceberá travamentos.
Trate erros de banco de dados de forma explícita. O sqflite lança exceções como DatabaseException quando ocorrem erros — violações de constraint, problemas de I/O, banco corrompido. Envolva as operações de banco em blocos try/catch nos repositórios e propague os erros de forma significativa para a camada de apresentação, onde o usuário pode ser informado.
Nunca guarde dados sensíveis no SQLite sem criptografia, nem no SharedPreferences. O arquivo de banco de dados do SQLite e o arquivo de preferências são legíveis por qualquer processo com acesso root no Android. Tokens de autenticação, senhas, chaves criptográficas e outros dados sensíveis devem ser guardados exclusivamente no flutter_secure_storage, que usa o Keystore/Keychain do sistema operacional com criptografia no nível de hardware. Você estudará isso em detalhes no Módulo 11.
Faça backup do banco antes de lançar migrações em produção. Se uma migração for executada incorretamente, os dados dos usuários podem ser corrompidos ou perdidos permanentemente. Em desenvolvimento, a prática de testar migrações consiste em desinstalar o aplicativo (apagando o banco), instalar a versão antiga, popular com dados, instalar a versão nova e verificar que os dados foram migrados corretamente. Nunca presuma que uma migração funciona sem testá-la end-to-end.
Não exponha a camada de infraestrutura para o resto da aplicação. Os ChangeNotifier, os casos de uso e quaisquer outros componentes de nível superior devem depender apenas das interfaces definidas no domínio (IProdutoRepository, IPedidoRepository), nunca das implementações concretas (SqfliteProdutoRepository). Essa disciplina é o que permite trocar a implementação de persistência — por exemplo, adicionar um cache em memória, ou substituir o SQLite por outro banco — sem alterar nada fora da camada de infraestrutura.
Há também considerações sobre o ciclo de vida do banco de dados que merecem atenção. O objeto Database do sqflite é thread-safe: você pode usar a mesma instância de múltiplos isolates do Dart sem risco de corrupção. No entanto, você deve fechar o banco quando o aplicativo é encerrado de forma limpa, chamando db.close(). Na prática, a maioria dos aplicativos Flutter não implementa esse fechamento explícito porque o sistema operacional limpa os recursos quando o processo termina — mas é uma boa prática, especialmente em testes automatizados onde você cria e destrói bancos com frequência.
Outra consideração prática é o tamanho do banco de dados ao longo do tempo. Um banco que começa pequeno pode crescer significativamente se pedidos antigos, logs e dados de cache não forem periodicamente purgados. Implemente uma rotina de limpeza que remove pedidos entregues com mais de X dias, trunca logs antigos e remove itens de cache expirados. Essa rotina pode ser executada na inicialização do aplicativo, de forma assíncrona e sem bloquear a UI.
Síntese do Módulo
O que você aprendeu e para onde vamos
Neste módulo, você percorreu os três pilares da persistência local em Flutter. Começou pelo SharedPreferences, compreendendo seu propósito preciso — configurações simples do usuário — e seus limites igualmente precisos. Avançou para o path_provider e o sistema de arquivos, aprendendo a gerar e guardar arquivos como comprovantes e logs. Mergulhou no sqflite com profundidade, desde a criação do schema e as migrações, passando pelas operações CRUD e transações, até a modelagem das entidades do Projeto Integrador com toMap e fromMap.
Mais importante do que os mecanismos técnicos, você aprendeu a aplicar o padrão Repository para separar claramente a abstração (domínio) da implementação (infraestrutura), e a usar o GetIt para injetar as dependências corretas sem acoplamento. Você também estudou as estratégias de cache — especialmente o Stale-While-Revalidate — e como combiná-las com a sincronização offline para criar uma experiência de usuário resiliente a falhas de rede.
No Módulo 09, você estudará o consumo de APIs REST com o pacote http. Com a persistência local que você implementou neste módulo, o próximo passo natural é conectar os dados locais com os dados remotos do servidor, implementando o repositório remoto que complementa o repositório local. O padrão Repository que você aprendeu aqui será o ponto de extensão: basta criar uma nova implementação que usa HTTP em vez de SQLite e configurar o GetIt para injetar a implementação correta dependendo do contexto.
O aplicativo de delivery está se tornando uma aplicação real: com estado gerenciado pelo Provider, persistência local com SQLite e, em breve, comunicação com o backend AWS. Cada módulo adiciona uma camada de completude ao projeto.