Sparround

Caching, offline and multiple sources

Offline support is not a class but a sequence of decisions, all made in the repository. The official offline-first page describes three read strategies and two write strategies.

Read strategies

1. Local as fallback (Future-based) — try the server first, fall back to the local database on failure. Simple, but the user waits for the network every time. 2. `Stream`-based — the page treats this as optimal: the repository emits twice, first the fast local data, then the refreshed data from the server. The user sees something immediately. 3. Local-only — reads always come from the database, with server synchronisation handled by a separate sync() method. The most predictable option, but it requires sync logic.

Write strategies

1. Online-only — API first, database on success. Data is always in sync, but offline writes are impossible. 2. Offline-first — database first, then the API. The user can work offline; in exchange you accept the risk the page states explicitly: if the network call fails, the local database and the server fall out of sync.

The fix for that last case is a `synchronized` flag: every written record is stored with synchronized: false, and a background process later sends only those records.

dart
class ProfileRepositoryOfflineFirst implements ProfileRepository {
  ProfileRepositoryOfflineFirst({
    required ApiClientService api,
    required DatabaseService database,
  })  : _api = api,
        _database = database;

  final ApiClientService _api;          // remote service
  final DatabaseService _database;      // lokal service

  // ── OXUMA: Stream əsaslı (rəsmi sənədin optimal saydığı variant) ──
  @override
  Stream<UserProfile> watchProfile() async* {
    // 1) Lokal məlumat dərhal verilir — ekran boş qalmır.
    final local = await _database.fetchUserProfile();
    if (local != null) yield local;

    // 2) Sonra serverdən yenilənmiş məlumat.
    try {
      final remote = await _api.getUserProfile();
      await _database.updateUserProfile(remote);
      yield remote;
    } catch (_) {
      // Şəbəkə yoxdur: lokal məlumat artıq verilib, xəta udulur.
      // Lokal da yoxdursa, çağıran tərəf boş axın alır və bunu idarə edir.
      if (local == null) throw const NetworkFailure();
    }
  }

  // ── YAZMA: offline-first (əvvəlcə lokal, sonra server) ──
  @override
  Future<void> updateProfile(UserProfile profile) async {
    // 1) Lokal yazı dərhal baş verir → UI cavab verir, offline işləyir.
    //    Qeyd sinxron olmayan kimi işarələnir.
    await _database.updateUserProfile(
      profile.copyWith(synchronized: false),
    );

    // 2) Serverə göndərməyə cəhd.
    try {
      await _api.putUserProfile(profile);
      // Uğurlu: bayraq qaldırılır.
      await _database.updateUserProfile(
        profile.copyWith(synchronized: true),
      );
    } catch (_) {
      // Uğursuz: qeyd `synchronized: false` qalır və sonra göndəriləcək.
      // Bu, sənədin açıq qeyd etdiyi "müvəqqəti desinxronizasiya"dır.
    }
  }

  // ── SİNXRONİZASİYA: yalnız göndərilməmiş qeydlər ──
  @override
  Future<void> sync() async {
    final pending = await _database.fetchUnsynchronizedProfiles();
    for (final profile in pending) {
      try {
        await _api.putUserProfile(profile);
        await _database.updateUserProfile(
          profile.copyWith(synchronized: true),
        );
      } catch (_) {
        // Bu qeyd növbəti dəfəyə qalır; digərlərini dayandırmır.
      }
    }
  }
}

`Stream`-based reads and offline-first writes. Note how two services (local and remote) are orchestrated by one repository.

StrategyWhat the user seesIts priceWhen to choose it
Read: local fallback (`Future`)Waits for the network; stale data on failureSimplest; a wait on every openThe data must be strictly fresh
Read: `Stream` (local → remote)Data immediately, then a silent refreshTwo emissions mean the UI builds twiceMost list and detail screens — the option the official page treats as optimal
Read: local-only plus `sync()`Always immediate; freshness depends on the syncNeeds sync logic and conflict resolutionFully offline-capable apps (notes, field work)
Write: online-onlyCannot write offlineSimple; no desync riskPayments, order confirmation — the server has the final word
Write: offline-first plus `synchronized`Instant response, works offlineTemporary desync plus sync codeNotes, favourites, settings — low conflict risk

Who knows what. The division of responsibility does not change in this setup:

  • DatabaseService — SQL or key-value operations. The official docs provide two separate design patterns for this: key-value for small data, SQL for lists.
  • ApiClientService — the REST calls.
  • The repository — which source to read from, when to write, when to synchronise. The whole strategy lives here.
  • The UI — nothing. The screen subscribes to a Stream and has no idea where the data came from.

What triggers a sync. The official page mentions a Timer or background processes (packages such as workmanager). In practice three triggers are used together: app start, connectivity returning (ConnectivityService), and an explicit user refresh.

Conflicts. When both sides modify the same record, who wins must be a deliberate choice: the server wins (simple), the local copy wins (risky), or last-write-by-timestamp. That decision also lives in the repository, and it needs a test — otherwise you get "my data disappeared" bugs.

The most common mistake: trying to build offline support in the UI — writing if (hasConnection) … else … on every screen. Each screen then carries its own strategy and behaviour diverges between them.

The right approach: the UI does not know the connectivity status. It only renders the data and, if needed, an "unsynchronised" badge.

Practice. Convert one feature to offline-first: Stream-based reads plus writes with a synchronized flag. Then write two tests: (1) with no network, a read returns the local data; (2) when a write fails, the record stays synchronized: false and sync() sends it later.

Done means: in airplane mode the app shows data and allows writing, and after connectivity returns sync() pushes the record.

📚 Sources and documentation

  • Offline-first supportofficialdocs.flutter.dev

    The source for this topic: three read strategies, two write strategies and the `synchronized` flag.

  • SQL storage patternofficialdocs.flutter.dev

    Building the local service for lists and relational data.

  • Key-value data patternofficialdocs.flutter.dev

    For settings and small data — at the `shared_preferences` level.

  • Optimistic state patternofficialdocs.flutter.dev

    Updating the UI without waiting for a write's result — used together with offline-first.