Seu aplicativo funciona muito bem no Wi-Fi da faculdade. Aí o usuário entra no elevador, no metrô, ou visita um cliente num galpão sem sinal. A tela trava, aparece um spinner eterno, e o que ele digitou nos últimos dois minutos evapora.
Esse é o comportamento padrão de um app que trata a rede como se ela sempre estivesse lá. E a rede, em celular, nunca está sempre lá.
Neste artigo você vai construir a espinha dorsal de um app offline-first em Flutter: o app grava tudo localmente primeiro, responde na hora, e sincroniza com o servidor quando a conexão voltar. O exemplo é uma lista de visitas agendadas, e o código usa as versões atuais dos pacotes.
A inversão que define offline-first
A maioria dos tutoriais ensina o fluxo assim: a tela chama a API, espera a resposta, e mostra o resultado. O banco local, quando existe, é um cache que “ajuda” quando dá erro.
Offline-first inverte isso:
- O banco local é a fonte da verdade. A tela lê dele e só dele. Sempre.
- A escrita é local e imediata. O usuário salva, o dado entra no SQLite, a tela atualiza. Acabou a interação.
- A rede é um detalhe de segundo plano. Um processo separado leva o que está pendente para o servidor quando houver conexão.
A consequência prática é que não existe mais tela de carregamento esperando a rede, e nem tratamento de erro de conexão no meio do fluxo do usuário. O app fica rápido porque ler SQLite é rápido, e fica confiável porque não depende de algo que ele não controla.
As dependências
No pubspec.yaml, com as versões publicadas no pub.dev:
dependencies:
flutter:
sdk: flutter
sqflite: ^2.4.3 # banco local SQLite
path_provider: ^2.1.6 # descobre onde gravar o arquivo do banco
dio: ^5.11.0 # cliente HTTP
connectivity_plus: ^7.3.1 # detecta mudanca de rede
uuid: ^4.6.0 # gera id local antes de existir id do servidor
Este exemplo foi escrito para o Flutter 3.47, o release estável atual da linha de agosto de 2026. O sqflite cuida do banco, o connectivity_plus avisa quando a rede muda, e o uuid resolve um problema específico que aparece já a seguir.
Passo 1: o modelo precisa saber se já foi sincronizado
Aqui está a primeira decisão que separa um app offline-first de um app com cache. Cada registro carrega o próprio estado de sincronização:
enum SyncStatus { synced, pending, deleted }
class Visita {
final String id; // uuid gerado no app, nao no servidor
final String cliente;
final DateTime agendadaPara;
final SyncStatus status;
final DateTime atualizadaEm;
Visita({
required this.id,
required this.cliente,
required this.agendadaPara,
this.status = SyncStatus.pending,
required this.atualizadaEm,
});
Visita copyWith({SyncStatus? status}) => Visita(
id: id,
cliente: cliente,
agendadaPara: agendadaPara,
status: status ?? this.status,
atualizadaEm: atualizadaEm,
);
Map<String, Object?> toMap() => {
'id': id,
'cliente': cliente,
'agendada_para': agendadaPara.toIso8601String(),
'status': status.name,
'atualizada_em': atualizadaEm.toIso8601String(),
};
factory Visita.fromMap(Map<String, Object?> map) => Visita(
id: map['id'] as String,
cliente: map['cliente'] as String,
agendadaPara: DateTime.parse(map['agendada_para'] as String),
status: SyncStatus.values.byName(map['status'] as String),
atualizadaEm: DateTime.parse(map['atualizada_em'] as String),
);
}
Dois detalhes que valem explicação.
O id vem do app, não do servidor. Se o id fosse gerado pelo backend, você não teria como identificar o registro enquanto estivesse offline. Gerando um UUID no dispositivo, o registro nasce com identidade e a sincronização vira uma operação idempotente — mandar duas vezes não cria duplicata.
Exclusão é um estado, não um DELETE. Se o usuário apaga uma visita offline e você remove a linha do SQLite, o servidor nunca fica sabendo. Por isso o status deleted: a linha continua no banco local até a exclusão ser confirmada lá.
Passo 2: o banco local
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
class BancoLocal {
static Database? _db;
static Future<Database> get instancia async {
_db ??= await _abrir();
return _db!;
}
static Future<Database> _abrir() async {
final caminho = join(await getDatabasesPath(), 'visitas.db');
return openDatabase(
caminho,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE visitas (
id TEXT PRIMARY KEY,
cliente TEXT NOT NULL,
agendada_para TEXT NOT NULL,
status TEXT NOT NULL,
atualizada_em TEXT NOT NULL
)
''');
// Sem este indice, a busca por pendentes varre a tabela inteira
// a cada tentativa de sincronizacao.
await db.execute(
'CREATE INDEX idx_visitas_status ON visitas(status)',
);
},
);
}
}
Passo 3: o repositório, que a tela enxerga
Este é o único ponto que a interface conhece. Repare que nenhum método aqui toca na rede:
import 'package:uuid/uuid.dart';
class VisitaRepository {
final _uuid = const Uuid();
/// A tela sempre le daqui. Nunca da rede.
Future<List<Visita>> listar() async {
final db = await BancoLocal.instancia;
final linhas = await db.query(
'visitas',
where: 'status != ?',
whereArgs: [SyncStatus.deleted.name],
orderBy: 'agendada_para ASC',
);
return linhas.map(Visita.fromMap).toList();
}
/// Grava local e marca como pendente. Retorna imediatamente,
/// tenha rede ou nao.
Future<Visita> criar({
required String cliente,
required DateTime agendadaPara,
}) async {
final visita = Visita(
id: _uuid.v4(),
cliente: cliente,
agendadaPara: agendadaPara,
status: SyncStatus.pending,
atualizadaEm: DateTime.now().toUtc(),
);
final db = await BancoLocal.instancia;
await db.insert('visitas', visita.toMap());
return visita;
}
/// Exclusao logica: o servidor ainda precisa saber que isso sumiu.
Future<void> excluir(String id) async {
final db = await BancoLocal.instancia;
await db.update(
'visitas',
{
'status': SyncStatus.deleted.name,
'atualizada_em': DateTime.now().toUtc().toIso8601String(),
},
where: 'id = ?',
whereArgs: [id],
);
}
Future<List<Visita>> pendentes() async {
final db = await BancoLocal.instancia;
final linhas = await db.query(
'visitas',
where: 'status != ?',
whereArgs: [SyncStatus.synced.name],
);
return linhas.map(Visita.fromMap).toList();
}
}
Note o retorno de criar: ele devolve a visita imediatamente, sem await em nenhuma chamada HTTP. Do ponto de vista do usuário, salvar é instantâneo — offline ou não.
Passo 4: detectar a rede
Aqui mora a pegadinha mais comum em código copiado da internet:
import 'package:connectivity_plus/connectivity_plus.dart';
class Conectividade {
final _connectivity = Connectivity();
/// Atencao: desde a versao 6 o pacote devolve uma LISTA,
/// porque o aparelho pode ter Wi-Fi e dados moveis ao mesmo tempo.
Future<bool> temRede() async {
final List<ConnectivityResult> resultado =
await _connectivity.checkConnectivity();
return _online(resultado);
}
Stream<bool> get mudancas =>
_connectivity.onConnectivityChanged.map(_online);
bool _online(List<ConnectivityResult> resultado) =>
!resultado.contains(ConnectivityResult.none) && resultado.isNotEmpty;
}
A partir da versão 6 do connectivity_plus, tanto checkConnectivity() quanto o stream onConnectivityChanged devolvem List<ConnectivityResult>, e não um valor único. O motivo é real: um aparelho pode estar em Wi-Fi e dados móveis ao mesmo tempo. Se você copiar um exemplo antigo que espera um valor só, o código simplesmente não compila.
Um alerta que vale mais que o código: ter interface de rede não é o mesmo que ter internet. O Wi-Fi do aeroporto que exige login no navegador aparece como conectado. Trate esse retorno como um bom palpite para tentar sincronizar, nunca como garantia de que vai funcionar.
Passo 5: o sincronizador
import 'dart:async';
import 'package:dio/dio.dart';
class Sincronizador {
Sincronizador(this._repo, this._dio, this._conectividade);
final VisitaRepository _repo;
final Dio _dio;
final Conectividade _conectividade;
bool _rodando = false;
StreamSubscription<bool>? _assinatura;
/// Dispara a sincronizacao toda vez que a rede voltar.
void iniciar() {
_assinatura = _conectividade.mudancas.listen((online) {
if (online) sincronizar();
});
}
Future<void> dispose() async => _assinatura?.cancel();
Future<void> sincronizar() async {
// Trava simples: sem ela, duas mudancas de rede seguidas
// enviariam o mesmo registro duas vezes.
if (_rodando) return;
if (!await _conectividade.temRede()) return;
_rodando = true;
try {
for (final visita in await _repo.pendentes()) {
await _enviar(visita);
}
} finally {
_rodando = false;
}
}
Future<void> _enviar(Visita visita) async {
final db = await BancoLocal.instancia;
try {
if (visita.status == SyncStatus.deleted) {
await _dio.delete('/visitas/${visita.id}');
await db.delete('visitas', where: 'id = ?', whereArgs: [visita.id]);
return;
}
// PUT com id gerado no app deixa a operacao idempotente:
// reenviar o mesmo registro nao cria duplicata no servidor.
await _dio.put('/visitas/${visita.id}', data: visita.toMap());
await db.update(
'visitas',
{'status': SyncStatus.synced.name},
where: 'id = ?',
whereArgs: [visita.id],
);
} on DioException catch (e) {
final status = e.response?.statusCode ?? 0;
// 4xx e erro do dado: reenviar nao resolve e trava a fila.
// 5xx e rede sao temporarios: deixa pendente e tenta de novo.
if (status >= 400 && status < 500) {
// Simplificacao: marcar como 'synced' tira o registro da fila,
// mas mente sobre o que aconteceu. Em producao, crie um estado
// proprio (ex.: SyncStatus.rejected) e mostre ao usuario.
await db.update(
'visitas',
{'status': SyncStatus.synced.name},
where: 'id = ?',
whereArgs: [visita.id],
);
}
}
}
}
Três decisões desse código merecem atenção, porque são o que separa uma sincronização que funciona de uma que corrompe dados.
A trava _rodando. Mudanças de rede vêm em rajada — o Android costuma emitir vários eventos ao sair do modo avião. Sem a trava, duas execuções simultâneas enviariam o mesmo registro em paralelo.
PUT em vez de POST. Como o id já existe antes do envio, o servidor pode tratar a operação como “crie ou atualize este id”. Reenviar depois de um timeout deixa de ser risco de duplicata.
Erro 4xx e erro 5xx não são a mesma coisa. Um 422 por dado inválido nunca vai passar, por mais que você reenvie — e um registro assim entope a fila para sempre, impedindo os seguintes de subirem. Já um 500 ou uma queda de conexão são temporários e merecem nova tentativa. Confundir os dois é o bug mais chato dessa arquitetura, porque ele só aparece dias depois.
E quando o mesmo registro muda dos dois lados?
Esse é o problema difícil de offline-first, e é honesto dizer que não existe solução única.
A estratégia mais simples é last-write-wins: compara-se o atualizada_em dos dois lados e o mais recente vence. É o que o campo do modelo já prepara. Funciona bem quando um registro tem um dono claro, como a visita de um vendedor específico.
Quando várias pessoas editam o mesmo dado, last-write-wins descarta trabalho silenciosamente. Aí as saídas são guardar as duas versões e pedir para o usuário decidir, ou mudar o modelo para que as edições sejam somáveis em vez de substituíveis. A escolha é de produto, não de código — e é a pergunta certa a fazer antes de escrever a primeira linha.
Erros comuns
- Ler da API na tela “só quando tem rede”. Isso recria dois caminhos de dados e faz a interface piscar quando a conexão oscila. A tela lê do banco local, ponto.
- Deixar o servidor gerar o id. Offline você não tem id, e sem id não há como atualizar nem deduplicar depois.
- Apagar a linha na exclusão. O servidor nunca fica sabendo, e o registro reaparece na próxima sincronização vinda de lá.
- Reenviar erro 4xx para sempre. Um registro inválido trava a fila inteira atrás dele.
- Confiar que “conectado” significa “com internet”. Portal de Wi-Fi público aparece como conectado.
- Guardar data em fuso local. Use UTC no banco e converta só na exibição, senão a comparação de qual versão é mais recente erra quando o aparelho troca de fuso.
Por onde continuar
O que está aqui é o esqueleto: gravação local, fila de pendentes e envio no retorno da rede. A partir dele, dois passos naturais são adicionar a sincronização no sentido contrário — buscar do servidor o que mudou desde a última vez, usando um parâmetro de data — e mover o envio para segundo plano, com workmanager, para que a fila suba mesmo com o app fechado.
Mas comece pelo esqueleto. Um app que grava local e sobe depois já resolve a maior parte das reclamações reais de usuário, e é a base sobre a qual todo o resto se apoia.