中文版

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.

View source markdown ↗ generated by claude-opus-4-8 · diagrams mermaid

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.

  • Platform Flutter — Android + iOS
  • Backend Existing Go / Postgres / NATS (single source of truth)
  • Sync PowerSync — offline + realtime, no mobile backend
  • Workspace melos monorepo, shared core_* packages

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.

ADRDecision
ADR-0037Flutter for the mobile client (over RN / Capacitor / native)
ADR-0038melos monorepo, multi-app, shared core_* packages
ADR-0039Clean Architecture + inward-pointing package dependency DAG
ADR-0040PowerSync for offline + realtime sync
ADR-0041Go-minted JWT, refresh-on-401, offline grace, verified-identity gate
ADR-0042Riverpod for DI + state
ADR-0043Background location — three-gate activation, telemetry routed around PowerSync
ADR-0044Push notifications — FCM + APNs, sync-on-push, deep-link routing
ADR-0045Sealed Result<T> + AppFailure error modeling
ADR-0046slang for i18n
ADR-0047Codemagic 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).

Selected

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.

Rejected

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.

Rejected

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.

Rejected

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

FieldForce data architecture — interactive entities via PowerSync, location telemetry around it, event wake-ups via push.
FieldForce data architecture — interactive entities via PowerSync, location telemetry around it, event wake-ups via push.

Heterogeneous data paths

The key consequence of going offline-first + location: not all data flows through the same pipe.

DataPathRealtimeOffline behavior
Interactive entities (work orders, tasks)PowerSync ↔ local SQLite ↔ PostgresPowerSync reactive queriesLocal SQLite is source while offline; upload queue replays on reconnect
Background location telemetryflutter_background_geolocation own queue → dedicated Go endpointN/A (append-only)Engine's own offline buffer; not in PowerSync buckets
Event wake-ups (assignment, etc.)FCM/APNs push from Go backendPush (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/AppFailure types (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

  1. 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.

  2. 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.

  3. Phase 3 — Location & Telemetry Maturity

    OEM battery-optimization handling, shift-scoped consent flows, geofencing/activity-recognition tuning via flutter_background_geolocation.

  4. 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.

  5. Phase 5 — OTA & Release Velocity

    Evaluate Shorebird for Dart code push to shorten iterate-and-ship cycles without full store resubmission.

  6. 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 via POST /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 marker platform: "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, and platform = "mobile". The app may also call GET /api/auth/me to 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 typed AppFailure to 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 AppFailure only at the data-layer boundary; domain and presentation never try/catch or throw, and pattern-match exhaustively.
  • Result applies to the write path and non-synced API calls; PowerSync reactive reads surface as Stream, 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.)

  1. Gate 1 — Feature flag

    Both the platform-wide kill-switch (admin_feature_flags) AND the per-org fieldforce.location flag (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.

  2. Gate 2 — OS permission

    "Always" location permission granted by the user.

  3. Gate 3 — Shift state

    Worker has an active Shift (ff_shifts or 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 + a ThemeExtension (single source of truth in core_ui).
  • Brand wrapper components (BrandButton, BrandSwitch, BrandTextField, BrandSelect, BrandDatePicker, …) — app code uses these, never raw Switch/TextField/DropdownButton (Brand-wrapper rule in CLAUDE.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.isIOS branching is centralized inside the wrappers.
  • i18n: slang (ADR-0046) — typed dot-notation, no BuildContext; locales EN / ZH (Simplified) / MS; callable from controllers and core_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-org fieldforce.location. No per-user/per-role targeting in MVP. Do NOT add org_feature_flags to 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

CaseBehavior
Device offlineApp operates fully on local SQLite; writes queue in PowerSync; no error surfaced beyond an offline indicator.
Reconnect after offlinePowerSync replays the upload queue and reconciles; server-authoritative conflict resolution applies.
Rejected Task uploadIf 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 conflictResolved 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 validRefresh-on-401 refreshes transparently; user uninterrupted.
Access + refresh both invalid/expiredHard-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 accountRoute 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 offTear down foreground service, stop GPS, remove notification.
Location permission deniedDo not start tracking; surface state to the user; no foreground service.
OEM battery-killer suspends trackingKnown field-device reality; mitigated by flutter_background_geolocation + per-OEM guidance, not fully solvable in code.
Repository operation failsData layer maps exception → typed AppFailure; controller pattern-matches and shows the appropriate UI.
CrashCaptured 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 a packages/* it depends on builds only FieldForce.
  • Backend (Go/TS) stays on GitHub Actions.
  • Shorebird (Dart code push) deferred to Phase 5.

Open Questions