FieldForce Mobile App Design
FieldForce is the first Flutter app in the Gremlin mobile workspace: an offline-first field-worker client that syncs directly with the existing Go/Postgres/NATS backend via PowerSync — no separate mobile backend. The MVP proves offline-first work cycles, realtime updates when connected, and feature-flagged shift-scoped background location, inside a melos monorepo built for multiple apps from day one.
FieldForce is the first Flutter app in the Gremlin mobile workspace: an offline-first field-worker client that syncs directly with the existing Go/Postgres/NATS backend through PowerSync — no separate mobile backend, no hand-built realtime. The MVP proves offline-first work cycles, polling-free realtime updates, and feature-flagged shift-scoped background location, inside a melos monorepo built for multiple apps from day one.
Governing ADRs
This design is governed by the mobile ADR set; read them before generating code. Conventions not warranting an ADR (UTC-always, commit generated files, config injection, Brand-wrapper rule, codegen discipline, UUIDv7 keys) live in the workspace CLAUDE.md.
| ADR | Decision |
|---|---|
| ADR-0037 | Flutter for the mobile client (over RN / Capacitor / native) |
| ADR-0038 | melos monorepo, multi-app, shared core_* packages |
| ADR-0039 | Clean Architecture + inward-pointing package dependency DAG |
| ADR-0040 | PowerSync for offline + realtime sync |
| ADR-0041 | Go-minted JWT, refresh-on-401, offline grace, verified-identity gate |
| ADR-0042 | Riverpod for DI + state |
| ADR-0043 | Background location — three-gate activation, telemetry routed around PowerSync |
| ADR-0044 | Push notifications — FCM + APNs, sync-on-push, deep-link routing |
| ADR-0045 | Sealed Result<T> + AppFailure error modeling |
| ADR-0046 | slang for i18n |
| ADR-0047 | Codemagic for mobile CI/CD |
Scope & Goals
FieldForce is an offline-first mobile app for field workers who operate in low- or no-connectivity environments. It behaves like a fully local app that transparently syncs to the Gremlin backend when connectivity allows, and stays useful when it does not. The MVP proves three behaviors:
- Offline-first work: a worker can view assigned Tasks, create and edit Task records, and complete Tasks with no connectivity, and have that work sync automatically on reconnect with no data loss.
- Realtime updates: when connected, assignments and changes pushed from the backend appear without manual refresh or interval polling.
- Background location (shift-scoped): while on-the-clock and with consent, the app reports location to the backend, gated behind a remote feature flag.
Success criteria
- A field worker can complete a full Task cycle (view → start → update → submit) entirely offline, and the work syncs correctly on reconnect.
- Connected devices receive backend-driven updates in realtime without polling.
- Offline-created records carry stable IDs (UUIDv7) generated on-device before the server sees them.
- Conflicting edits resolve server-authoritatively without losing or duplicating data.
- Background location runs only when feature flag + OS permission + shift state are all satisfied, and stops cleanly when any is revoked.
- The app feels native per platform (iOS/Android) while carrying a consistent Gremlin brand.
- Crashes and errors are observable remotely (Crashlytics) without needing to reproduce the worker's device state.
Out of scope for MVP
- A separate mobile backend or a second source of truth on-device.
- Hand-built offline-sync, conflict resolution, or schema-migration machinery (PowerSync owns this — ADR-0040).
- Live ephemeral realtime signals (presence, typing, live status) — deferred until a concrete need appears.
- Full analytics taxonomy — only a typed event interface when analytics work begins.
- Shorebird / OTA Dart code push — available later, not MVP.
- Building shared
feature_*packages before a second app needs them. - Digital Worker functionality (separate app; shares
core_*only).
Approach Selected
Several cross-platform and native approaches were considered for a vibe-coded, offline-first, realtime field app on a TypeScript-adjacent Go backend (full rationale in ADR-0037).
Flutter + PowerSync, melos monorepo, Clean Architecture
Strength: Cohesive ecosystem → fewer bugs, tighter dev cycle; compile-time null safety; pixel-consistent native-feeling UI on both platforms; PowerSync collapses offline+realtime+migration into config.
Cost: Dart is a separate language island from the TS monorepo; no client/backend type sharing.
Selected: ecosystem cohesion and PowerSync's collapse of offline+realtime+migration outweigh losing TS type-sharing.
React Native + Expo + WatermelonDB
Strength: Stays in TypeScript; shares types with backend; largest native-module ecosystem; OTA via EAS.
Cost: Ecosystem churn → LLM pulls incompatible/dead libs; offline-sync is hand-assembled; weaker dev-cycle cohesion.
Debugging-cost and offline-sync risk outweigh the language-consolidation win for this project.
Capacitor (web shell)
Strength: Reuses existing web UI fastest.
Cost: WebView is the worst fit for critical offline-first + best-UX requirements.
Fails the two hard requirements.
Native Kotlin + Swift
Strength: Best platform integration.
Cost: Two codebases, two languages, slowest to ship.
Unjustified for a solo/lead vibe-coded build at MVP.
The selected design keeps all identity, write authority, and business logic in the Go backend, with the Flutter app as an offline-first client. PowerSync is a thin, replaceable data-sync boundary; the Go backend owns truth. Each core_* package is a clean, inward-pointing boundary mirroring the backend's hexagonal architecture (ADR-0038, ADR-0039).
Architecture
Heterogeneous data paths
The key consequence of going offline-first + location: not all data flows through the same pipe.
| Data | Path | Realtime | Offline behavior |
|---|---|---|---|
| Interactive entities (work orders, tasks) | PowerSync ↔ local SQLite ↔ Postgres | PowerSync reactive queries | Local SQLite is source while offline; upload queue replays on reconnect |
| Background location telemetry | flutter_background_geolocation own queue → dedicated Go endpoint | N/A (append-only) | Engine's own offline buffer; not in PowerSync buckets |
| Event wake-ups (assignment, etc.) | FCM/APNs push from Go backend | Push (survives backgrounding) | Delivered on reconnect/wake; triggers PowerSync sync |
The otherwise-uniform PowerSync model holds for interactive data, but location telemetry is the exception — high-volume append-only pings route around PowerSync to avoid bloating buckets past their sweet spot (ADR-0040).
Package ownership
The Flutter workspace owns (in core_* packages):
core_models- domain entities,
Result/AppFailuretypes (pure Dart, zero deps) core_network- API client + DTOs
core_auth- JWT/session + verified-identity
core_database- PowerSync schema + sync wiring
core_ui- Brand UI components + theme
core_i18n- slang i18n infrastructure
core_notifications- FCM/APNs + deep-link routing + sync-on-push (ADR-0044)
core_location- background location capability (FieldForce-only)
The Go backend owns everything authoritative: identity, write authority, conflict resolution, Sync Rules / bucket scoping, the uploadData() target endpoint, the location telemetry endpoint, and all business logic.
Roadmap
-
Phase 1 — Offline-First MVP
Offline-first work cycle over PowerSync, realtime when connected, verified-account gate, brand UI, crash reporting. Background location behind a feature flag. Includes the backend work to make location safe: build
ff_shifts(or equivalent Shift entity), clock-in/clock-out APIs, sync active Shift to mobile, Shift in Sync Rules + tests. Production location stays off until Shift exists, syncs, and passes three-gate tests. -
Phase 2 — Push & Notifications Hardening
Full FCM/APNs path across foreground/background/killed, silent pre-sync pushes, deep-link routing from notifications, approver/assignment notifications.
-
Phase 3 — Location & Telemetry Maturity
OEM battery-optimization handling, shift-scoped consent flows, geofencing/activity-recognition tuning via
flutter_background_geolocation. -
Phase 4 — Second App (Digital Worker)
Promote shared features into
feature_*packages as Digital Worker (the chat-observer app) reuses them; validate the monorepo sharing model. -
Phase 5 — OTA & Release Velocity
Evaluate Shorebird for Dart code push to shorten iterate-and-ship cycles without full store resubmission.
-
Phase 6 — Expanded Field Capabilities
Richer offline media capture, barcode/BLE device integrations, and advanced sync scoping as data volume grows.
Security & Permissions
- JWT lifecycle: access token 30–60 min, refresh token 7–30 days. Refresh-on-401 interceptor refreshes transparently. The same Go-minted JWT authenticates both the API and PowerSync sync (it drives Sync Rules bucket access).
- JWT source and claims: follow the existing SolidStart / Better Auth pattern. Mobile signs in via the same auth source (
POST /api/auth/sign-in-email, refresh viaPOST /api/auth/refresh; session shape{ user, session { token, refreshToken, expiresAt } }). The JWT keeps the standard claims (sub,iss,aud,iat,exp) and custom claims (userId,roles,organizationId,isActive), and adds a source markerplatform: "mobile". - Offline grace: the app remains usable on cached data with an expired access token while the refresh token is valid; hard-lock only when the refresh token expires or refresh is explicitly rejected. "Can reach server" is decoupled from "session still valid."
- Secure storage: tokens live in
flutter_secure_storage(Keychain/Keystore-backed) only — never SharedPreferences. - Verified Gremlin account gate: the security spine, enforced via a centralized go_router redirect guard. For Phase 1, a user is verified when the backend-issued auth state matches the existing SolidStart account model: valid JWT/session,
isActive = true, andplatform = "mobile". The app may also callGET /api/auth/meto refresh user state. The device never asserts verification locally. - Sync visibility: MVP must ensure the worker only sees records they are allowed to see. Fine-grained RBAC Sync Rules are not a Phase 1 blocker; start with the simplest safe bucket shape and mobile-side visibility filtering, while keeping Go API writes server-authoritative. Deeper RBAC is a later hardening step.
- Feature-flag kill-switch: the background-location capability is gated by a server-driven flag that doubles as a remote kill-switch (see Background Location).
Data, Sync & Error Model
PowerSync owns offline durability, reconnect-replay, idempotency, and realtime delivery (ADR-0040). The app owns the write contract and error semantics. State and DI are managed by Riverpod (ADR-0042).
- Read path: local SQLite ← PowerSync ← Postgres; reactive queries surface as
Stream<List<T>>; UI updates realtime. - Write path: local write → PowerSync upload queue →
uploadData()(Flutter-side bridge) → existing Go API endpoints → Postgres; server-authoritative conflict resolution.
- Operation-to-endpoint map (Phase 1 requirement): must be defined per synced entity before implementation. Known mapping: Task creates/updates route through the event-based PATCH API (ADR-0013), preserving state-machine validation server-side. New entities (e.g. Shift clock-in/clock-out) require new Go endpoints, added only as each entity is introduced.
- Task status conflicts: ADR-0013's server-side Task state machine is the conflict resolver for Task status transitions — not last-write-wins. LWW may apply to non-status fields only. If a queued offline Task event is rejected by the Go API as an invalid transition,
uploadData()must surface a typedAppFailureto the presentation layer rather than silently dropping it. - IDs: UUIDv7 client-generated, identical key type in Postgres and client SQLite.
- Migrations: none hand-written client-side — PowerSync syncs schemaless and projects schema as SQLite views. Evolve via schema definition + Sync Rules. Data transformation logic stays in the Go backend.
- All local state in PowerSync — no separate Drift/sqflite DB unless explicitly justified.
Error handling — sealed Result<T> + AppFailure
In core_models (ADR-0045):
- Repositories return
Result<T>for operations that can meaningfully fail. - Exceptions are caught and mapped to typed
AppFailureonly at the data-layer boundary; domain and presentation nevertry/catchorthrow, and pattern-match exhaustively. Resultapplies to the write path and non-synced API calls; PowerSync reactive reads surface asStream, not wrapped where there is no real failure mode.
Background Location (FieldForce only)
core_location package; engine flutter_background_geolocation. Digital Worker does not depend on it. (Full decision in ADR-0043.)
-
Gate 1 — Feature flag
Both the platform-wide kill-switch (
admin_feature_flags) AND the per-orgfieldforce.locationflag (org_feature_flags, ADR-0016) must be on. Read from a dedicated Go API flags endpoint; cached locally. Unknown = off. Also the remote kill-switch. -
Gate 2 — OS permission
"Always" location permission granted by the user.
-
Gate 3 — Shift state
Worker has an active Shift (
ff_shiftsor equivalent) in the synced local SQLite. Shift is a server-authoritative entity minted by the Go backend; the mobile gate reads it from PowerSync-synced local SQLite. This entity does not exist in the backend today — it is a Phase 1 backend requirement. Until it is implemented and synced, background location MUST remain feature-flagged off in production.
- Flipping the flag off cleanly tears down the foreground service, stops GPS, and removes the tracking notification — not merely stops uploading.
- Flag values are cached locally and evaluated from cache offline (last-known value; unknown = off, never default-on). The cache is refreshed on auth, app start, and resume.
- Location upload is separate from PowerSync — the engine's own offline queue → a dedicated Go endpoint (append-only telemetry).
- Android requires a foreground service with a visible notification while tracking; iOS requires "Always" permission + location background mode.
UI, Brand & Platform Behavior
- Brand tokens flow through
ThemeData+ aThemeExtension(single source of truth incore_ui). - Brand wrapper components (
BrandButton,BrandSwitch,BrandTextField,BrandSelect,BrandDatePicker, …) — app code uses these, never rawSwitch/TextField/DropdownButton(Brand-wrapper rule inCLAUDE.md). - Wrappers apply brand styling to chrome and delegate the interactive primitive to the platform-appropriate widget (Cupertino on iOS, Material on Android) where native muscle-memory matters. All
Platform.isIOSbranching is centralized inside the wrappers. - i18n: slang (ADR-0046) — typed dot-notation, no
BuildContext; locales EN / ZH (Simplified) / MS; callable from controllers andcore_models. - Time: UTC always for storage/transport; convert at presentation only; server time is authoritative.
Configuration & Observability
- Non-secret config via
--dart-define-from-file=config/<flavor>.json. Apps read config at the composition root and inject it into packages; packages never read env directly. True secrets live in Codemagic CI secrets, never the bundle. - Flavors: dev / staging / prod, with separate bundle IDs and entrypoints.
- Crash reporting: Firebase Crashlytics (Firebase is present for FCM). Remote observability is the primary debugging channel — fix from traces, not device repro.
- Feature flags: use the existing ADR-0016 two-tier flag infrastructure. Mobile reads a lightweight flags endpoint at auth / app start / resume; caches last-known values for offline evaluation. Unknown flag value = off. v1 scope: platform-wide kill-switch (
admin_feature_flags) plus per-orgfieldforce.location. No per-user/per-role targeting in MVP. Do NOT addorg_feature_flagsto PowerSync — fetch-and-cache is sufficient.
Crashlytics debug context
Crashlytics captures crashes and supports custom keys/logs, but FieldForce must set its own non-PII debugging metadata. Required keys: app flavor, app version/build number, platform = "mobile", OS name/version, device model, organization ID, user ID hash (never raw email/name/phone), current route/screen, active Task ID, active Shift ID, PowerSync connection state, pending upload queue count, last sync timestamp, location gate states (feature flag / permission / Shift), and relevant feature flags. Update keys on auth, org switch, route change, sync state change, Shift change, and location gate change.
Error Handling
| Case | Behavior |
|---|---|
| Device offline | App operates fully on local SQLite; writes queue in PowerSync; no error surfaced beyond an offline indicator. |
| Reconnect after offline | PowerSync replays the upload queue and reconciles; server-authoritative conflict resolution applies. |
| Rejected Task upload | If a queued offline Task event is no longer valid when replayed, show a clear message explaining why sync is no longer valid, show the latest server version, mark the local submit as failed, allow review/resubmit if appropriate, and never silently delete the user's action. |
| Write conflict | Resolved server-side at the Go API. Task status transitions use the ADR-0013 state machine; non-status field conflicts may use LWW. Rejected queued writes surface as typed AppFailures and follow the rejected-upload UX above. |
| Access token expired, refresh valid | Refresh-on-401 refreshes transparently; user uninterrupted. |
| Access + refresh both invalid/expired | Hard-lock to verification/login; cached data no longer trusted. |
| Refresh impossible (offline) | Offline grace: keep working on cached data until refresh-token expiry. |
| Unverified Gremlin account | Route to verification flow; gated actions blocked (default-deny). |
| Push received (foreground/background/killed) | Headless handler triggers PowerSync sync; on tap, deep-link via go_router. Silent pushes best-effort (iOS throttles). |
| Location flag off | Tear down foreground service, stop GPS, remove notification. |
| Location permission denied | Do not start tracking; surface state to the user; no foreground service. |
| OEM battery-killer suspends tracking | Known field-device reality; mitigated by flutter_background_geolocation + per-OEM guidance, not fully solvable in code. |
| Repository operation fails | Data layer maps exception → typed AppFailure; controller pattern-matches and shows the appropriate UI. |
| Crash | Captured by Crashlytics with context for remote diagnosis. |
Privacy Boundaries
- Location is shift-scoped and consented — tracked only while on-the-clock, never always-on; the feature flag is a hard remote off-switch.
- Tokens and sensitive data live only in
flutter_secure_storage. - Sync Rules ensure a device syncs only its own user/team subset — no cross-tenant data on-device.
- The tracking foreground-service notification keeps location use visible to the worker while active.
- No employee performance scoring from location or activity in MVP.
Testing (MVP floor)
- Widget tests for critical user flows (offline work cycle, verification gate, brand components).
- Integration tests for the PowerSync upload path and MVP sync visibility (user only sees records they are allowed to see). Fine-grained RBAC Sync Rules are deferred to a later hardening phase.
- Shared
core_*packages carry the strictest coverage — they are load-bearing for both apps. - Location: tests for the three-gate activation and clean teardown on flag-off.
- Observability: tests or smoke checks that Crashlytics custom keys are set after auth, route change, sync state change, Shift change, and location gate change; verify no raw PII is sent as keys or user identifiers.
CI/CD
(See ADR-0047.)
- Codemagic for build, iOS/Android code signing, and store distribution.
- Path-based triggers: a change under
apps/fieldforce/**or apackages/*it depends on builds only FieldForce. - Backend (Go/TS) stays on GitHub Actions.
- Shorebird (Dart code push) deferred to Phase 5.