Offline não é um estado de erro
O app que mostra "sem conexão, tente novamente" no meio do talhão não tem bug: tem uma premissa errada sobre onde ele vai rodar. E o problema difícil nem é guardar offline — é o que acontece na volta.
Talhão 7, duas e vinte da tarde, 12 km de estrada de terra até a sede. O operador desce do trator com o celular na mão pra apontar o que acabou de fazer: herbicida pós-emergente, 1,8 L/ha, 63 hectares, safra 2025/2026. Ele abre o app, preenche os quatro campos, toca em salvar. A barra de sinal aparece, some, aparece de novo.
"Sem conexão. Verifique sua internet e tente novamente."
Ele guarda o celular no bolso e sobe no trator. Vai apontar depois, quando chegar na sede. Não vai.
Três semanas depois alguém no escritório fecha o custo por hectare do talhão 7 e o número vem baixo demais. Ninguém desconfia de um app que nunca deu erro em produção, que tem 99,4% de disponibilidade no painel, que não gerou um único ticket sobre isso. O dado simplesmente não existe. E dado que não existe não aparece em alerta nenhum — é o mesmo silêncio que faz pipeline de dados mentir, só que aqui ele começa na tela do celular.
"Tem internet?" é a pergunta errada
O reflexo do time, quando esse relato chega, é adicionar uma checagem. Chama connectivity_plus, olha se o resultado é diferente de ConnectivityResult.none, e a partir disso decide se salva no servidor ou mostra um aviso.
Só que connectivity_plus não responde a pergunta que você acha que fez. Ele diz que existe uma interface de rede ativa. Não diz que existe rota, não diz que o DNS resolve, e principalmente não diz que o seu servidor está do outro lado.
O caso clássico é o Wi-Fi do escritório da fazenda. O celular conecta, mostra ícone cheio, e o roteador está pendurado num rádio enlace que caiu de madrugada. Do ponto de vista da API do Flutter, esse aparelho está lindamente conectado. Todo request vai morrer em timeout de 30 segundos.
Conectividade não é booleano. O app vive num espectro com pelo menos cinco estados que importam: sem rádio nenhum; rádio sem rota; rota com o servidor fora; servidor respondendo em 180 ms; e servidor respondendo em 24 segundos porque o sinal caiu pra borda da torre. Os dois últimos quebram mais código que a ausência de rede, porque não disparam SocketException — eles só demoram, e enquanto demoram o usuário toca no botão de novo.
A pergunta útil não é "tem internet?". É: essa operação precisa de rede pra ser considerada concluída? Se a resposta for sim para o ato de registrar um apontamento de aplicação, o resto da arquitetura já não importa muito.
A inversão
Offline-first soa como funcionalidade e é, na verdade, uma inversão de quem manda. A fonte de verdade passa a ser o banco local — SQLite via Drift, ou Isar, tanto faz pra essa conversa — e a rede vira processo de fundo que reconcilia. Não é o caminho feliz com um catch caprichado no fim: é a rede saindo do caminho crítico.
Daí sai uma regra que parece pequena e não é: a tela nunca lê da rede. A tela observa um stream do banco local. O sincronizador escreve no banco local. Um caminho só, uma direção só.
Quem ignora isso produz o bug mais comum desse tipo de app, o que chega no suporte como "sumiu meu lançamento". O fluxo é sempre igual: grava local, dispara o POST, e em seguida chama GET /apontamentos pra "atualizar a lista". Se o POST ainda está na fila, ou se subiu mas a leitura pegou réplica atrasada, a resposta não contém o registro recém-criado — e a tela troca a lista boa que veio do disco por uma lista pior que veio do servidor. O dado está lá, íntegro. A interface é que parou de olhar pra ele.
O repositório fica com uma cara bem chata, e chato aqui é elogio:
lib/apontamento/apontamento_repository.dartenum EstadoSync { pendente, enviando, sincronizado, rejeitado, conflito }
class ApontamentoAplicacao {
final String id; // UUID v4 gerado no celular, antes de existir no servidor
final String talhaoId; // pode apontar pra um talhão que também nasceu offline
final String safraId;
final String produtoId;
final double dosePorHectare;
final double areaHectares;
final DateTime registradoEm;
final int versaoServidor; // 0 = nunca subiu
final EstadoSync estadoSync;
// construtor, copyWith e toJson omitidos
}
class ApontamentoRepository {
ApontamentoRepository(this._dao, this._fila, this._sincronizador, this._uuid);
final ApontamentoDao _dao;
final FilaOperacoes _fila;
final Sincronizador _sincronizador;
final Uuid _uuid;
/// Retorna assim que o disco confirma. Rede não entra aqui.
Future<ApontamentoAplicacao> registrar({
required String talhaoId,
required String safraId,
required String produtoId,
required double dosePorHectare,
required double areaHectares,
}) async {
final apontamento = ApontamentoAplicacao(
id: _uuid.v4(),
talhaoId: talhaoId,
safraId: safraId,
produtoId: produtoId,
dosePorHectare: dosePorHectare,
areaHectares: areaHectares,
registradoEm: DateTime.now(),
versaoServidor: 0,
estadoSync: EstadoSync.pendente,
);
// Entidade e operação pendente na MESMA transação. Se o app morrer
// entre as duas escritas, o registro existe e nunca sobe. Já vi.
await _dao.transaction(() async {
await _dao.inserir(apontamento);
await _fila.enfileirar(OperacaoPendente(
chaveIdempotencia: _uuid.v4(), // gerada uma vez, vive com a operação
tipo: TipoOperacao.criarApontamento,
entidadeId: apontamento.id,
dependeDe: [talhaoId], // ordem topológica na hora de subir
payload: apontamento.toJson(),
));
});
unawaited(_sincronizador.acordar());
return apontamento;
}
/// A UI assina isto. Nunca um Future de rede.
Stream<List<ApontamentoAplicacao>> observarPorTalhao(String talhaoId) =>
_dao.observarPorTalhao(talhaoId);
}
O ID precisa existir antes do servidor
Repare no _uuid.v4() lá em cima. Ele é o detalhe que decide se dá ou não pra construir o resto.
Um registro criado no talhão precisa de identidade no instante em que existe, porque a tela vai referenciá-lo, o usuário vai editá-lo, outro registro vai apontar pra ele — tudo isso antes de qualquer byte subir. ID sequencial de banco é fundamentalmente incompatível com isso, porque só o servidor sabe qual é o próximo número. Não existe meio-termo elegante: ou a chave nasce no cliente, ou você vai carregar um ID temporário e reescrever referência em cascata depois, e essa cascata é onde mora o bug que ninguém consegue reproduzir.
O caso que expõe isso com clareza é o cadastro rápido. O operador percebe que aquela área nova não está no sistema, cadastra o talhão ali mesmo, offline, e lança a aplicação nele. Agora você tem duas entidades sem existência remota, uma referenciando a outra. Se o servidor devolver um ID próprio para o talhão, o apontamento que já subiu — ou que vai subir daqui a pouco — aponta pra um ID que não existe mais.
Minha posição é firme aqui: o servidor aceita o UUID do cliente como chave primária. Não como "campo auxiliar", não como coluna client_id pra dar match depois. Chave. Isso resolve o problema na origem, e o preço é um índice em UUID em vez de bigserial, coisa que qualquer Postgres aguenta numa base de apontamento de campo. O dependeDe na operação pendente cuida do resto: a fila ordena talhão antes de apontamento e não sobe filho antes de pai.
Guardar offline é fácil. Voltar é que dói
Aqui está o assunto que quase todo tutorial de offline-first pula.
Às 9h da manhã, o agrônomo no escritório abre aquele apontamento e corrige a dose de 1,8 para 2,1 L/ha, porque a recomendação técnica mudou. Às 15h, o operador — que está offline desde as 7h — sincroniza a correção dele: a área não era 63 ha, era 58,4 ha. Ele editou o mesmo registro, sem nunca ter visto a mudança do agrônomo.
Duas edições legítimas, em campos diferentes, do mesmo objeto. Nenhuma delas está errada.
Com last-write-wins por objeto inteiro, o pacote do campo chega por último e sobrescreve tudo. A dose volta pra 1,8. Ninguém é notificado, nada falha, o log fica verde. A correção do agrônomo evaporou.
E agora a parte que eu preferia não escrever: essa estratégia foi ideia minha. Num app interno de apontamento eu defendi LWW justamente porque era simples de explicar e de testar — um updated_at, um comparativo, resolvido. Segurou bem por uns quatro meses. Aí um técnico refez um apontamento no fim da tarde e sobrescreveu, sem saber, a revisão que outra pessoa tinha feito de manhã em outro campo do mesmo registro. Descobrimos no fechamento, quando a dose consolidada não batia com a nota de saída do produto. O dado não voltou, porque não tinha histórico — LWW não guarda o que ele sobrescreveu. A gente reconstruiu na mão a partir de foto de romaneio.
| Estratégia | Quando usar | O que você perde |
|---|---|---|
| Last-write-wins por timestamp do cliente | Praticamente nunca. Talvez preferência de usuário, tema, filtro salvo | Dado, em silêncio — e com relógio errado você perde o dado mais novo |
| Last-write-wins por ordem de chegada no servidor | Quando a entidade é descartável e a edição concorrente é rara de verdade | A mesma perda, mas ao menos determinística e auditável no log do servidor |
| Merge por campo (campo alterado vence campo intocado) | Cadastro: talhão, produto, ficha de safra. Campos independentes entre si | Consistência entre campos que só fazem sentido juntos (dose e unidade de medida) |
| Contador de versão / lock otimista (409 no conflito) | Quando existe alguém capaz de decidir e a decisão importa | Fluidez: alguém precisa resolver na tela, e no campo esse alguém é o operador |
| Vector clock por dispositivo | Muitos dispositivos escrevendo na mesma entidade, sem ordem central confiável | Simplicidade. É correto e é caro de manter, depurar e explicar pro time |
| Modelar como evento em vez de estado | Movimento: aplicação, colheita, abastecimento, transferência de produto | CRUD. Precisa materializar estado e conviver com log crescendo |
A última linha é a que eu defendo, e ela é subestimada porque parece exagero arquitetural quando você está só tentando salvar um formulário.
Pensa na diferença entre estas duas frases. "quantidade = 40" é um estado, e dois dispositivos afirmando estados diferentes sobre o mesmo campo estão em conflito por definição — um dos dois tem que perder. Já "registrou aplicação de 40 L de glifosato no talhão 7 às 14h20 do dia 5" é um fato. Fato não conflita com fato. Se dois dispositivos registrarem dois fatos, os dois entram, e o total é a soma. Nada some.
O que sobra não é perda de dado, é ambiguidade de apresentação — dois apontamentos parecidos no mesmo talhão, no mesmo dia. Isso é infinitamente melhor, porque é visível: você mostra os dois lado a lado e deixa o agrônomo decidir se foi duplicidade ou se foram mesmo duas passadas. Correção vira evento de estorno, não UPDATE. E de brinde você ganha a auditoria que o setor cobra de qualquer jeito.
Não vale pra tudo, e eu não vou fingir que vale. Cadastro de talhão é estado, e merge por campo resolve bem. Movimento é evento. A regra que eu uso: se o negócio pergunta "quem mudou isso e quando?", é evento.
Idempotência, ou o request que você não sabe se chegou
Tem um estado que dá mais trabalho que o offline puro: o request que saiu, chegou, foi processado, e cuja resposta morreu no caminho de volta. Do lado do celular isso é indistinguível de "não chegou". Timeout é timeout.
Se o retry for ingênuo, você acabou de criar o segundo apontamento. E o usuário não descobre no dia — descobre no fechamento do mês, quando o consumo de produto do talhão está 40% acima do que saiu do estoque. É a mesma idempotência que segura reprocesso em pipeline de dados, aplicada num lugar onde a rede é muito pior e o número de tentativas é muito maior.
lib/sync/sincronizador.dartclass Sincronizador {
static const _maxTentativas = 7;
Future<void> processar(OperacaoPendente op) async {
var tentativa = op.tentativas;
while (tentativa < _maxTentativas) {
try {
final resposta = await _api
.enviar(op, headers: {'Idempotency-Key': op.chaveIdempotencia})
.timeout(const Duration(seconds: 20));
// 200 e 201 caem aqui. O servidor devolve o mesmo recurso nos dois
// casos, então o cliente não precisa saber se foi criação ou replay.
await _confirmar(op, resposta.versao);
return;
} on TimeoutException {
// O caso perigoso: pode ter chegado. Reenviar com a MESMA chave é
// o que torna isso seguro — sem ela, aqui nasce lançamento duplicado.
tentativa++;
await Future.delayed(_backoff(tentativa));
} on ConflitoDeVersao catch (e) {
// 409: o servidor está numa versão que este dispositivo não viu.
await _resolvedor.resolver(op, e.estadoServidor);
return;
} on ErroHttp catch (e) {
if (e.status >= 500) {
tentativa++;
await Future.delayed(_backoff(tentativa));
continue;
}
// 4xx é payload inválido. Repetir mil vezes não conserta,
// e fila travada num item é fila que não entrega nenhum outro.
await _fila.marcarRejeitada(op, motivo: e.mensagem);
return;
}
}
await _fila.reagendar(op, tentativas: tentativa);
}
Duration _backoff(int tentativa) {
final teto = math.min(1 << tentativa, 64) * 1000;
// Jitter: 40 tratores saindo do talhão às 17h fazem um pico bonito.
return Duration(milliseconds: teto + _random.nextInt(teto ~/ 3));
}
}
A chave de idempotência tem que ser gerada uma vez, no momento em que a operação entra na fila, e persistida junto com ela. Se você gerar a chave dentro do método que envia, cada retry manda uma chave nova e o servidor cria um registro por tentativa — você escreveu todo o mecanismo e ficou com exatamente o bug que ele existia pra evitar. E a chave é da operação, não da entidade: editar o mesmo apontamento duas vezes são duas operações distintas.
Como se testa isso sem enlouquecer
Teste de sincronização é uma fábrica de condição de corrida, e condição de corrida é exatamente o que as pessoas chamam de teste instável quando não querem investigar. A diferença é que aqui a corrida não está no teste: está no sistema. O teste só está mostrando.
Quatro camadas, em ordem de custo.
Unidade da fila, com repositório e API fakes. É onde mora 80% do valor e onde eu começo sempre. Nada de mock de http — um fake de verdade, com estado, que consegue simular o servidor tendo processado e o cliente não tendo visto.
Widget test com estados de conexão injetados. O ponto não é testar a rede, é garantir que a tela lê do banco local mesmo quando o sincronizador está gritando erro. Se o widget test quebra quando você força falha de rede, a UI está lendo do lugar errado.
Integração matando a conexão no meio. O caso do request enviado e da resposta perdida precisa de teste automatizado, porque é rigorosamente o único jeito de provar que a chave de idempotência funciona.
E o quarto, que quase ninguém escreve: sincronizar com o relógio do dispositivo errado. Celular de campo passa dias sem sinal e volta com hora torta; já vi aparelho com três dias de diferença depois de trocar de chip. Se o critério de ordenação de conflito for DateTime.now() do cliente, um celular adiantado vence toda edição feita no escritório pelas próximas 72 horas. Ordenação é por versão do servidor. O timestamp do cliente serve pra registrar quando a aplicação aconteceu no campo — que é dado de negócio, não critério de resolução.
void main() {
late ApiFake api;
late ApontamentoRepository repositorio;
late Sincronizador sincronizador;
setUp(() async {
api = ApiFake();
// dao em memória, fila real, relógio controlado
final ambiente = await montarAmbienteDeSync(api: api);
repositorio = ambiente.repositorio;
sincronizador = ambiente.sincronizador;
});
test('não duplica apontamento quando a resposta se perde no meio', () async {
final apontamento = await repositorio.registrar(
talhaoId: 'T-07',
safraId: '2025/2026',
produtoId: 'HERB-GLI-480',
dosePorHectare: 1.8,
areaHectares: 63.0,
);
// O servidor processa e persiste; a resposta morre na volta.
api.perderRespostaDepoisDePersistir(vezes: 1);
await sincronizador.drenarFila(); // tentativa 1: estoura timeout
api.restaurar();
await sincronizador.drenarFila(); // tentativa 2: mesma chave
expect(api.requisicoesRecebidas, hasLength(2));
expect(api.apontamentosPersistidos, hasLength(1));
expect(
api.requisicoesRecebidas.map((r) => r.chaveIdempotencia).toSet(),
hasLength(1),
reason: 'a chave tem que sobreviver ao retry',
);
final local = await repositorio.buscarPorId(apontamento.id);
expect(local!.estadoSync, EstadoSync.sincronizado);
expect(await sincronizador.pendentes(), isEmpty);
});
test('relógio adiantado do celular não vence a versão do servidor', () async {
await api.semear(apontamentoServidor(id: 'a-91', dose: 2.1, versao: 4));
// Celular voltou do talhão com 3 dias de adiantamento no relógio.
relogioDoDispositivo.avancar(const Duration(days: 3));
await repositorio.editarArea('a-91', areaHectares: 58.4, versaoBase: 3);
await sincronizador.drenarFila(); // servidor responde 409
final resolvido = await repositorio.buscarPorId('a-91');
expect(resolvido!.dosePorHectare, 2.1); // não voltou pro valor antigo
expect(resolvido.areaHectares, 58.4); // e a edição do campo entrou
});
}
O segundo teste é feio de escrever e é o que eu mais recomendo. Ele falha em vermelho no dia em que alguém "simplifica" a resolução de conflito trocando versão por updated_at, que é uma refatoração que parece boa em code review.
"Isso é complexidade demais, hoje tem 4G em todo canto"
Esse argumento aparece toda vez, e ele não é bobo. Offline-first custa: banco local com migração própria, fila persistida, resolução de conflito, uma classe inteira de teste que não existiria, e um modelo mental que todo desenvolvedor novo no time leva umas duas semanas pra internalizar. Isso é caro e é caro pra sempre, não só na primeira sprint.
Só que "em todo canto" está fazendo um trabalho pesado nessa frase. A cobertura que importa não é a média nacional — é a do lugar onde esse usuário abre esse app. E esse lugar é o meio de um talhão de 400 hectares, com o aparelho dentro de uma cabine de aço, a 12 km da torre mais próxima, num horário em que a lavoura inteira está no campo usando a mesma célula. Cobertura ali não é 4G ou nada: é 4G que vira EDGE, que vira 180 ms de latência que vira 30 segundos, que volta.
Onde o crítico tem razão inteira: se o seu usuário está sempre conectado — ferramenta interna de escritório, painel administrativo, SaaS B2B que roda no navegador da mesa dele — offline-first é custo sem retorno e você não deve pagar. Um bom estado de erro, retry com backoff e um rascunho salvo no localStorage resolvem 99% do que você teria ganhado, por 5% do trabalho. Escolher offline-first pra esse produto é engenharia de currículo.
O que não dá é decidir isso depois. Retrofitar offline-first significa trocar a fonte de verdade de um app inteiro: toda tela, todo repositório, todo ID, todo teste. Já vi essa conversa acontecer e ela sempre termina em reescrita com outro nome. É decisão de primeira semana, e ela se resume a uma pergunta que não é técnica: onde essa pessoa vai estar quando tocar em salvar?
Porque quando o app responde "sem conexão, tente novamente", ele está devolvendo pro operador um problema de arquitetura que não é dele. E ele vai resolver do jeito que dá — caderninho no bolso, foto da anotação, memória até o fim do dia. Aí você não tem mais um app de campo. Tem um formulário caro que só funciona no escritório, alimentado por um caderno que ninguém consegue auditar.