Voltar para a página inicial

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:

...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