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.
Primary stack
The default picks this pillar assumes
- Drift
Alternates worth knowing
Swap in when scale, cost or constraints change
- Isar
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- 01step
Domain: entities + abstract repository contracts + use cases.
- 02step
Data: repository impl + remote data source (Dio) + local data source (Drift).
- 03step
Presentation: Riverpod providers (or GetX controllers) + screens.
- 04step
DI: GetIt registers everything in main() before runApp.
Cases & edge cases
watch for theseUse 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- lib/features/<feature>/domain/
- lib/features/<feature>/data/
- lib/features/<feature>/presentation/
Notes
marginIf the codebase doesn't match this structure within the first few weeks, it never will. Bake it in early.
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- 01step
UI calls repo.tasksStream() → returns a Stream<List<Task>> from Drift.
- 02step
Repo simultaneously kicks off syncEngine.pullFor('tasks').
- 03step
Pull writes new/updated tasks to Drift → stream emits → UI re-renders.
- 04step
User creates a task → repo.createTask() inserts locally with unsynced=true → emits.
- 05step
Sync engine picks up unsynced rows → POSTs → on success, marks synced=true.
Cases & edge cases
watch for theseStream-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 thisReactive SQLite for Flutter (Streams from queries).
Isar
alternateFaster NoSQL alternative; great for write-heavy local data.
Where it lives
code references- lib/features/tasks/data/repositories/task_repository_impl.dart
- lib/features/tasks/data/datasources/local/task_local_ds.dart
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- 01step
Pull: GET /v1/sync?cursor=<last> → response includes changes + new cursor.
- 02step
Local DB applies changes inside a transaction; sync cursor updated.
- 03step
Push: SELECT * FROM <table> WHERE unsynced = true → batch POST.
- 04step
Server returns per-row result; mark synced where ok, queue retry where conflict.
- 05step
Schedule next sync after delay (e.g. 30s active, 5min background).
Cases & edge cases
watch for theseBackoff 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- lib/core/sync/sync_engine.dart
- lib/core/sync/sync_cursor.dart
Notes
marginServer 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.
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- 01step
App startup: request notification permission → register FCM token → POST to /v1/push-tokens.
- 02step
Backend stores (user_id, device_id, fcm_token, platform).
- 03step
Notification fires → orchestrator sends to FCM with token + payload.
- 04step
App receives → if foreground, render in-app banner; if background, OS shows it.
- 05step
Tap → routed via deep link to the relevant screen.
Cases & edge cases
watch for theseTokens 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- must
FCM configured + tokens stored server-side
- should
Permission prompted contextually, not on first launch
- must
Deep-link routing from notification taps
- should
Topics / channels for notification categories
Where it lives
code references- lib/core/notifications/fcm_service.dart
- ios/Runner/Info.plist // background modes
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- 01step
Backend serves /.well-known/apple-app-site-association + assetlinks.json — verified by the OS.
- 02step
User taps a link in Mail / Slack / SMS → OS checks domain → opens app if installed.
- 03step
App receives URI via app_links package → routes to the matching screen.
- 04step
If not installed → falls through to web (Next.js renders the same content).
Cases & edge cases
watch for theseAASA + 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- ios/Runner/Runner.entitlements // associated domains
- android/app/src/main/AndroidManifest.xml // intent filters
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- 01step
Sign-in success → secureStorage.write('refresh_token', token).
- 02step
App boot → read refresh token, attempt silent JWT issuance, set authenticated state.
- 03step
Sign-out → delete refresh_token + clear local DB.
- 04step
Biometric unlock → key wrapped by Keychain biometric protection.
Cases & edge cases
watch for theseKeychain 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- lib/core/auth/secure_storage.dart
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- 01step
Provider declares state: final taskListProvider = StreamProvider((ref) => ref.read(taskRepoProvider).watchAll()).
- 02step
Screen: final tasks = ref.watch(taskListProvider).valueOrNull ?? [].
- 03step
Stream from Drift drives rebuilds automatically — no manual setState.
- 04step
Mutations call use cases via ref.read; loading + errors track in a separate provider.
Cases & edge cases
watch for theseDon'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- lib/features/<feature>/presentation/providers/
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- 01step
Routes declared in a single GoRouter config with redirect logic (auth gating).
- 02step
Screens are stateless widgets that consume providers.
- 03step
Forms: flutter_hooks + reactive_forms for validation parity with the web Zod schemas.
Cases & edge cases
watch for thesego_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- lib/core/routing/router.dart
Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.