Skip to content

Bab 31 — Offline-First: SQFlite & Sinkronisasi

Firestore sudah punya cache offline bawaan. Jadi kenapa membangun sinkronisasi sendiri?

Karena ada batasnya. Cache Firestore bekerja baik untuk membaca dan menulis sederhana, tetapi tidak memberimu kendali atas kapan sinkronisasi terjadi, apa yang terjadi ketika terjadi konflik, dan bagaimana menampilkan status "belum tersinkron" ke pengguna.

Bab ini membangun lapisan sinkronisasi eksplisit dengan SQFlite — pendekatan yang dipakai aplikasi yang harus benar-benar bisa diandalkan tanpa jaringan.

Arsitektur offline-first

Idenya sederhana dan mengubah cara kamu berpikir: basis data lokal adalah sumber kebenaran untuk UI. Jaringan hanya mekanisme sinkronisasi di latar belakang.

   ┌───────────────────────────────────────────────────────┐
   │                        UI                             │
   │        Selalu membaca dari SQFlite — instan           │
   └────────────────────────┬──────────────────────────────┘

   ┌────────────────────────▼──────────────────────────────┐
   │                  REPOSITORY                           │
   │   Tulis ke SQFlite dulu, tandai "belum tersinkron"    │
   └───────┬───────────────────────────────┬───────────────┘
           │                               │
   ┌───────▼────────┐            ┌─────────▼──────────┐
   │    SQFLITE     │            │  SYNC MANAGER      │
   │  (lokal)       │◄───────────┤  Jalan saat online │
   └────────────────┘            └─────────┬──────────┘

                                 ┌─────────▼──────────┐
                                 │    FIRESTORE       │
                                 └────────────────────┘

Perbedaannya dengan pendekatan biasa: UI tidak pernah menunggu jaringan. Pengguna menekan simpan, data langsung tersimpan lokal, dan layar langsung diperbarui. Sinkronisasi terjadi belakangan tanpa pengguna tahu.

Skema basis data lokal

Kuncinya ada pada kolom tambahan untuk melacak status sinkronisasi.

dart
// lib/data/local/basis_data_lokal.dart
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

class BasisDataLokal {
  BasisDataLokal._();
  static final BasisDataLokal instance = BasisDataLokal._();

  static Database? _db;

  Future<Database> get db async => _db ??= await _buka();

  Future<Database> _buka() async {
    final dir = await getApplicationDocumentsDirectory();
    final jalur = p.join(dir.path, 'polling_offline.db');

    return openDatabase(
      jalur,
      version: 1,
      onCreate: (db, versi) async {
        await db.execute('''
          CREATE TABLE polls (
            id              TEXT PRIMARY KEY,
            judul           TEXT NOT NULL,
            pemilik_id      TEXT NOT NULL,
            nama_pemilik    TEXT NOT NULL,
            opsi            TEXT NOT NULL,   -- JSON array
            jumlah_suara    TEXT NOT NULL,   -- JSON array
            total_suara     INTEGER NOT NULL DEFAULT 0,
            url_gambar      TEXT,
            jalur_gambar    TEXT,            -- berkas lokal belum terunggah
            dibuat_pada     INTEGER NOT NULL,

            -- Kolom pelacak sinkronisasi
            status_sync     TEXT NOT NULL DEFAULT 'tersinkron',
            diubah_pada     INTEGER NOT NULL,
            percobaan_sync  INTEGER NOT NULL DEFAULT 0
          )
        ''');

        // Catatan operasi yang menunggu dikirim
        await db.execute('''
          CREATE TABLE antrean_sync (
            id           INTEGER PRIMARY KEY AUTOINCREMENT,
            entitas      TEXT NOT NULL,      -- 'poll', 'vote'
            entitas_id   TEXT NOT NULL,
            operasi      TEXT NOT NULL,      -- 'buat', 'ubah', 'hapus'
            muatan       TEXT,               -- JSON
            dibuat_pada  INTEGER NOT NULL,
            percobaan    INTEGER NOT NULL DEFAULT 0,
            kesalahan    TEXT
          )
        ''');

        await db.execute(
          'CREATE INDEX idx_polls_status ON polls(status_sync)',
        );
        await db.execute(
          'CREATE INDEX idx_antrean_entitas ON antrean_sync(entitas, entitas_id)',
        );
      },
    );
  }

  Future<void> tutup() async {
    await _db?.close();
    _db = null;
  }
}

Tiga kolom pelacak yang penting:

status_sync'tersinkron', 'menunggu', atau 'gagal'. Inilah yang memungkinkan UI menampilkan ikon "belum terkirim" pada item tertentu.

diubah_pada — stempel waktu perubahan lokal, dipakai untuk menyelesaikan konflik.

percobaan_sync — berapa kali sudah dicoba. Setelah beberapa kegagalan, berhenti mencoba agar tidak menguras baterai.

Tabel antrean_sync mencatat operasi, bukan hanya data. Ini penting: kalau pengguna membuat lalu menghapus sebuah poll saat offline, kita perlu tahu urutannya.

Model dengan status sinkronisasi

dart
// lib/data/models/poll_lokal.dart
import 'dart:convert';

enum StatusSync { tersinkron, menunggu, gagal }

class PollLokal {
  const PollLokal({
    required this.id,
    required this.judul,
    required this.pemilikId,
    required this.namaPemilik,
    required this.opsi,
    required this.jumlahSuara,
    required this.totalSuara,
    this.urlGambar,
    this.jalurGambar,
    required this.dibuatPada,
    this.statusSync = StatusSync.tersinkron,
    required this.diubahPada,
    this.percobaanSync = 0,
  });

  final String id;
  final String judul;
  final String pemilikId;
  final String namaPemilik;
  final List<String> opsi;
  final List<int> jumlahSuara;
  final int totalSuara;
  final String? urlGambar;
  final String? jalurGambar;
  final DateTime dibuatPada;
  final StatusSync statusSync;
  final DateTime diubahPada;
  final int percobaanSync;

  bool get belumTersinkron => statusSync != StatusSync.tersinkron;

  Map<String, Object?> toRow() => {
        'id': id,
        'judul': judul,
        'pemilik_id': pemilikId,
        'nama_pemilik': namaPemilik,
        'opsi': json.encode(opsi),
        'jumlah_suara': json.encode(jumlahSuara),
        'total_suara': totalSuara,
        'url_gambar': urlGambar,
        'jalur_gambar': jalurGambar,
        'dibuat_pada': dibuatPada.millisecondsSinceEpoch,
        'status_sync': statusSync.name,
        'diubah_pada': diubahPada.millisecondsSinceEpoch,
        'percobaan_sync': percobaanSync,
      };

  factory PollLokal.dariRow(Map<String, Object?> row) {
    return PollLokal(
      id: row['id'] as String,
      judul: row['judul'] as String,
      pemilikId: row['pemilik_id'] as String,
      namaPemilik: row['nama_pemilik'] as String,
      opsi: List<String>.from(json.decode(row['opsi'] as String) as List),
      jumlahSuara:
          List<int>.from(json.decode(row['jumlah_suara'] as String) as List),
      totalSuara: row['total_suara'] as int,
      urlGambar: row['url_gambar'] as String?,
      jalurGambar: row['jalur_gambar'] as String?,
      dibuatPada:
          DateTime.fromMillisecondsSinceEpoch(row['dibuat_pada'] as int),
      statusSync: StatusSync.values.firstWhere(
        (s) => s.name == row['status_sync'],
        orElse: () => StatusSync.tersinkron,
      ),
      diubahPada:
          DateTime.fromMillisecondsSinceEpoch(row['diubah_pada'] as int),
      percobaanSync: row['percobaan_sync'] as int? ?? 0,
    );
  }

  PollLokal copyWith({
    String? judul,
    String? urlGambar,
    String? jalurGambar,
    List<int>? jumlahSuara,
    int? totalSuara,
    StatusSync? statusSync,
    DateTime? diubahPada,
    int? percobaanSync,
  }) {
    return PollLokal(
      id: id,
      judul: judul ?? this.judul,
      pemilikId: pemilikId,
      namaPemilik: namaPemilik,
      opsi: opsi,
      jumlahSuara: jumlahSuara ?? this.jumlahSuara,
      totalSuara: totalSuara ?? this.totalSuara,
      urlGambar: urlGambar ?? this.urlGambar,
      jalurGambar: jalurGambar ?? this.jalurGambar,
      dibuatPada: dibuatPada,
      statusSync: statusSync ?? this.statusSync,
      diubahPada: diubahPada ?? this.diubahPada,
      percobaanSync: percobaanSync ?? this.percobaanSync,
    );
  }
}

Perhatikan bahwa List<String> dan List<int> disimpan sebagai JSON string. SQLite tidak punya tipe array, sehingga ini pendekatan yang lazim.

Data source lokal

dart
// lib/data/local/poll_dao.dart
import 'package:sqflite/sqflite.dart';
import 'dart:convert';
import '../models/poll_lokal.dart';
import 'basis_data_lokal.dart';

class PollDao {
  Future<Database> get _db async => BasisDataLokal.instance.db;

  Future<List<PollLokal>> semua() async {
    final db = await _db;
    final rows = await db.query('polls', orderBy: 'dibuat_pada DESC');
    return rows.map(PollLokal.dariRow).toList();
  }

  Future<PollLokal?> satu(String id) async {
    final db = await _db;
    final rows =
        await db.query('polls', where: 'id = ?', whereArgs: [id], limit: 1);
    if (rows.isEmpty) return null;
    return PollLokal.dariRow(rows.first);
  }

  Future<List<PollLokal>> yangBelumTersinkron() async {
    final db = await _db;
    final rows = await db.query(
      'polls',
      where: 'status_sync != ? AND percobaan_sync < ?',
      whereArgs: [StatusSync.tersinkron.name, 5],
      orderBy: 'diubah_pada ASC',   // yang lama dulu
    );
    return rows.map(PollLokal.dariRow).toList();
  }

  Future<void> simpan(PollLokal poll) async {
    final db = await _db;
    await db.insert(
      'polls',
      poll.toRow(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future<void> simpanBanyak(List<PollLokal> daftar) async {
    final db = await _db;
    final batch = db.batch();
    for (final poll in daftar) {
      batch.insert(
        'polls',
        poll.toRow(),
        conflictAlgorithm: ConflictAlgorithm.replace,
      );
    }
    await batch.commit(noResult: true);
  }

  Future<void> tandaiTersinkron(String id) async {
    final db = await _db;
    await db.update(
      'polls',
      {'status_sync': StatusSync.tersinkron.name, 'percobaan_sync': 0},
      where: 'id = ?',
      whereArgs: [id],
    );
  }

  Future<void> tandaiGagal(String id) async {
    final db = await _db;
    await db.rawUpdate(
      'UPDATE polls SET status_sync = ?, percobaan_sync = percobaan_sync + 1 '
      'WHERE id = ?',
      [StatusSync.gagal.name, id],
    );
  }

  Future<void> hapus(String id) async {
    final db = await _db;
    await db.delete('polls', where: 'id = ?', whereArgs: [id]);
  }

  // --- Antrean operasi ---
  Future<void> antrekan({
    required String entitas,
    required String entitasId,
    required String operasi,
    Map<String, dynamic>? muatan,
  }) async {
    final db = await _db;
    await db.insert('antrean_sync', {
      'entitas': entitas,
      'entitas_id': entitasId,
      'operasi': operasi,
      'muatan': muatan == null ? null : json.encode(muatan),
      'dibuat_pada': DateTime.now().millisecondsSinceEpoch,
    });
  }

  Future<List<Map<String, Object?>>> antreanTertunda() async {
    final db = await _db;
    return db.query(
      'antrean_sync',
      where: 'percobaan < ?',
      whereArgs: [5],
      orderBy: 'dibuat_pada ASC',   // urutan operasi harus dijaga
    );
  }

  Future<void> hapusDariAntrean(int id) async {
    final db = await _db;
    await db.delete('antrean_sync', where: 'id = ?', whereArgs: [id]);
  }

  Future<void> tandaiAntreanGagal(int id, String kesalahan) async {
    final db = await _db;
    await db.rawUpdate(
      'UPDATE antrean_sync SET percobaan = percobaan + 1, kesalahan = ? '
      'WHERE id = ?',
      [kesalahan, id],
    );
  }
}

orderBy: 'dibuat_pada ASC' pada antrean sangat penting. Operasi harus dikirim dalam urutan yang sama seperti saat dibuat — kalau tidak, "hapus" bisa terkirim sebelum "buat", dan servernya bingung.

Memantau koneksi

dart
// lib/data/services/pemantau_koneksi.dart
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';

class PemantauKoneksi {
  PemantauKoneksi({Connectivity? connectivity})
      : _connectivity = connectivity ?? Connectivity();

  final Connectivity _connectivity;

  Stream<bool> get aliranStatus =>
      _connectivity.onConnectivityChanged.map(_daring);

  Future<bool> sedangDaring() async {
    final hasil = await _connectivity.checkConnectivity();
    return _daring(hasil);
  }

  bool _daring(List<ConnectivityResult> hasil) {
    return hasil.any((r) =>
        r == ConnectivityResult.wifi ||
        r == ConnectivityResult.mobile ||
        r == ConnectivityResult.ethernet);
  }
}

connectivity_plus tidak menjamin internet

Paket ini hanya memberi tahu apakah ada koneksi jaringan, bukan apakah internet benar-benar bisa diakses. Wi-Fi hotel yang butuh login akan dilaporkan sebagai "tersambung" padahal tidak ada akses.

Untuk kepastian, lakukan pemeriksaan sungguhan:

dart
Future<bool> internetBenarAda() async {
  try {
    final hasil = await InternetAddress.lookup('firebase.google.com')
        .timeout(const Duration(seconds: 3));
    return hasil.isNotEmpty && hasil.first.rawAddress.isNotEmpty;
  } catch (_) {
    return false;
  }
}

Manajer sinkronisasi

Inilah bagian intinya.

dart
// lib/data/sync/manajer_sync.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../local/poll_dao.dart';
import '../models/poll_lokal.dart';
import '../services/layanan_storage.dart';
import '../services/pemantau_koneksi.dart';

enum StatusSyncGlobal { diam, menyinkronkan, gagal }

class ManajerSync {
  ManajerSync({
    required PollDao dao,
    required LayananStorage storage,
    required PemantauKoneksi koneksi,
    FirebaseFirestore? firestore,
  })  : _dao = dao,
        _storage = storage,
        _koneksi = koneksi,
        _db = firestore ?? FirebaseFirestore.instance;

  final PollDao _dao;
  final LayananStorage _storage;
  final PemantauKoneksi _koneksi;
  final FirebaseFirestore _db;

  StreamSubscription<bool>? _langgananKoneksi;
  Timer? _timerBerkala;
  bool _sedangBerjalan = false;

  final _pengendaliStatus =
      StreamController<StatusSyncGlobal>.broadcast();
  Stream<StatusSyncGlobal> get aliranStatus => _pengendaliStatus.stream;

  void mulai() {
    // Sinkronkan begitu koneksi kembali
    _langgananKoneksi = _koneksi.aliranStatus.listen((daring) {
      if (daring) sinkronkan();
    });

    // Dan coba lagi berkala sebagai jaring pengaman
    _timerBerkala = Timer.periodic(
      const Duration(minutes: 5),
      (_) => sinkronkan(),
    );
  }

  Future<void> sinkronkan() async {
    // Cegah dua proses sinkronisasi berjalan bersamaan
    if (_sedangBerjalan) return;
    if (!await _koneksi.sedangDaring()) return;

    _sedangBerjalan = true;
    _pengendaliStatus.add(StatusSyncGlobal.menyinkronkan);

    try {
      await _dorongPerubahanLokal();
      await _tarikPerubahanServer();
      _pengendaliStatus.add(StatusSyncGlobal.diam);
    } catch (_) {
      _pengendaliStatus.add(StatusSyncGlobal.gagal);
    } finally {
      _sedangBerjalan = false;
    }
  }

  /// Kirim perubahan lokal ke server, sesuai urutan antrean.
  Future<void> _dorongPerubahanLokal() async {
    final antrean = await _dao.antreanTertunda();

    for (final item in antrean) {
      final idAntrean = item['id'] as int;
      final entitasId = item['entitas_id'] as String;
      final operasi = item['operasi'] as String;

      try {
        switch (operasi) {
          case 'buat':
          case 'ubah':
            await _kirimPoll(entitasId);
          case 'hapus':
            await _db.collection('polls').doc(entitasId).delete();
            await _dao.hapus(entitasId);
          case 'vote':
            final muatan =
                json.decode(item['muatan'] as String) as Map<String, dynamic>;
            await _kirimSuara(entitasId, muatan);
        }

        await _dao.hapusDariAntrean(idAntrean);
      } catch (e) {
        await _dao.tandaiAntreanGagal(idAntrean, e.toString());
        await _dao.tandaiGagal(entitasId);
        // Lanjut ke item berikutnya — jangan hentikan seluruh sinkronisasi
      }
    }
  }

  Future<void> _kirimPoll(String id) async {
    final lokal = await _dao.satu(id);
    if (lokal == null) return;

    var urlGambar = lokal.urlGambar;

    // Unggah gambar yang masih tersimpan lokal
    if (lokal.jalurGambar != null && urlGambar == null) {
      final berkas = File(lokal.jalurGambar!);
      if (await berkas.exists()) {
        urlGambar = await _storage.unggahGambarPoll(id, berkas);
      }
    }

    await _db.collection('polls').doc(id).set({
      'judul': lokal.judul,
      'pemilikId': lokal.pemilikId,
      'namaPemilik': lokal.namaPemilik,
      'opsi': lokal.opsi,
      'jumlahSuara': lokal.jumlahSuara,
      'totalSuara': lokal.totalSuara,
      'urlGambar': urlGambar,
      'dibuatPada': Timestamp.fromDate(lokal.dibuatPada),
      'diubahPada': FieldValue.serverTimestamp(),
    }, SetOptions(merge: true));

    await _dao.simpan(
      lokal.copyWith(
        urlGambar: urlGambar,
        statusSync: StatusSync.tersinkron,
      ),
    );
    await _dao.tandaiTersinkron(id);
  }

  Future<void> _kirimSuara(
    String pollId,
    Map<String, dynamic> muatan,
  ) async {
    final uid = muatan['uid'] as String;
    final indeks = muatan['indeksOpsi'] as int;

    final refPoll = _db.collection('polls').doc(pollId);
    final refSuara = refPoll.collection('votes').doc(uid);

    await _db.runTransaction((txn) async {
      final docSuara = await txn.get(refSuara);
      final docPoll = await txn.get(refPoll);

      if (!docPoll.exists) return;
      // Sudah ada — pengguna memberi suara dari perangkat lain
      if (docSuara.exists) return;

      final jumlah = List<int>.from(docPoll.data()!['jumlahSuara'] as List);
      jumlah[indeks] += 1;

      txn.set(refSuara, {
        'indeksOpsi': indeks,
        'padaWaktu': FieldValue.serverTimestamp(),
      });
      txn.update(refPoll, {
        'jumlahSuara': jumlah,
        'totalSuara': FieldValue.increment(1),
      });
    });
  }

  /// Ambil perubahan dari server, tanpa menimpa perubahan lokal
  /// yang belum sempat dikirim.
  Future<void> _tarikPerubahanServer() async {
    final snapshot = await _db
        .collection('polls')
        .orderBy('dibuatPada', descending: true)
        .limit(100)
        .get();

    final lokalSemua = await _dao.semua();
    final petaLokal = {for (final p in lokalSemua) p.id: p};

    final untukDisimpan = <PollLokal>[];

    for (final doc in snapshot.docs) {
      final lokal = petaLokal[doc.id];

      // Jangan timpa perubahan lokal yang belum terkirim
      if (lokal != null && lokal.belumTersinkron) continue;

      final data = doc.data();
      untukDisimpan.add(
        PollLokal(
          id: doc.id,
          judul: data['judul'] as String? ?? '',
          pemilikId: data['pemilikId'] as String? ?? '',
          namaPemilik: data['namaPemilik'] as String? ?? 'Anonim',
          opsi: List<String>.from(data['opsi'] as List? ?? const []),
          jumlahSuara:
              List<int>.from(data['jumlahSuara'] as List? ?? const []),
          totalSuara: (data['totalSuara'] as num?)?.toInt() ?? 0,
          urlGambar: data['urlGambar'] as String?,
          dibuatPada:
              (data['dibuatPada'] as Timestamp?)?.toDate() ?? DateTime.now(),
          statusSync: StatusSync.tersinkron,
          diubahPada: DateTime.now(),
        ),
      );
    }

    await _dao.simpanBanyak(untukDisimpan);
  }

  void hentikan() {
    _langgananKoneksi?.cancel();
    _timerBerkala?.cancel();
    _pengendaliStatus.close();
  }
}

Beberapa keputusan penting yang layak dijelaskan.

if (_sedangBerjalan) return; mencegah dua proses sinkronisasi berjalan bersamaan — misalnya ketika timer berkala dan pemulihan koneksi terpicu hampir bersamaan. Tanpa ini, data bisa terkirim dua kali.

Kegagalan satu item tidak menghentikan yang lain. try/catch berada di dalam perulangan, bukan di luarnya.

if (lokal.belumTersinkron) continue; adalah strategi penyelesaian konflik. Perubahan lokal yang belum terkirim tidak boleh ditimpa oleh data server — kalau tidak, pekerjaan pengguna hilang.

Batas percobaan (percobaan < 5). Item yang terus gagal akhirnya berhenti dicoba, agar tidak menguras baterai selamanya.

Repository offline-first

dart
// lib/data/repositories/poll_offline_repository.dart
import 'dart:io';
import '../local/poll_dao.dart';
import '../models/poll_lokal.dart';
import '../services/pemantau_koneksi.dart';
import '../sync/manajer_sync.dart';

class PollOfflineRepository {
  PollOfflineRepository({
    required PollDao dao,
    required ManajerSync sync,
    required PemantauKoneksi koneksi,
  })  : _dao = dao,
        _sync = sync,
        _koneksi = koneksi;

  final PollDao _dao;
  final ManajerSync _sync;
  final PemantauKoneksi _koneksi;

  /// UI selalu membaca dari lokal — instan, tanpa menunggu jaringan.
  Future<List<PollLokal>> semua() => _dao.semua();

  Future<PollLokal> buat({
    required String judul,
    required List<String> opsi,
    required String pemilikId,
    required String namaPemilik,
    File? gambar,
  }) async {
    final id = DateTime.now().microsecondsSinceEpoch.toString();
    final sekarang = DateTime.now();

    final poll = PollLokal(
      id: id,
      judul: judul.trim(),
      pemilikId: pemilikId,
      namaPemilik: namaPemilik,
      opsi: opsi,
      jumlahSuara: List.filled(opsi.length, 0),
      totalSuara: 0,
      jalurGambar: gambar?.path,
      dibuatPada: sekarang,
      statusSync: StatusSync.menunggu,
      diubahPada: sekarang,
    );

    // 1. Simpan lokal dulu — UI bisa langsung menampilkannya
    await _dao.simpan(poll);

    // 2. Catat operasi ke antrean
    await _dao.antrekan(
      entitas: 'poll',
      entitasId: id,
      operasi: 'buat',
    );

    // 3. Coba sinkronkan kalau sedang daring — tanpa await,
    //    agar UI tidak menunggu
    if (await _koneksi.sedangDaring()) {
      unawaited(_sync.sinkronkan());
    }

    return poll;
  }

  Future<void> beriSuara({
    required String pollId,
    required String uid,
    required int indeksOpsi,
  }) async {
    final poll = await _dao.satu(pollId);
    if (poll == null) return;

    // Perbarui tampilan lokal secara optimis
    final jumlah = List<int>.from(poll.jumlahSuara);
    jumlah[indeksOpsi] += 1;

    await _dao.simpan(
      poll.copyWith(
        jumlahSuara: jumlah,
        totalSuara: poll.totalSuara + 1,
        statusSync: StatusSync.menunggu,
        diubahPada: DateTime.now(),
      ),
    );

    await _dao.antrekan(
      entitas: 'vote',
      entitasId: pollId,
      operasi: 'vote',
      muatan: {'uid': uid, 'indeksOpsi': indeksOpsi},
    );

    if (await _koneksi.sedangDaring()) {
      unawaited(_sync.sinkronkan());
    }
  }

  Future<void> hapus(String id) async {
    await _dao.antrekan(
      entitas: 'poll',
      entitasId: id,
      operasi: 'hapus',
    );

    // Hilangkan dari tampilan segera
    await _dao.hapus(id);

    if (await _koneksi.sedangDaring()) {
      unawaited(_sync.sinkronkan());
    }
  }
}

unawaited(...) dari dart:async menyatakan secara eksplisit bahwa kamu sengaja tidak menunggu Future itu — sekaligus mendiamkan peringatan linter. Ini yang membuat UI tetap responsif.

Menampilkan status ke pengguna

Bagian yang membedakan aplikasi offline-first yang baik dari yang membingungkan: pengguna harus tahu apa yang belum tersinkron.

dart
// lib/presentation/widgets/lencana_sync.dart
import 'package:flutter/material.dart';
import '../../data/models/poll_lokal.dart';

class LencanaSync extends StatelessWidget {
  const LencanaSync({super.key, required this.status});

  final StatusSync status;

  @override
  Widget build(BuildContext context) {
    if (status == StatusSync.tersinkron) return const SizedBox.shrink();

    final (ikon, warna, tooltip) = switch (status) {
      StatusSync.menunggu => (
          Icons.cloud_upload_outlined,
          Colors.orange,
          'Menunggu disinkronkan',
        ),
      StatusSync.gagal => (
          Icons.cloud_off_outlined,
          Colors.red,
          'Gagal disinkronkan — akan dicoba lagi',
        ),
      StatusSync.tersinkron => (Icons.check, Colors.green, ''),
    };

    return Tooltip(
      message: tooltip,
      child: Icon(ikon, size: 16, color: warna),
    );
  }
}

Dan spanduk status global:

dart
// lib/presentation/widgets/spanduk_koneksi.dart
import 'package:flutter/material.dart';
import '../../data/sync/manajer_sync.dart';
import '../../data/services/pemantau_koneksi.dart';

class SpandukKoneksi extends StatelessWidget {
  const SpandukKoneksi({
    super.key,
    required this.pemantau,
    required this.manajer,
  });

  final PemantauKoneksi pemantau;
  final ManajerSync manajer;

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<bool>(
      stream: pemantau.aliranStatus,
      builder: (context, snapshotKoneksi) {
        final daring = snapshotKoneksi.data ?? true;

        if (!daring) {
          return Container(
            width: double.infinity,
            color: Colors.grey.shade800,
            padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
            child: const Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(Icons.cloud_off, size: 16, color: Colors.white),
                SizedBox(width: 8),
                Text(
                  'Mode offline — perubahanmu akan dikirim nanti',
                  style: TextStyle(color: Colors.white, fontSize: 13),
                ),
              ],
            ),
          );
        }

        return StreamBuilder<StatusSyncGlobal>(
          stream: manajer.aliranStatus,
          builder: (context, snapshotSync) {
            if (snapshotSync.data != StatusSyncGlobal.menyinkronkan) {
              return const SizedBox.shrink();
            }

            return Container(
              width: double.infinity,
              color: Colors.blue.shade700,
              padding: const EdgeInsets.symmetric(vertical: 6),
              child: const Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  SizedBox(
                    width: 12,
                    height: 12,
                    child: CircularProgressIndicator(
                      strokeWidth: 2,
                      color: Colors.white,
                    ),
                  ),
                  SizedBox(width: 8),
                  Text(
                    'Menyinkronkan...',
                    style: TextStyle(color: Colors.white, fontSize: 13),
                  ),
                ],
              ),
            );
          },
        );
      },
    );
  }
}

Alur lengkap

Kapan pendekatan ini sepadan

Membangun lapisan sinkronisasi sendiri adalah pekerjaan yang tidak sedikit. Pertimbangkan dengan jujur.

Cache Firestore bawaan sudah cukup kalau:

  • Penggunamu jarang benar-benar offline.
  • Kamu tidak butuh menampilkan status "belum tersinkron" per item.
  • Konflik jarang terjadi atau tidak penting.
  • Kamu tidak perlu memodifikasi data saat offline dalam jumlah besar.

Bangun sinkronisasi sendiri kalau:

  • Aplikasimu memang dirancang untuk dipakai tanpa jaringan — aplikasi lapangan, survei, inventaris gudang.
  • Kamu butuh kendali penuh atas penyelesaian konflik.
  • Pengguna harus tahu persis apa yang sudah dan belum terkirim.
  • Kamu butuh menggabungkan beberapa sumber data, bukan hanya Firestore.

Alternatif yang layak dilihat

Sebelum membangun sendiri, pertimbangkan:

  • PowerSync — sinkronisasi SQLite ↔ Postgres/Supabase, dirancang khusus untuk offline-first.
  • Isar atau Drift — basis data lokal yang lebih ergonomis daripada sqflite mentah.
  • Brick — lapisan repository offline-first untuk Flutter.

Membangun sendiri memberi pemahaman yang berharga, tetapi untuk produksi, memakai pustaka yang sudah teruji sering lebih bijak.

Latihan Mandiri

Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.

Variasi 1: Satu Fitur Offline-First — ⭐⭐ · 45–60 menit

Tantangan: Ambil satu fitur tulis yang sederhana — menambah catatan, misalnya — dan buat ia bekerja offline-first dalam lingkup kecil. Bukan mesin sinkronisasi lengkap: cukup satu tabel, satu jenis operasi, dan status yang terlihat oleh pengguna.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  ┌─────────────────────────────┐  │
│  │ Catatan A         ✓ Tersync  │  │
│  │ Catatan B        ⏳ Menunggu │  │
│  │ Catatan C         ✗ Gagal 3x │  │
│  └─────────────────────────────┘  │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Mode pesawat → tulis 3 catatan → langsung tampil "⏳ Menunggu" tanpa menunggu jaringan → nyalakan koneksi → satu per satu berubah jadi "✓ Tersync", tidak ada yang terkirim dua kali.

Kriteria selesai:

  • Catatan baru tersimpan seketika ke basis data lokal dan langsung tampil di daftar, tanpa menunggu jaringan.
  • Tabel lokal punya kolom status sinkronisasi dan jumlah percobaan, dan setiap baris menampilkan statusnya sendiri di layar.
  • Sinkronisasi berjalan sendiri ketika koneksi kembali, dan kegagalan satu catatan tidak menghentikan catatan lain.
  • Ada penanda yang mencegah dua proses sinkronisasi berjalan bersamaan, dan batas percobaan yang membuat catatan yang selalu gagal berhenti dicoba.
  • Kamu sudah menguji dengan mode pesawat: membuat tiga catatan, menutup aplikasi, membukanya lagi, lalu menyalakan koneksi. Ketiganya terkirim, dan tidak ada yang terkirim dua kali.

Petunjuk: Kriteria terakhir adalah yang menemukan sebagian besar bug. Catatan yang terkirim dua kali biasanya berarti statusnya baru diperbarui setelah seluruh perulangan selesai, bukan segera setelah masing-masing berhasil — sehingga proses kedua yang dimulai lebih cepat masih melihatnya sebagai "belum terkirim". Untuk memantau koneksi, ingat bahwa connectivity_plus hanya memberitahumu ada jaringan, bukan ada internet: Wi-Fi hotel yang belum kamu login akan dilaporkan sebagai tersambung. InternetAddress.lookup memberi kepastian yang sebenarnya. Dan bungkus panggilan sinkronisasimu dengan unawaited() supaya UI tidak menunggu prosesnya selesai.

Variasi 2: Antrean Operasi Berurutan — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun catatan inspeksi lapangan yang mendukung tiga jenis operasi offline: buat, ubah, dan hapus. Antrean harus mengirimnya dalam urutan yang benar, dan menangani kasus di mana pengguna membuat lalu menghapus catatan yang sama sebelum sempat tersinkron sama sekali.

Kriteria selesai:

  • Yang disimpan adalah antrean operasi, bukan hanya keadaan akhir data.
  • Membuat lalu menghapus sebelum sinkron tidak mengirim apa pun ke server.
  • Mengubah catatan yang belum pernah tersinkron menggabungkan diri ke operasi buat, bukan menjadi operasi ubah terhadap dokumen yang belum ada.
  • ID lokal sementara diganti ID server setelah operasi buat berhasil, dan operasi berikutnya memakai ID yang benar.

Petunjuk: Kriteria terakhir adalah bagian tersulit dari sinkronisasi mana pun: catatan yang dibuat offline butuh identitas sebelum server memberinya satu. Buat ID lokal sendiri, simpan pemetaannya, lalu perbarui seluruh operasi tertunda yang menunjuk ID lama ketika ID server tiba. Alternatif yang lebih sederhana adalah membuat ID di klien sejak awal dan memakainya juga di server — Firestore mengizinkan itu, dan ia menghilangkan seluruh kelas masalah ini.

Variasi 3: Konflik Dua Perangkat — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Simulasikan konflik sungguhan: ubah catatan yang sama di dua perangkat saat keduanya offline, lalu sambungkan keduanya. Terapkan dua strategi penyelesaian berbeda dan bandingkan hasilnya pada kasus yang sama.

Bayangan tampilan (strategi kedua — minta pengguna memilih):

text
┌─────────────────────────────────┐
│ ⚠ Konflik Ditemukan              │
│                                  │
│ Versi Perangkat Ini:             │
│ "Suhu ruang: 24°C"               │
│                                  │
│ Versi Server (dari HP lain):     │
│ "Suhu ruang: 26°C"               │
│                                  │
│  [Pakai Punyaku]  [Pakai Server]│
└─────────────────────────────────┘

Kriteria selesai:

  • Strategi pertama sederhana dan bisa dijelaskan dalam satu kalimat (misalnya perubahan terakhir menang berdasarkan waktu ubah).
  • Strategi kedua menyimpan keduanya dan meminta pengguna memilih.
  • Perubahan lokal yang belum terkirim tidak pernah ditimpa diam-diam saat menarik data server.
  • Kamu bisa menyebut satu kasus di mana strategi pertama menghasilkan kehilangan data yang tidak disadari pengguna.

Petunjuk: "Perubahan terakhir menang" terdengar wajar sampai kamu menyadari ia bergantung pada jam perangkat, yang bisa salah, dan bahwa ia membuang seluruh perubahan pihak yang kalah tanpa memberi tahu siapa pun. Untuk banyak aplikasi itu pertukaran yang dapat diterima; untuk catatan inspeksi yang menjadi dokumen resmi, jelas tidak. Yang penting bukan memilih strategi paling canggih, melainkan memilih sadar dan bisa menjelaskan apa yang dikorbankan.

Variasi 4: Apakah Ini Sepadan — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Bangun fitur yang sama dua kali: sekali dengan mesin sinkronisasi buatanmu sendiri, sekali hanya dengan mengandalkan cache offline bawaan Firestore. Lalu tulis perbandingan jujur tentang mana yang sebaiknya dipakai dan kapan.

Kriteria selesai:

  • Kedua versi berjalan dan diuji dengan skenario offline yang sama.
  • Kamu mencatat jumlah baris kode dan jumlah kasus tepi yang harus ditangani masing-masing.
  • Kamu menemukan minimal satu hal yang hanya bisa dilakukan mesin buatan sendiri.
  • Kamu menemukan minimal satu hal yang cache bawaan lakukan lebih baik.

Petunjuk: Cache bawaan Firestore jauh lebih mampu daripada yang diperkirakan kebanyakan orang — ia mengantre penulisan, menyajikan pembacaan dari cache, dan menyelesaikan urutannya sendiri. Yang tidak bisa ia lakukan biasanya berkisar pada kendali: kamu tidak bisa melihat antreannya, tidak bisa menampilkan status per item, tidak bisa menetapkan batas percobaan, dan tidak bisa memakai sumber data selain Firestore. Kalau aplikasimu tidak butuh satu pun dari itu, menulis mesin sendiri adalah pekerjaan besar tanpa imbalan.

Ikhtisar

  • Dalam arsitektur offline-first, basis data lokal adalah sumber kebenaran untuk UI. Jaringan hanya mekanisme sinkronisasi di latar belakang.
  • Tambahkan kolom status_sync, diubah_pada, dan percobaan_sync ke tabel lokal untuk melacak apa yang belum terkirim.
  • Simpan antrean operasi, bukan hanya data — urutannya penting agar "hapus" tidak terkirim sebelum "buat".
  • SQLite tidak punya tipe array; simpan List sebagai JSON string.
  • Cegah sinkronisasi ganda dengan penanda _sedangBerjalan.
  • Kegagalan satu item tidak boleh menghentikan yang lain — taruh try/catch di dalam perulangan.
  • Jangan timpa perubahan lokal yang belum terkirim saat menarik data server. Ini strategi penyelesaian konflik paling sederhana yang aman.
  • Beri batas percobaan agar item yang selalu gagal berhenti dicoba dan tidak menguras baterai.
  • connectivity_plus hanya mendeteksi koneksi jaringan, bukan akses internet sungguhan. Lakukan InternetAddress.lookup untuk kepastian.
  • Pakai unawaited() agar UI tidak menunggu sinkronisasi selesai.
  • Tampilkan status sinkronisasi ke pengguna — per item dan secara global. Tanpa itu, offline-first terasa seperti aplikasi yang rusak.
  • Pertimbangkan dengan jujur apakah cache Firestore bawaan sudah cukup sebelum membangun semua ini.

Berikutnya: Bab 32 — Google Maps & Proximity.

Transkrip asli

Disintesis dari 4_flutter_ai-chatbot-n-firebase/7_offline-data-with-sqflite-and-firebase-sync.md (6 video: SQFLite setup, checking internet connection, syncing offline polls part 1 & 2, updating records & syncing updates, deleting records & syncing deletion logs). Lihat PDF Firebase & AI.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.