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)
Navigation¶
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¶
../brainstorms/brainstorm-07-mobile-strategy.md— decision discussion../brainstorms/brainstorm-09-ui-ux-vision.md— UX principles../10-GUARDRAILS.md§1b (CQM-01–CQM-12), §12 (IR-15/16)./architecture.md— main architecture (mobile summary)./agents.md— agent interactions from mobile./irap.md— IRAP deployment considerations./data.md— offline event shape mirrors server events