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.
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:
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:
A shopping list app sounds trivial until you picture the user standing in the supermarket
aisle, no signal, with the app stuck on an endless spinner. Offline-first
isn't a feature — it's an architectural decision that has to be made before writing the first
line of code. In this article I explain how I implemented this approach in
Lista da Casa using Flutter and Drift.
Why Drift instead of SharedPreferences or Hive?
For simple data (app theme, last screen visited, preferences), SharedPreferences
gets the job done. For serialized objects with no relations, Hive is fast and
easy. But when you need to:
Filter items by category
Sort by name or date added
Count how many items are still unchecked
Handle multiple lists with related items
...you need SQL. Drift (formerly known as Moor) is a type-safe layer on top
of SQLite that runs natively on Android and iOS. You define tables as Dart classes, write
queries with compile-time verification, and get reactive Streams automatically.
No SQL in strings, no runtime errors from a typo in a column name.
sqlite3_flutter_libs includes the SQLite binary compiled for Android and iOS.
build_runner together with drift_dev is used to generate the
boilerplate code from the table definitions.
Defining the items table
// 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)();
}
Each field is a typed column. autoIncrement() creates the primary key
automatically. nullable() indicates the category is optional. Drift generates
strongly typed getters and setters from this definition.
Creating the database
// 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'; // generated by build_runner
@DriftDatabase(tables: [ItensCompra])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
// Returns a Stream that re-emits whenever the table changes
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);
});
}
After creating the file, run dart run build_runner build to generate
app_database.g.dart with all the SQL code under the hood.
Integrating with Riverpod
The database is instantiated once via a Provider and shared across the whole app:
// lib/providers/database_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
final dbProvider = Provider<AppDatabase>(
(ref) => AppDatabase(),
// Closes the connection when the provider is disposed
onDispose: (db) => db.close(),
);
// Stream of all items — updates the UI automatically
final itensProvider = StreamProvider<List<ItemCompra>>(
(ref) => ref.watch(dbProvider).watchItens(),
);
On the main screen, StreamProvider handles all the reactivity:
When the user marks an item as purchased, Drift updates the database, the Stream
emits the new data, and Riverpod rebuilds the widget automatically. No
setState, no notifyListeners, no manual state management.
The result: truly offline UX
The app opens instantly — no splash screen waiting on a connection, no data lost between
sessions, no "no internet" message. The local SQLite database is the source of truth. If the
user stays offline for days, the data is still there, intact.
For apps that need to sync with the cloud in the future, the same SQLite database can serve
as the local cache layer while Firestore acts as the remote backend — a pattern known as
local-first sync.
Drift has an initial learning curve because of build_runner and code generation.
But once the project is set up, writing queries and migrations is safe, predictable, and far
more pleasant than concatenating SQL strings. It's worth the investment.
Check out Lista da Casa, available for free on Google Play:
Una app de lista de compras parece trivial hasta que piensas en el usuario parado en el
pasillo del supermercado, sin señal de internet, con la app atascada en un spinner infinito.
Offline-first no es una funcionalidad — es una decisión arquitectónica que
debe tomarse antes de escribir la primera línea de código. En este artículo explico cómo
implementé este enfoque en Lista da Casa usando Flutter y Drift.
¿Por qué Drift y no SharedPreferences o Hive?
Para datos simples (tema de la app, última pantalla visitada, preferencias),
SharedPreferences resuelve. Para objetos serializados sin relaciones,
Hive es rápido y fácil. Pero cuando necesitas:
Filtrar ítems por categoría
Ordenar por nombre o fecha de creación
Contar cuántos ítems faltan marcar
Manejar múltiples listas con ítems relacionados
...necesitas SQL. Drift (antes llamado Moor) es una capa type-safe sobre
SQLite que corre nativamente en Android e iOS. Defines las tablas como clases Dart, escribes
queries con verificación en tiempo de compilación y recibes Streams reactivos
automáticamente. Sin SQL en strings, sin errores en tiempo de ejecución por un typo en el
nombre de una columna.
sqlite3_flutter_libs incluye el binario de SQLite compilado para Android e iOS.
build_runner junto con drift_dev se usa para generar el código
boilerplate a partir de las definiciones de las tablas.
Definiendo la tabla de ítems
// 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 es una columna tipada. autoIncrement() crea la clave primaria
automáticamente. nullable() indica que la categoría es opcional. Drift genera
getters y setters fuertemente tipados a partir de esta definición.
Creando la base de datos
// 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'; // generado por build_runner
@DriftDatabase(tables: [ItensCompra])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
// Devuelve un Stream que se re-emite cada vez que la tabla cambia
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);
});
}
Después de crear el archivo, ejecuta dart run build_runner build para generar
app_database.g.dart con todo el código SQL por debajo.
Integrando con Riverpod
La base de datos se instancia una vez mediante un Provider y se comparte con toda la
aplicación:
// lib/providers/database_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
final dbProvider = Provider<AppDatabase>(
(ref) => AppDatabase(),
// Cierra la conexión cuando se descarta el provider
onDispose: (db) => db.close(),
);
// Stream de todos los ítems — actualiza la UI automáticamente
final itensProvider = StreamProvider<List<ItemCompra>>(
(ref) => ref.watch(dbProvider).watchItens(),
);
En la pantalla principal, StreamProvider se encarga de toda la reactividad:
Cuando el usuario marca un ítem como comprado, Drift actualiza la base de datos, el
Stream emite los nuevos datos y Riverpod reconstruye el widget automáticamente.
Sin setState, sin notifyListeners, sin gestión manual de estado.
El resultado: una UX verdaderamente offline
La app se abre al instante — sin splash screen esperando conexión, sin datos perdidos entre
sesiones, sin mensaje de "sin internet". El SQLite local es la fuente de la verdad. Si el
usuario permanece offline durante días, los datos siguen ahí, intactos.
Para apps que necesiten sincronizar con la nube en el futuro, la misma base de datos SQLite
puede servir como capa de caché local mientras Firestore funciona como backend remoto — un
patrón conocido como local-first sync.
Drift tiene una curva de aprendizaje inicial por causa de build_runner y la
generación de código. Pero una vez que el proyecto está configurado, escribir queries y
migraciones es seguro, predecible y mucho más agradable que concatenar strings SQL. Vale la
inversión.
Descubre Lista da Casa, disponible gratis en Google Play: