13 — State Management (Attendance Module)
- 1. Cubits & responsibilities
- 2. RosterCubit (S1 → S2)
- 3. MarkingCubit (S2) — the heart
- 4. Offline marking queue (module-defined;
00-shared/06 §3.7) - 5. HistoryCubit (S4/S7)
- 6. ReportCubit (S5/S6)
- 7. DeviceCubit (S8)
- 8. Cross-cutting
Per-screen state on top of 00-shared/06_State_Management.md (Bloc/Cubit, repository layer, SWR cache, WS, optimistic-update rules, offline queue contract). The module's defining problem: a marking grid with an offline write queue plus fast roster reads and async report polling.
1. Cubits & responsibilities
| Cubit | Screen(s) | State |
|---|---|---|
RosterCubit | S1, S2 | classes → roster rows + existing marks + live mark status |
MarkingCubit | S2, S3 | per-row status transitions, pending/queued flags, selection, batch apply, undo stack |
HistoryCubit | S4, S7 | month heatmap (per-day fetch fan-out), day summary, student history |
ReportCubit | S5, S6 | summary tile + async report job lifecycle (poll) |
DeviceCubit | S8 | devices, last punch, ingest counts, pipeline state |
All implement LoadState {Initial, Loading, Success, Error} (00-shared/06 §3.1);
lists implement PaginatedListMixin only where the API paginates — attendance reads
do not (OQ-4), so history uses client-side paging.
2. RosterCubit (S1 → S2)
state: {loadState, classes, classId?, date, rows: [AttendanceRow], existingByStudentId: Map}
events: PickClass, ChangeDate, Refresh, Retry
AttendanceRow = {student, doc?}— doc fromGET /attendance/class/:classId?date=(server state); absent doc = unmarked (default-present prospective state).- Roster source: students by
classId(student.schema.ts:38-39); class picker from academics (class.schema.ts:8-35). Roster fetch:GET /students?classId=(students module) — flagged as module dependency; fallback: students endpoint supportsq/pagination (00-shared/07 §5). - Cache: roster rows 24 h TTL (reference data,
00-shared/06 §3.3); existing marks 5 min TTL ("volatile", attendance today). Pull-to-refresh bypasses cache. - Realtime: subscribe to
attendance.changedWS topic (00-shared/06 §3.4) for the currentclassId+date→ re-fetch marks (another teacher/device wrote) — keeps grid honest; on 409 collision, same path.
3. MarkingCubit (S2) — the heart
state: {
rows, // statuses keyed by studentId
pending: Set<studentId>, // in-flight to server
queued: Map<studentId, MarkOp>,// offline queue
selection: Set<studentId>, // multi-select
undoStack: [{studentId, prevStatus}],
lastOp: {opId, count, failed: Set<studentId>}
}
events: CycleStatus(row), OpenPopover(row), SelectMany, ApplyBatch(status),
UndoLast, FlushQueue, RetryFailed, DiscardRow
Optimistic vs sync policy (per 00-shared/06 §3.5):
| Write | Policy | Reason |
|---|---|---|
| Single mark (POST upsert) | optimistic (flip chip → rollback on error) | upsert is idempotent & non-destructive; fast classroom cadence demands zero latency |
| Batch apply (POST bulk) | optimistic with conflict banner | overwrite semantics need the pre-flight banner (06 §3); after confirm, flip all, rollback failures |
| PATCH correction (S4/S7) | pessimistic (spinner, no local flip) | correction has event side effects (AttendanceUpdated) — "mutations with side effects are never optimistic" (00-shared/06 §3.5) |
| Sweep-all-absent | optimistic + UndoBar | reversible via undo re-send |
Rollback: on 4xx/5xx revert row to previous status, failed set, snackbar w/ retry.
Undo stack: max depth 50 ops; UndoBar for 4 s; undo = re-send previous statuses
(bulk where ≥ 2).
4. Offline marking queue (module-defined; 00-shared/06 §3.7)
MarkOp = {opId: uuid, kind: 'mark'|'batch', payload: MarkPayload[], createdAt, tenantId}
- Capture: offline or write-error → op appended to persisted queue (Hive box
att.queue, key{tenant}:{classId}:{date}), rows flaggedqueued(dashed chip + cloud icon). - Dedupe: before enqueue, drop queued ops for the same
studentId+date(replace with newer payload — last-write-wins mirrors server upsert). - Flush: on reconnect (
ConnectivityCubit) → sequentialPOST /attendance/POST /attendance/bulkper op, withIdempotency-Key= opId. Success → remove op; failure → keep, retry with backoff, cap 5 attempts then surface "N marks waiting — review". - Collision on flush (409): fetch server doc for that student+date; if differs from
queued payload → mark row "conflicted", show preview sheet; user confirms overwrite or
discards (
09 §3). - Batch chunking: flush ≤ 100 records per bulk call (server has no cap; sanity bound).
- Ordering: FIFO within a class/date; cross-class FIFO by createdAt.
5. HistoryCubit (S4/S7)
- Month view: for each day in month →
GET /attendance/class/:classId?date=(S4) or one range callGET /attendance/student/:studentId?startDate&endDate(S7 — prefer the range call: S7 = 1 request; S4 has no range endpoint, so fan-out 30 calls with a 5-min day-cacheatt.days:{classId}:{date}). - Day cache invalidated when a mark lands for that date (local write) or
attendance.changedWS event arrives. - Client-side paging for S7 long ranges: slice the returned array by month (OQ-4).
6. ReportCubit (S5/S6)
- Live tile:
GET /attendance/summary(SWR, 5 min). - Job:
POST /reports/generate→ pollGET /reports/:jobIdevery 2 s whilequeued|processing(cap 5 min), thencompleted|failedterminal states (report-job.schema.ts:13-18); background poll survives screen switch (same cubit in shell scope), snackbar "Report ready" on completion. - Never optimistic for job submission (side effects).
7. DeviceCubit (S8)
- Devices + last punch + ingest counts (sources:
biometric_logsbydeviceId+timestamp,biometric-log.schema.ts:26; device list endpoint(planned), OQ-3). Poll 60 s while screen visible (no WS topic today).
8. Cross-cutting
- Auth/session: all cubits react to
sessionExpired→ preserve unsaved queue, redirect login, restore on re-login (00-shared/06 §3.6). - Permissions:
attendance.markgates chip taps & FAB;attendance.editgates PATCH entry points;report.readgates S5/S6;biometric.*gates S8 — evaluated at route build + guard (00-shared/05 §9); server authoritative (OQ-5). - Testing hooks: pure-Dart cubits with mocked repositories; widget tests for the
three-state machine + optimistic rollback + queue flush (
00-shared/06 §6).