13 — State Management (Biometric Module)
- 1. Server-side sync job lifecycle (source of truth for UI)
- 2. Per-screen Cubits
- 3. State objects (concise)
- 4. Events & actions map (UI → Cubit → API)
- 5. Repository
- 6. Caching & refresh
- 7. Error states per action
- 8. Testing hooks (
00-shared/06 §6) - 9. Cross-cutting interplay
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
| Screen | Cubit | Events → State |
|---|---|---|
| Hub/Sync Status | BiometricHubCubit | Load, Refresh, SyncNow → {initial, loading, loaded(devices, queue), degraded, error} |
| Device List | DeviceListCubit | Load, Refresh, Disable(id) → {initial, loading, loaded([device]), empty, error, disabling} |
| Device Register | DeviceRegisterCubit | Register(form) → {idle, submitting, success(device), duplicate, error} |
| Device Detail | DeviceDetailCubit | Load(id), Sync(id), Update(id, patch) → {initial, loading, loaded(device, lastPunches), syncing, syncResult, error} |
| Enrollment | EnrollCubit | SelectStudent, Capture, Confirm → {selecting, capturing, captureFailed, confirming, enrolled, duplicate} |
| Log List | LogListCubit | Load, Refresh, ApplyFilters(f), ClearFilters → {initial, loading, loaded([punch]), empty, error} |
| Verify Check-in | VerifyCheckinCubit | Load(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 event | Cubit method | Repository call |
|---|---|---|
| Hub refresh | load() | biometricRepo.devices() + queueHealth() (Bull-Board) |
| Hub Sync now | syncNow(id) | biometricRepo.syncDevice(id) |
| Devices refresh | load() | biometricRepo.devices() |
| Row disable | disable(id) | biometricRepo.updateDevice(id, {status:'inactive'}) |
| Register submit | register(form) | biometricRepo.createDevice(form) |
| Detail sync | sync(id) | biometricRepo.syncDevice(id) |
| Detail punches | loadPunches(id) | biometricRepo.logs(device: id, page: 1) |
| Enroll confirm | confirm() | biometricRepo.enroll(deviceId, studentId) (planned) |
| Logs filter | applyFilters(f) | biometricRepo.logs(filters) |
| Verify | verify(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),RefreshIndicatorre-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
| Action | Error | State → |
|---|---|---|
| sync | 5xx/network | syncResult.failed(retries) → banner "will retry (4× exp)" |
| sync | DLQ | syncResult.dlq → Bull-Board link |
| disable | 409/403 | snackbar; row unchanged |
| register | 409 | duplicate → inline deviceId banner |
| verify | 404 student | noPunch verdict is data, not error; 404 student → inline |
| any | 401 | global 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
ConnectivityCubitgates sync/enroll (offline → banner, no silent failure).AuthCubitsession 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).