No momento, você está visualizando Flutter offline-first na prática: um app que funciona sem internet

Flutter offline-first na prática: um app que funciona sem internet

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.

Fontes

Deixe um comentário