Skip to content

Finnest Mobile Architecture

Date: 2026-04-16 Status: Draft Scope: Flutter mobile app architecture — one app, four roles, offline-first, agent-chat-primary. Expands on the summary in architecture.md.

Related documents: architecture.md (main), ../brainstorms/brainstorm-07-mobile-strategy.md, ../10-GUARDRAILS.md §1b (CQM-*), §12 (IR-15/16).


Purpose & Scope

One Flutter mobile application (finnest-mobile separate repo) replaces four existing apps (aCMS, aAMS, aTask, aTeam) with role-based feature visibility. Primary interaction is agent chat; traditional screens exist as fallback and for complex flows. Works offline with a SQLite event queue that mirrors the server event shape.

Non-goals:

  • Not a LiveView-Native experiment — Flutter is the pragmatic choice (B07)
  • Not a web responsive clone — mobile is capture-first, web is manage-first (B07 Insight 5)
  • Not feature-parity with web — mobile shows what field workers and supervisors need, not the full admin surface

Decision: Flutter (adapt existing)

Framework chosen: Flutter 3.x + Dart. Adapt the existing Flutter v5.0.4 codebase rather than rewrite.

Why Flutter (B07):

  • Existing code already proves NFC, GPS, push, biometric, BLoC pattern
  • Bounded polyglot cost (~5–10K LOC of Dart vs 150K+ LOC of Elixir backend)
  • Mature native capabilities: NFC, QR, GPS geofencing, offline SQLite, camera, biometrics, background location
  • Single codebase → iOS and Android builds

Why not alternatives (B07):

  • LiveView Native: NFC, offline SQLite, background location aren't production-ready
  • React Native: ecosystem churn; bridge architecture; fourth language in the stack (JS)
  • KMP + Compose: no code sharing with Elixir backend; iOS Compose still maturing
  • PWA: no NFC; weak App Store presence for enterprise MDM

One App, Four Roles

Single codebase, single App Store listing, role-based navigation:

Old plan New plan (Finnest mobile)
aCMS (employee companion) Finnest — Field Worker role
aAMS (asset management) Finnest — Asset Manager role
aTask (task management) Finnest — Supervisor role
aTeam (team management) Finnest — Supervisor role
Finnest — Client Contact role (new)

Role-based visibility:

On login: server returns JWT containing role claim + org_id
  └─ Flutter reads role from JWT
      └─ UiConfig.for_role(role) determines:
          - visible bottom nav items
          - home screen layout
          - available quick actions
          - which agent tools the chat agent can invoke

Screens per role:

Role Screens Primary interaction
Field Worker ~5 (home/agent, roster, timecard, leave, notifications, profile) Agent chat, NFC clock
Supervisor ~10 (above + team view, approvals, shifts, tasks, incident report) Notifications + agent chat
Asset Manager ~10 (above + equipment, maintenance, fleet, checklists) Quick actions + agent chat
Client Contact ~5 (assigned workers, roster view, placements, billing summary) Client portal

App Architecture

FINNEST MOBILE APP (Flutter 3.x)
├── Agent Chat (home screen — primary interaction)
│   └── "Clock me in" → NFC/GPS flow → recorded
│   └── "My roster?" → roster card
│   └── "Leave request" → conversational form → submitted
│   └── "Approve timesheets" → batch list with inline actions (supervisor)
├── Native Features
│   ├── Clock In/Out (NFC tag, QR code, GPS geofencing)
│   ├── Background Location streaming (roster active window only)
│   ├── Camera (document upload, incident photos, timesheet dockets)
│   ├── Push Notifications (Firebase FCM — encrypted payload)
│   ├── Biometric Auth (fingerprint, face)
│   └── Offline Event Queue (SQLite)
├── Role-Based Screens
│   ├── Field Worker: roster, timecards, leave, notifications, profile
│   ├── Supervisor:  + team view, approvals, shift assignment, tasks
│   ├── Asset Manager: + equipment, maintenance, fleet, checklists
│   └── Client Contact: assigned workers, roster view, placements
├── Offline Sync Engine
│   ├── SQLite event queue (actions stored as events)
│   ├── Automatic sync when online
│   ├── Conflict resolution per event type (documented below)
│   └── Last-sync timestamp per entity type
└── Connectivity to Backend
    ├── REST API (Phoenix JSON) for CRUD and sync
    ├── WebSocket Channels for real-time (notifications, agent chat streaming)
    └── JWT auth with biometric-unlocked refresh token

State management

BLoC pattern (existing, CQM-08). Feature modules self-contained (CQM-09). Key BLoCs:

AuthBloc            — login state, JWT lifecycle, biometric unlock
RosterBloc          — user's shifts, updates via WS
TimecardBloc        — timecard entry state
AgentBloc           — chat session, streaming responses
SyncBloc            — offline queue, sync status, conflicts
NotificationBloc    — inbox, unread count, actionable items
ConnectivityBloc    — online/offline state (drives offline UI affordances)

GoRouter with role-based route guards:

GoRouter(
  routes: [
    GoRoute(path: '/', builder: (_, __) => HomeAgentScreen()),
    GoRoute(
      path: '/roster',
      builder: (_, __) => RosterScreen(),
      redirect: (ctx, state) => requireRole([Role.fieldWorker, Role.supervisor])
    ),
    GoRoute(
      path: '/team',
      builder: (_, __) => TeamScreen(),
      redirect: (ctx, state) => requireRole([Role.supervisor])
    ),
    // ... role-guarded routes ...
  ],
);

Connection to Backend

Transport summary

Concern Protocol Endpoint Notes
CRUD / actions REST over HTTPS /api/v1/* JWT; 1-hr access + 7-day refresh commercial; 15-min access no refresh IRAP
Real-time push Phoenix Channels over WSS user:<id>, org:<id>:roster, agent:<session_id> Reuses same JWT
Agent chat streaming Phoenix Channel (Part 6 decision — not SSE) agent:<session_id> Token-by-token streaming
Offline sync REST POST /api/v1/sync Batch event submission
Push notifications Firebase Cloud Messaging FCM SDK Encrypted payload only; no PII in notification body

Auth flow

Commercial (1-hr access + 7-day refresh + biometric):

1. First-time login: email + password (+ TOTP/FIDO2 if enabled)
   └─ Server returns {access_token (1h), refresh_token (7d)}
2. Refresh token stored in device secure enclave
   └─ Unlocked by biometric (fingerprint/face) on each app launch
3. Access token in memory (never persisted)
4. On 401: attempt refresh via refresh_token
   └─ Success → new access_token
   └─ Failure → force re-login

IRAP (15-min access + no refresh):

1. Login: email + password + FIDO2 (mandatory)
2. Server returns {access_token (15min)} only — no refresh token
3. User re-authenticates every 15 min
4. App locks after 5 min idle (biometric unlock)
5. FIDO2 re-prompt required for session re-establishment
6. MDM (Microsoft Intune) enrollment required (IR-16)

Cert pinning

App pins the ALB certificate chain — rejects TLS connections with non-pinned certs. Prevents MITM even if device trusts a malicious CA. Rotation: app update pushed before cert rotation on server.


Offline Strategy

What works offline

Feature Offline Notes
Clock in/out Stored in SQLite, synced when online
View roster ✓ (cached) Last-synced roster data
Time card entry Queued for sync
Leave request Queued for sync
View notifications ✓ (cached) Cached list; no new notifications offline
Document upload Queued Photo captured, upload queued
Prestart checklist Queued for sync
Digital docket signing Stored with timestamp and location
Agent chat Requires backend connection
Approvals Requires server state
Complex queries Requires backend

SQLite event queue

Schema mirrors the server event shape, so client events are naturally consumable by the server:

-- local SQLite (on device)
CREATE TABLE offline_events (
  id TEXT PRIMARY KEY,                      -- UUID generated client-side
  event_type TEXT NOT NULL,                 -- 'clock_in' | 'timecard_entry' | 'leave_request' | ...
  aggregate_id TEXT,                         -- local entity id (resolves to server UUID via mapping after sync)
  payload TEXT NOT NULL,                    -- JSON
  created_at TEXT NOT NULL,                 -- ISO 8601
  synced_at TEXT,                           -- NULL until synced
  sync_status TEXT NOT NULL DEFAULT 'pending',   -- pending | synced | conflict | failed
  sync_attempts INTEGER NOT NULL DEFAULT 0,
  last_error TEXT,
  correlation_id TEXT NOT NULL               -- same ID stored in server event.metadata.correlation_id
);

CREATE INDEX idx_offline_pending ON offline_events(sync_status, created_at) WHERE sync_status = 'pending';

CREATE TABLE cached_entities (
  entity_type TEXT NOT NULL,
  entity_id TEXT NOT NULL,
  data TEXT NOT NULL,
  last_synced_at TEXT NOT NULL,
  PRIMARY KEY (entity_type, entity_id)
);

CREATE TABLE sync_state (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL,
  updated_at TEXT NOT NULL
);
-- Example rows:
--   ('last_sync_at', '2026-04-16T02:30:00Z')
--   ('jwt_refresh_token_enc', '<encrypted>')

Sync Protocol

Endpoint: POST /api/v1/sync

Request:

{
  "last_sync_at": "2026-04-16T02:30:00Z",
  "events": [
    {
      "id": "client-uuid-1",
      "event_type": "clock_in",
      "aggregate_id": "employee-uuid",
      "payload": {
        "timestamp": "2026-04-16T06:02:14Z",
        "lat": -33.72,
        "lon": 150.81,
        "nfc_tag": "ASG-MINCH-GATE-A",
        "device_id": "iphone-..."
      },
      "created_at": "2026-04-16T06:02:14Z",
      "correlation_id": "client-correlation-1"
    },
    ...
  ]
}

Response:

{
  "confirmations": ["client-uuid-1", "client-uuid-2"],
  "conflicts": [
    {
      "client_event_id": "client-uuid-3",
      "resolution": "server_wins",
      "reason": "shift_already_changed",
      "server_state": { ... }
    }
  ],
  "updates": [
    { "entity_type": "shift", "entity_id": "...", "data": { ... } },
    { "entity_type": "notification", "entity_id": "...", "data": { ... } }
  ],
  "server_time": "2026-04-16T03:15:22Z"
}

Conflict Resolution Rules (B07)

Event type Resolution Rationale
Clock events Client wins (timestamp authoritative) Worker physically was there; no way for server to know better
Roster changes (assignment, swap) Server wins Supervisor may have changed roster while worker offline
Leave requests Server decides Request may already be approved/rejected by time it syncs
Time card entries Merge with server validation Client-captured hours + server-validated calculation
Document uploads Accept both Uploads are additive, never conflict
Incident reports Accept client (log server version as correction if differs) Field observation is authoritative
Approval actions (supervisor) Server wins if action already processed Approvals shouldn't be replayed

Performance target (PF-06)

  • 100 queued events reconcile in <10s on typical 4G connection
  • Events batched 50-at-a-time to keep request size manageable
  • Failed sync retried with exponential backoff (1s, 5s, 30s, 5min, 30min, abandon-with-alert)

Agent Chat on Mobile

Primary interaction pattern

Agent chat is the home screen. Traditional screens are one tap away.

┌──────────────────────────┐
│  Finnest                 │
│  Good morning, John      │
│                          │
│  Your shift: 6AM-2PM     │
│  Woolworths Minchinbury  │
│  [ Clock In ]            │
│                          │
│  ─────────────────────   │
│                          │
│  💬 Ask me anything...   │
│                          │
│  Quick actions:          │
│  [Roster][Leave][Time]   │
│                          │
└──────────────────────────┘

Not a chat bubble in a corner — the home screen is the conversation.

Example interactions

User: "Clock me in"
  Agent: "Scan your tag" → NFC prompt
  → NFC scan detected: ASG-MINCH-GATE-A
  → Agent: "Clocked in at 6:02 AM. Shift ends 2:00 PM."
  → If offline: queued, shown with ⏳ badge, synced automatically later

User: "Swap Thursday with Sarah"
  Agent: checks Sarah's availability, both rosters, compliance
  → "Sarah is available Thursday. I'll request the swap."
  → Creates swap request via roster_mcp.propose_swap (PROPOSE category)
  → Notifies supervisor

User: "I had an incident on site"
  Agent: "What happened? I'll start an incident report."
  → Guides through: what, when, where, who, injury?, photos?
  → Creates safety.incident record via safety_mcp.report_incident
  → Offline-capable (photos queued for upload)

User: "My forklift cert expires soon"
  Agent: "Your forklift license expires June 15. I can help schedule renewal."
  → Offers to notify compliance team or link to training provider

Streaming from server

Agent responses stream over Phoenix Channel on topic agent:<session_id> (Part 6 decision):

class AgentBloc extends Bloc<AgentEvent, AgentState> {
  final PhoenixChannel _channel;

  void _onSendMessage(SendMessage event, Emitter<AgentState> emit) async {
    // Post via REST to create the user message
    await api.post('/api/v1/agent/sessions/${event.sessionId}/messages',
      body: {'content': event.text});

    // Response streams via channel
    _channel.on('message_token', (payload) {
      emit(state.copyWithAppendedToken(payload['token']));
    });

    _channel.on('message_complete', (payload) {
      emit(state.copyWithComplete(payload));
    });

    _channel.on('tool_call', (payload) {
      // Surface tool call progress in UI ("Checking your roster...")
      emit(state.copyWithToolCall(payload));
    });
  }
}

Fallback to screens

Agent can't handle everything — complex interactions fall back:

  • Multi-field time card entry with allowances → TimeCard screen
  • Detailed roster calendar view → Roster screen
  • Profile editing (address, bank, emergency contact) → Profile screen
  • Document upload with metadata → Upload screen

Native Feature Integration

NFC (clock-in)

class NfcService {
  Future<String?> readTag({Duration timeout = const Duration(seconds: 30)}) async {
    final nfc = await NfcManager.instance;

    if (!await nfc.isAvailable()) {
      throw NfcUnavailableException();
    }

    final completer = Completer<String?>();

    await nfc.startSession(
      onDiscovered: (NfcTag tag) async {
        final ndef = Ndef.from(tag);
        final message = await ndef?.read();
        completer.complete(extractTagId(message));
        await nfc.stopSession();
      },
    );

    return completer.future.timeout(timeout);
  }
}

GPS geofencing

class LocationService {
  StreamSubscription<Position>? _positionStream;

  // Active only during shift window — conserves battery
  void startTracking(Shift shift) {
    _positionStream = Geolocator.getPositionStream(
      locationSettings: LocationSettings(
        accuracy: LocationAccuracy.best,
        distanceFilter: 20, // metres
      ),
    ).listen((position) {
      _checkGeofence(position, shift);
      _recordLocation(position, shift);
    });
  }

  void stopTracking() {
    _positionStream?.cancel();
    _positionStream = null;
  }

  bool _checkGeofence(Position pos, Shift shift) {
    final distance = Geolocator.distanceBetween(
      pos.latitude, pos.longitude,
      shift.siteLat, shift.siteLon,
    );
    return distance <= shift.geofenceRadiusMeters;
  }
}

Battery discipline (CQM-12): background location active only during scheduled shift window ± 30 min. Rest of day: no background tracking.

Push notifications (FCM)

Firebase Cloud Messaging with encrypted payload — notification body never contains PII:

Bad:  "John's shift at Woolworths Minchinbury has changed"
Good: "You have a roster update"   + deep link to /roster

The app wakes, fetches the updated entity via REST (authenticated), then shows the real content.

IRAP: FCM is acceptable because payload is encrypted and no PII travels via FCM (IR-15). Firebase Analytics is disabled in IRAP build.

Biometric unlock

class SecureStorageService {
  final _secureStorage = FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
  );

  Future<String?> getRefreshToken() async {
    final canCheckBiometric = await LocalAuthentication().canCheckBiometrics;

    if (canCheckBiometric) {
      final authenticated = await LocalAuthentication().authenticate(
        localizedReason: 'Authenticate to access Finnest',
        options: AuthenticationOptions(biometricOnly: true, stickyAuth: true),
      );

      if (!authenticated) return null;
    }

    return await _secureStorage.read(key: 'refresh_token');
  }
}

IRAP Mobile Build

Separate Flutter flavor (flutter build apk --flavor irap / flutter build ipa --flavor irap).

IRAP-specific deltas

Aspect Commercial IRAP
Distribution Public App Store + Play Store Microsoft Intune MDM only (IR-16)
OAuth providers Google, Microsoft, Apple Microsoft Entra ID only (IR-13)
Firebase Analytics Enabled Disabled (IR-15)
FCM Push Enabled Enabled but no PII in payload
Biometric + PIN Biometric optional Mandatory (biometric + PIN)
Idle lock 60 min 5 min
Session timeout 1 hour 15 min (no refresh token)
MFA Optional TOTP / FIDO2 FIDO2 mandatory
Remote wipe Via device OS Via Intune MDM
Cert pinning ALB cert IRAP ALB cert (different trust root)
App ID au.finnest.app au.finnest.app.irap
Icon / theme Standard Distinct visual marker (classified branding)
Backup codes Printable recovery codes Printable recovery codes only (no SMS)

Build pipeline

Same GitHub Actions CI, with flavor-specific build jobs:

jobs:
  build-commercial:
    steps:
      - run: flutter build apk --flavor commercial --release
      - run: flutter build ipa --flavor commercial --release
      - upload-to-app-store

  build-irap:
    environment: irap-mobile     # requires 2-person approval
    steps:
      - run: flutter build apk --flavor irap --release
      - run: flutter build ipa --flavor irap --release
      - upload-to-intune-tenant

Testing Strategy (CQM-*)

Test pyramid

Level Framework Coverage target
Unit flutter test BLoCs + services + repositories
Widget flutter test Every reusable widget
Golden flutter test Critical screens (update deliberately)
Integration flutter test integration_test/ Critical flows (login, clock-in, offline sync)
Accessibility axe-style semantic audit Every screen

Critical-flow integration tests

  • Login flow (including biometric unlock)
  • Clock in → clock out → timecard entry → submit
  • Offline: airplane mode → clock events queued → reconnect → sync
  • Roster view → shift swap request → notification received
  • Incident report with photo upload
  • Agent chat with tool call (roster_list_shifts)

Offline integration tests

The key differentiator for mobile. Tests simulate connectivity flapping and verify:

  • Pending events persist across app restarts
  • Sync completes after reconnect within PF-06 target (10s for 100 events)
  • Conflict resolution applies correct rule per event type
  • UI shows correct pending/syncing/synced/conflict badges

Migration from Current Flutter App (B07)

What to keep

  • BLoC state management architecture
  • GoRouter navigation pattern
  • NFC implementation (nfc_manager)
  • GPS/Location service (geolocator)
  • Push notification setup (Firebase FCM)
  • Dio/HTTP client pattern

What to change

  • API endpoints: v2 CodeIgniter → Finnest Phoenix API (/api/v1/*)
  • Auth: custom token → JWT with refresh + biometric unlock
  • Add: SQLite offline event queue (drift or sqflite)
  • Add: Agent chat interface (new primary UI)
  • Add: Role-based navigation (replace static menu)
  • Add: Asset management screens (absorbing aAMS)
  • Add: Task management screens (absorbing aTask)
  • UI: refresh to match Finnest design system (DaisyUI-inspired)
  • Remove: v2-specific workarounds and legacy patterns
  • Separate: IRAP flavor with distinct build config

Estimated effort (B07)

Work item Duration
API migration 2 weeks
Offline sync engine 2 weeks
Agent chat interface 1 week
Role-based navigation 1 week
Asset management screens 1 week
UI refresh 1 week
Testing + App Store submission 1 week
Total ~8-10 weeks

Aligns with main roadmap Phase 2 (Weeks 13–20).


Security (SE-* / CQM-07 / IR-15/16)

Concern Control
JWT storage Secure enclave (Keychain iOS / Keystore Android) via flutter_secure_storage
Biometric gate Required to unlock refresh token (SE-18); mandatory in IRAP
Null safety Entire codebase with no ! force-unwraps on nullable values (CQM-07)
Hardcoded secrets flutter_dotenv + CI-injected secrets; never in source (SE-17)
Cert pinning Pinned to ALB cert chain; rejects MITM CAs
Logging Log by ID only (CQM via main principle Commandment #24); no PII in log messages
Crash reporting Firebase Crashlytics (commercial); disabled in IRAP (replaced by in-app reporter → Sentry self-hosted)
Permissions Request just-in-time; explain rationale; respect denial
Background location Only active during shift window; respects battery optimiser (CQM-12)
Device trust MDM enrolment enforced for IRAP build (IR-16); managed policies applied

Performance Targets (PF-05, PF-06)

Metric Target
Cold app launch <2s (PF-05)
Warm app launch <500ms
Offline event sync (100 events) <10s on 4G (PF-06)
Screen transition <300ms
NFC tag read <1s after contact
GPS fix on clock-in <3s
Agent first token <1s (via channel)
Agent full response <3s for short queries

Observability (D19)

  • Sentry (commercial) for crash reports — PII-scrubbed
  • Custom in-app reporter (IRAP) — routes to Sentry self-hosted in IRAP VPC
  • Correlation IDs propagated from mobile into REST headers (X-Correlation-ID) so mobile-initiated actions trace end-to-end through server logs
  • Sync telemetry — pending queue depth, sync success rate, conflict rate reported as metrics to server
  • Session telemetry — launch time, screen time, error rate (opt-in, no Firebase Analytics in IRAP)

Open Questions

# Item Phase Owner
MOB-OI-01 Flutter state persistence choice (drift vs sqflite vs Hive) 2 Mobile lead
MOB-OI-02 Background location battery strategy refinement (Android 14+ restrictions) 2 Mobile lead
MOB-OI-03 Intune tenant provisioning — per-IRAP-client tenant or shared? 3 Mobile + IRAP
MOB-OI-04 Accessibility audit tooling (axe-style) for Flutter 2 QA
MOB-OI-05 Offline photo upload: compress on-device or full-res? (tradeoffs: quality vs sync time vs bandwidth) 2 Mobile + Product
MOB-OI-06 LiveView Native re-evaluation trigger (2027 review per main OI-08) Future Mobile lead

References