arch-atlas
SaaS Atlas·Pillar 07 / 12

Mobile Frontend

Flutter · Riverpod / GetX · Drift · Offline-first repo

On mobile, latency is the user experience. The bet is offline-first: every read hits the local DB, every write is queued and synced, the UI never waits on the network. That requires a sync engine (changes have version vectors and conflict resolution), proper repository pattern (single source of truth per entity), and OS-native integrations (secure storage, push, deep links). Flutter wins this in 2026 because the codebase doubles as the foundation for Material / Cupertino in one repo.

8Nodes2Vendors0Decisions4Checklist1Flows

Primary stack

The default picks this pillar assumes

  • Drift

Alternates worth knowing

Swap in when scale, cost or constraints change

  • Isar
01
ArchitectureDomain · Data · Presentation · DI

Clean Architecture

Same shape as your web feature work: entity in domain, use cases as the seam, repository owning data sources, controllers / Riverpod providers in presentation. Keeps Flutter testable and lets you swap data sources (mock / remote / local) freely.

How it flows

step by step
  1. 01

    Domain: entities + abstract repository contracts + use cases.

    step
  2. 02

    Data: repository impl + remote data source (Dio) + local data source (Drift).

    step
  3. 03

    Presentation: Riverpod providers (or GetX controllers) + screens.

    step
  4. 04

    DI: GetIt registers everything in main() before runApp.

    step

Cases & edge cases

watch for these

Use cases return Either<Failure, T>. Never let exceptions cross the boundary.

Models (data) ≠ entities (domain). Models know JSON; entities are pure.

Don't reach across layers — UI never imports a data source directly.

Where it lives

code references
References
  • lib/features/<feature>/domain/
  • lib/features/<feature>/data/
  • lib/features/<feature>/presentation/

Notes

margin

If the codebase doesn't match this structure within the first few weeks, it never will. Bake it in early.

02
SyncSingle source of truth

Offline-First Repository

The repository reads from local DB first, returns instantly to UI, then triggers a background sync. Writes go to local DB immediately (flagged as 'unsynced'), then to a sync queue.

How it flows

step by step
  1. 01

    UI calls repo.tasksStream() → returns a Stream<List<Task>> from Drift.

    step
  2. 02

    Repo simultaneously kicks off syncEngine.pullFor('tasks').

    step
  3. 03

    Pull writes new/updated tasks to Drift → stream emits → UI re-renders.

    step
  4. 04

    User creates a task → repo.createTask() inserts locally with unsynced=true → emits.

    step
  5. 05

    Sync engine picks up unsynced rows → POSTs → on success, marks synced=true.

    step

Cases & edge cases

watch for these

Stream-first means UI never needs spinners on tab switch — data is always there.

Optimistic IDs (UUIDv7 client-generated) survive sync — no 'temp id → real id' swap.

Conflicts: server wins by default; expose a 'reload from server' for advanced users.

Vendors & tools

what implements this

Drift

primarysite

Reactive SQLite for Flutter (Streams from queries).

Isar

alternate

Faster NoSQL alternative; great for write-heavy local data.

Where it lives

code references
References
  • lib/features/tasks/data/repositories/task_repository_impl.dart
  • lib/features/tasks/data/datasources/local/task_local_ds.dart
03
SyncPush · Pull · Resolve

Sync Engine

A background worker that pulls server changes since the last sync cursor, pushes local unsynced rows, and resolves conflicts. Runs on app start, on connectivity restore, and periodically.

How it flows

step by step
  1. 01

    Pull: GET /v1/sync?cursor=<last> → response includes changes + new cursor.

    step
  2. 02

    Local DB applies changes inside a transaction; sync cursor updated.

    step
  3. 03

    Push: SELECT * FROM <table> WHERE unsynced = true → batch POST.

    step
  4. 04

    Server returns per-row result; mark synced where ok, queue retry where conflict.

    step
  5. 05

    Schedule next sync after delay (e.g. 30s active, 5min background).

    step

Cases & edge cases

watch for these

Backoff aggressively on 5xx; give up after ~5 min on the foreground attempt and surface 'offline' status.

Conflict resolution policy must be documented per entity — last-writer-wins, server-wins, or merge.

Don't sync everything — limit to entities the user has accessed recently. Saves data + battery.

Where it lives

code references
References
  • lib/core/sync/sync_engine.dart
  • lib/core/sync/sync_cursor.dart

Notes

margin

Server is the source of truth for IDs and conflict resolution. Client is the source of truth for 'what the user sees right now'. Hold this distinction.

04
NativeFCM + APNs

Push Notifications

Firebase Cloud Messaging for both iOS and Android (yes, FCM handles APNs under the hood). Server fires from your notification orchestrator (Knock / Courier). On tap, navigate via deep link; on receive, can trigger a silent sync.

How it flows

step by step
  1. 01

    App startup: request notification permission → register FCM token → POST to /v1/push-tokens.

    step
  2. 02

    Backend stores (user_id, device_id, fcm_token, platform).

    step
  3. 03

    Notification fires → orchestrator sends to FCM with token + payload.

    step
  4. 04

    App receives → if foreground, render in-app banner; if background, OS shows it.

    step
  5. 05

    Tap → routed via deep link to the relevant screen.

    step

Cases & edge cases

watch for these

Tokens rotate — re-register on every cold start, dedupe on the server.

iOS requires APNs auth key uploaded to Firebase console; ship checklist.

Silent push for sync (content-available=1) doesn't bypass the OS battery saver — never rely on it.

Checklist

ship readiness
  • FCM configured + tokens stored server-side

    must
  • Permission prompted contextually, not on first launch

    should
  • Deep-link routing from notification taps

    must
  • Topics / channels for notification categories

    should

Where it lives

code references
References
  • lib/core/notifications/fcm_service.dart
  • ios/Runner/Info.plist // background modes
05
NativeUniversal Links · App Links

Deep Linking

Universal Links (iOS) + App Links (Android) let users tap a https URL and land in the app — bypassing the browser. Required for password reset, email invites, magic links, marketing campaigns.

How it flows

step by step
  1. 01

    Backend serves /.well-known/apple-app-site-association + assetlinks.json — verified by the OS.

    step
  2. 02

    User taps a link in Mail / Slack / SMS → OS checks domain → opens app if installed.

    step
  3. 03

    App receives URI via app_links package → routes to the matching screen.

    step
  4. 04

    If not installed → falls through to web (Next.js renders the same content).

    step

Cases & edge cases

watch for these

AASA + assetlinks must be served as application/json over HTTPS — no redirect, no CDN cache games.

Test on real devices — emulators lie about deep-link behavior.

Marketing 'open in app' banners are a fallback for users who don't have it installed.

Where it lives

code references
References
  • ios/Runner/Runner.entitlements // associated domains
  • android/app/src/main/AndroidManifest.xml // intent filters
06
NativeKeychain · Keystore

Secure Storage

Refresh tokens, biometric keys, sensitive caches go in flutter_secure_storage (iOS Keychain / Android Keystore). Never in SharedPreferences. Never in the Drift DB unencrypted.

How it flows

step by step
  1. 01

    Sign-in success → secureStorage.write('refresh_token', token).

    step
  2. 02

    App boot → read refresh token, attempt silent JWT issuance, set authenticated state.

    step
  3. 03

    Sign-out → delete refresh_token + clear local DB.

    step
  4. 04

    Biometric unlock → key wrapped by Keychain biometric protection.

    step

Cases & edge cases

watch for these

Keychain survives uninstall on iOS by default — explicitly clear on first run if you don't want that.

Android Keystore is per-app — uninstalling clears it. Document this for user-facing 'why am I logged out' questions.

On rooted/jailbroken devices, secure storage isn't actually secure. Detect + warn (or refuse to launch in regulated apps).

Where it lives

code references
References
  • lib/core/auth/secure_storage.dart
07
PresentationRiverpod / GetX

UI & State

Riverpod is the 2026 Flutter default for new apps; GetX is fine if the team is already invested. Either way: state lives outside widgets, screens read via Consumer / Get.find, and changes flow through use cases.

How it flows

step by step
  1. 01

    Provider declares state: final taskListProvider = StreamProvider((ref) => ref.read(taskRepoProvider).watchAll()).

    step
  2. 02

    Screen: final tasks = ref.watch(taskListProvider).valueOrNull ?? [].

    step
  3. 03

    Stream from Drift drives rebuilds automatically — no manual setState.

    step
  4. 04

    Mutations call use cases via ref.read; loading + errors track in a separate provider.

    step

Cases & edge cases

watch for these

Don't put async stuff in StatefulWidget initState — wrap in a provider.

AsyncValue is your friend — its when() handles loading/error/data without ad-hoc booleans.

Avoid global Riverpod ProviderScope side effects on every rebuild — initialize once at app boot.

Where it lives

code references
References
  • lib/features/<feature>/presentation/providers/
08
Presentationgo_router · Material · Cupertino

Screens & Navigation

go_router for declarative navigation that handles deep links and web URLs identically. Material 3 widgets adapted with light Cupertino accents on iOS — don't fake-port iOS entirely (users will spot it).

How it flows

step by step
  1. 01

    Routes declared in a single GoRouter config with redirect logic (auth gating).

    step
  2. 02

    Screens are stateless widgets that consume providers.

    step
  3. 03

    Forms: flutter_hooks + reactive_forms for validation parity with the web Zod schemas.

    step

Cases & edge cases

watch for these

go_router type-safe routes via code generation — never hand-string URIs.

iOS swipe-back gesture is muscle memory; always enable on push.

Reduce JS-isms in design: tap targets are >=44px; lists scroll-to-top on tab-tap; nothing 'right-clicks'.

Where it lives

code references
References
  • lib/core/routing/router.dart
Annotate0 strokes

Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.