Uma referência composta para operações de campo offline-first - inspeções de utilidades, auditorias de armazém ou levantamentos de ativos municipais. Técnicos capturam fotos, localizações GPS e respostas de checklists sem conectividade confiável. Stack: Expo SDK 57 (expo ~57.0.4), React Native 0.86.0, React 19.2.3.
40% do trabalho ocorre em porões, subestações e locais rurais com sinal zero por horas.
Perder uma inspeção concluída porque o aplicativo travou antes da sincronização é uma falha de contrato, não um incômodo de UX.
Dispositivos são Android robustos (Zebra TC57, Honeywell CT40) mais uma minoria de iPhones corporativos.
O backend aceita uploads em lote REST; a política de conflito é autoritativa do servidor em timestamps de aprovação.
Restrições da equipe: Quatro engenheiros mobile, um engenheiro backend, nenhum especialista nativo de plantão. Fluxo de trabalho gerenciado pelo Expo com distribuição interna EAS para dispositivos da frota.
┌──────────────────────────────────────────────────────────────────────────┐│ App de campo - plano de dados offline-first │├──────────────────────────────────────────────────────────────────────────┤│ Expo Router ││ (app)/jobs/[jobId]/inspection/[id] ← deep link de SMS de despacho │├──────────────────────────────────────────────────────────────────────────┤│ Camada de UI ││ DataFreshnessLabel (live|stale|syncing|unavailable) ││ Salvar otimista → SQLite imediatamente │├──────────────────────────────────────────────────────────────────────────┤│ expo-sqlite (fonte da verdade) ││ inspections, answers, attachments_meta ││ outbox (fila de mutações, idempotency_key, payload_json) ││ sync_cursor (last_server_seq) │├──────────────────────────────────────────────────────────────────────────┤│ TanStack Query ││ lista de jobs / dados de referência - stale-while-revalidate quando online │├──────────────────────────────────────────────────────────────────────────┤│ NetInfo → onlineManager + flush em primeiro plano ││ expo-background-task → esvaziamento adiado da outbox (intervalo ≥15 min) │├──────────────────────────────────────────────────────────────────────────┤│ expo-file-system + expo-image ││ arquivos de anexo no disco; upload no worker em segundo plano │└──────────────────────────────────────────────────────────────────────────┘
Caminho de escrita (criação offline):
Usuário toca em Salvar → SQLite INSERT (synced=0) → outbox INSERT → UI mostra "Pendente de upload" → (depois) NetInfo online → flushOutbox() → POST /inspections + Idempotency-Key → ack do servidor → UPDATE synced=1, apaga linha da outbox
Caminho de sincronização (rede de segurança em segundo plano):
OS agenda BACKGROUND_SYNC_TASK → abre SQLite → flushOutbox (máximo 50 linhas, 25 MB de anexos) → retorna Success | Failed
Gate de inicialização em _layout.tsx raiz executa migrações PRAGMA user_version antes que qualquer tela leia tabelas - veja expo-sqlite.
// src/sync/outboxFlush.ts (trecho)export async function flushOutbox(db: SQLiteDatabase) { const rows = await db.getAllAsync<OutboxRow>( `SELECT * FROM outbox ORDER BY created_at LIMIT 50` ); for (const row of rows) { const res = await api.post("/inspections", row.payload_json, { headers: { "Idempotency-Key": row.idempotency_key }, }); if (res.ok) await markSynced(db, row.id); }}
// index.ts - registra a tarefa em segundo plano no escopo globalimport "./src/background/syncTask";import "expo-router/entry";
Laboratório de dispositivos mínimo: Um Android robusto no Android 11+, um iPhone duas gerações atrás do atual, ambos em builds de release - não Expo Go.
Recuperação de corrupção: um dispositivo com um OTA falho no meio da migração acionou a quarentena safeParse - o aplicativo exibiu inspeções conhecidas como boas sem tela branca, conforme Melhores Práticas de Camada de Dados #13.
Migrações SQLite antes da primeira pintura. Um aumento de user_version enviado em um OTA sem um build da loja bloqueou a inicialização a frio uma vez - agora mudanças de schema nativas exigem um aumento binário.
Mostrar estado pendente por linha, não um spinner global. Inspetores trabalham no job B enquanto o job A faz upload; o flag synced por linha superou um modal bloqueador.
Dispositivos robustos mentem sobre a bateria.BatterySaver no Zebra atrasou tarefas em segundo plano; documente que o flush em primeiro plano é o caminho de entrega real.
Uploads de fotos precisam de progresso em bytes. Upload do expo-file-system com onUploadProgress reduziu as chamadas de "salvou?" para o despacho.
Logout limpa todas as camadas. Cache de inspeção desatualizado em dispositivos compartilhados foi uma classe de incidente de privacidade - queryClient.clear() + tabelas de usuário SQLite + diretórios de anexos.
Fixar SDK 57 em runbooks. O TI de campo instala APKs meses depois; a documentação de suporte deve indicar qual runtimeVersion aceita qual OTA.