Um app de lista de compras parece trivial até você pensar no usuário parado no corredor do supermercado, sem sinal de internet, com o app travando em um spinner infinito. Offline-first não é uma funcionalidade — é uma decisão arquitetural que precisa ser tomada antes de escrever a primeira linha de código. Neste artigo explico como implementei essa abordagem na Lista da Casa usando Flutter e Drift.
Por que Drift e não SharedPreferences ou Hive?
Para dados simples (tema do app, última tela visitada, preferências), SharedPreferences resolve. Para objetos serializados sem relações, Hive é rápido e fácil. Mas quando você precisa de:
- Filtrar itens por categoria
- Ordenar por nome ou data de adição
- Contar quantos itens ainda faltam marcar
- Múltiplas listas com itens relacionados
...você precisa de SQL. O Drift (anteriormente chamado Moor) é uma camada
type-safe sobre SQLite que roda nativamente no Android e iOS. Você define tabelas como
classes Dart, escreve queries com verificação em tempo de compilação e recebe
Streams reativos automaticamente. Sem SQL em strings, sem runtime errors por
typo em nome de coluna.
Configurando o Drift no projeto
Adicione as dependências no pubspec.yaml:
dependencies:
flutter:
sdk: flutter
drift: ^2.16.0
sqlite3_flutter_libs: ^0.5.24
path_provider: ^2.1.3
path: ^1.9.0
dev_dependencies:
drift_dev: ^2.16.0
build_runner: ^2.4.9
O sqlite3_flutter_libs inclui o binário SQLite compilado para Android e iOS.
O build_runner com drift_dev é usado para gerar o código boilerplate
a partir das definições de tabela.
Definindo a tabela de itens
// lib/data/database/tables/itens_compra.dart
import 'package:drift/drift.dart';
class ItensCompra extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get nome => text().withLength(min: 1, max: 100)();
TextColumn get categoria => text().nullable()();
BoolColumn get comprado =>
boolean().withDefault(const Constant(false))();
DateTimeColumn get criadoEm =>
dateTime().withDefault(currentDateAndTime)();
}
Cada campo é uma coluna tipada. autoIncrement() cria a chave primária
automaticamente. nullable() indica que a categoria é opcional. O Drift
gera getters e setters fortemente tipados a partir dessa definição.
Criando o banco de dados
// lib/data/database/app_database.dart
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
part 'app_database.g.dart'; // gerado pelo build_runner
@DriftDatabase(tables: [ItensCompra])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
// Retorna um Stream que re-emite sempre que a tabela muda
Stream<List<ItemCompra>> watchItens() =>
select(itensCompra).watch();
Future<int> inserirItem(ItensCompraCompanion item) =>
into(itensCompra).insert(item);
Future<void> marcarComprado(int id, bool comprado) =>
(update(itensCompra)..where((t) => t.id.equals(id)))
.write(ItensCompraCompanion(comprado: Value(comprado)));
Future<int> deletarItem(int id) =>
(delete(itensCompra)..where((t) => t.id.equals(id))).go();
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'lista_da_casa.sqlite'));
return NativeDatabase(file);
});
}
Depois de criar o arquivo, execute dart run build_runner build para gerar
o app_database.g.dart com todo o código SQL por baixo dos panos.
Integrando com Riverpod
O banco é instanciado uma vez via Provider e compartilhado com toda a aplicação:
// lib/providers/database_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
final dbProvider = Provider<AppDatabase>(
(ref) => AppDatabase(),
// Fecha a conexão quando o provider for descartado
onDispose: (db) => db.close(),
);
// Stream de todos os itens — atualiza a UI automaticamente
final itensProvider = StreamProvider<List<ItemCompra>>(
(ref) => ref.watch(dbProvider).watchItens(),
);
Na tela principal, o StreamProvider cuida de toda a reatividade:
class ListaScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final itensAsync = ref.watch(itensProvider);
return itensAsync.when(
data: (itens) => ListView.builder(
itemCount: itens.length,
itemBuilder: (_, i) => ItemTile(item: itens[i]),
),
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Erro: $e'),
);
}
}
Quando o usuário marca um item como comprado, o Drift atualiza o banco, o Stream
emite os dados novos e o Riverpod reconstrói a widget automaticamente. Sem
setState, sem notifyListeners, sem gerenciamento manual de estado.
O resultado: UX verdadeiramente offline
O app abre instantaneamente — sem splash screen esperando conexão, sem dados perdidos entre sessões, sem mensagem de "sem internet". O SQLite local é a fonte de verdade. Se o usuário ficar offline por dias, os dados continuam lá, intactos.
Para apps que precisam sincronizar com a nuvem no futuro, o mesmo banco SQLite pode ser a camada de cache local enquanto o Firestore funciona como backend remoto — um padrão conhecido como local-first sync.
Drift tem uma curva de aprendizado inicial por causa do build_runner e da
geração de código. Mas depois que o projeto está configurado, escrever queries e migrations
é seguro, previsível e muito mais agradável do que concatenar strings SQL. Vale o
investimento.
Veja a Lista da Casa disponível gratuitamente no Google Play:
Baixar Lista da Casa