Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

13 — State Management (Biometric Module)

Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) + the server-side sync job state machine the UI must mirror. Backed by BiometricRepository (dio) which calls the endpoints in 12_API_Mapping.md. All screens are (planned) — cubits are designed against the blueprint contract, not shipped today.


1. Server-side sync job lifecycle (source of truth for UI)

stateDiagram-v2
    [*] --> scheduled : repeatable job (*/15 * * * *)
    scheduled --> enqueued : scheduler fires (queue: biometric-sync)
    enqueued --> active : worker picks up
    active --> completed : punches ingested
    active --> failed : device offline / vendor error
    failed --> active : retry (4x, exp 2000ms)
    failed --> dlq : retries exhausted
    dlq --> [*] : manual replay (Bull-Board)
    completed --> [*] : done

Sources: queue.constants.ts:7 (queue), scheduler.service.ts:70-76 (cadence), blueprint RETRIES.md:38 (4 retries / exp 2000 ms), Biometric.md:58 (→ DLQ). The active transition has no worker today (OQ-4) — SyncCubit must treat "queued forever" as an error state ("queue unavailable").

2. Per-screen Cubits

ScreenCubitEvents → State
Hub/Sync StatusBiometricHubCubitLoad, Refresh, SyncNow → {initial, loading, loaded(devices, queue), degraded, error}
Device ListDeviceListCubitLoad, Refresh, Disable(id) → {initial, loading, loaded([device]), empty, error, disabling}
Device RegisterDeviceRegisterCubitRegister(form) → {idle, submitting, success(device), duplicate, error}
Device DetailDeviceDetailCubitLoad(id), Sync(id), Update(id, patch) → {initial, loading, loaded(device, lastPunches), syncing, syncResult, error}
EnrollmentEnrollCubitSelectStudent, Capture, Confirm → {selecting, capturing, captureFailed, confirming, enrolled, duplicate}
Log ListLogListCubitLoad, Refresh, ApplyFilters(f), ClearFilters → {initial, loading, loaded([punch]), empty, error}
Verify Check-inVerifyCheckinCubitLoad(student, date), OpenAttendance → {idle, loading, matched, mismatched, noPunch, error}

Conventions: LoadState from 00-shared/06 §3.1; lists refresh via RefreshIndicator bypassing cache; verdict screens never serve cached data.

3. State objects (concise)

class DeviceStatus { active, inactive, offline }        // biometric-device.schema.ts:7-11
class Device { id, name, deviceId, model?, status, location?, config?, createdAt, updatedAt }
class Punch { studentId, deviceId, timestamp, mode?, rawData?, processed? } // processed planned
class SyncState { enum {idle, running, succeeded, failed, dlq}; int punches; int retries; }
class EnrollFlow { student?, step; captureState; enrolledStudent? }
class VerifyVerdict { enum {matched, mismatched, noPunch}; List<Punch> punches; }

4. Events & actions map (UI → Cubit → API)

UI eventCubit methodRepository call
Hub refreshload()biometricRepo.devices() + queueHealth() (Bull-Board)
Hub Sync nowsyncNow(id)biometricRepo.syncDevice(id)
Devices refreshload()biometricRepo.devices()
Row disabledisable(id)biometricRepo.updateDevice(id, {status:'inactive'})
Register submitregister(form)biometricRepo.createDevice(form)
Detail syncsync(id)biometricRepo.syncDevice(id)
Detail punchesloadPunches(id)biometricRepo.logs(device: id, page: 1)
Enroll confirmconfirm()biometricRepo.enroll(deviceId, studentId) (planned)
Logs filterapplyFilters(f)biometricRepo.logs(filters)
Verifyverify(student, date)biometricRepo.logs(student: s, date: d) + attendanceRepo.student(s, d, d)

5. Repository

class BiometricRepository {
  Future<List<Device>> devices();
  Future<Device> createDevice(RegisterDeviceDto dto);
  Future<Device> updateDevice(String id, Map<String, dynamic> patch);
  Future<SyncResult> syncDevice(String id);
  Future<Paginated<Punch>> logs({String? studentId, String? deviceId, DateTimeRange? range, String? mode, int page});
  Future<EnrollResult> enroll(String deviceId, String studentId);   // planned
}

6. Caching & refresh

  • Devices: cache last-good list (local_cache), RefreshIndicator re-fetch (00-shared/06 §5).
  • Logs: no persistence cache — evidence must be live; filters are session state only.
  • Verify verdicts: never cached.
  • Queue health: refresh on hub open + pull-to-refresh; treat missing worker as error.

7. Error states per action

ActionErrorState →
sync5xx/networksyncResult.failed(retries) → banner "will retry (4× exp)"
syncDLQsyncResult.dlq → Bull-Board link
disable409/403snackbar; row unchanged
register409duplicate → inline deviceId banner
verify404 studentnoPunch verdict is data, not error; 404 student → inline
any401global refresh → session-expiry overlay

8. Testing hooks (00-shared/06 §6)

  • Pure-Dart cubits; unit-test the sync state machine (diagram §1) and verify verdict transitions (matched/mismatched/noPunch).
  • Widget tests: hub degraded state; device list empty/error; enroll capture-fail retry; log filter chips.
  • Integration: register → sync → log appears (mock server); retries exhausted → dlq state.

9. Cross-cutting interplay

  • ConnectivityCubit gates sync/enroll (offline → banner, no silent failure).
  • AuthCubit session expiry applies globally; FeatureFlagsCubit (proposed) could gate the whole biometric subtree while enrollment is vendor-gated (FEATURE_ROADMAP.md:56).
  • Attendance cubits are not extended here — biometric surfaces read attendance via repository calls only (module boundary, AGENTS.md).