13 — State Management (Exams Module)
- 1. Cubits & responsibilities
- 2. ExamDetailCubit state machine
- 3. MarksEntryCubit — the heart
- 4. Offline marks queue (module-defined;
00-shared/06 §3.7) - 5. ExamsListCubit
- 6. StudentResultsCubit / ReportCardCubit
- 7. 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 problems: idempotent marks-entry with an offline queue, publish as a never-optimistic side-effect, and server truth for the published state.
1. Cubits & responsibilities
| Cubit | Screen(s) | State |
|---|---|---|
ExamsListCubit | S1 | paginated exam list, status/type filters (client-side — server ignores q/sort, OQ-7), cache |
ExamDetailCubit | S2 | header + slots + coverage per slot; publish lifecycle |
ExamFormCubit | S4, S8, S8a | create exam / edit exam / add slot; client-only validations (OQ-2/3/6) |
MarksEntryCubit | S3 | roster rows + marks, per-row save state machine, offline queue |
StudentResultsCubit | S6 | marks list for a student, grouped by exam |
ReportCardCubit | S7 | report card aggregate |
All implement LoadState {Initial, Loading, Success, Error} (00-shared/06 §3.1);
pagination only where the API paginates (exams list; S3/S6/S7 reads are
unpaginated arrays — client-side handling).
2. ExamDetailCubit state machine
flowchart TD
A[Initial] -->|load exam+slots| B[Loading]
B -->|200| C[Success<br/>header + slots]
B -->|404| E[ErrorNotFound<br/>'Examination not found.']
B -->|5xx/offline| F[Error<br/>+ last-good cache]
C -->|slot tapped| G[Open marks screen]
C -->|publish confirmed| H[Publishing<br/>spinner, dialog locked]
H -->|204/200| I[Published<br/>status='published' badge]
H -->|error| F
E -->|back| A
- Publish is never optimistic: badge flips only from the server response
(
examination.service.ts:209-211);POST /examinations/:id/publishis online-only, no offline queue. - After
published, the cubit exposeslocked = true→ UI hides Publish/Edit/Delete/Add-subject (client immutability contract, OQ-5).
3. MarksEntryCubit — the heart
flowchart TD
A[Initial] -->|load roster + marks| B[Loading]
B -->|200| C[Ready<br/>rows: student + marks? + saveState]
B -->|404 slot| D[SlotGone<br/>pop to detail]
C -->|type/edit value| E[Editing<br/>row.saveState=unsaved]
E -->|save (Enter/Tab)| F[Saving<br/>optimistic value shown]
F -->|201/200 upsert| G[Saved<br/>check, version from server]
F -->|404 max exceeded| H[MaxViolation<br/>inline error, refresh max]
F -->|network fail| I[Failed<br/>shake + Retry]
C -->|offline detected| J[Queued<br/>dashed row + cloud icon]
J -->|reconnect flush| F
I -->|Retry| F
G -->|next row| E
- Row state machine:
unsaved → saving → saved | failed | queued; rollback on failure (restore previous server value). - Optimistic policy (
00-shared/06 §3.5): single mark save is optimistic with server-confirm (upsert is idempotent —examination.service.ts:147-175, unique indexexamination-result.schema.ts:31-33); row reverts on 4xx/5xx. Publish is pessimistic (side effects). - Save batching: saves are serialized per row (one in-flight POST per row);
no bulk endpoint today (
/marks-importis(planned),IMPLEMENTATION_PLAN.md:219).
4. Offline marks queue (module-defined; 00-shared/06 §3.7)
MarkOp = {opId: uuid, kind: 'marks', payload: EnterMarksDto + examSubjectId, createdAt, tenantId}
- Capture: offline or write-error → op persisted (Hive box
exm.queue, key{tenant}:{examSubjectId}), rows flaggedqueued. - Dedupe: before enqueue, replace queued ops for the same
studentIdin the sameexamSubjectId(last-write-wins mirrors the server upsert). - Flush: on reconnect (
ConnectivityCubit) →POST /results/exam-subject/:id/marksper op withIdempotency-Key= opId; success → remove op + mark saved; failure → keep, backoff retry, cap 5 attempts, then "N marks waiting — review". - Conflict on flush (409): fetch server doc for
studentId+examSubjectId; if it differs from the queued payload → row flaggedconflicted, preview sheet (server vs local), user confirms overwrite or discards. - Ordering: FIFO within a slot; cross-slot FIFO by
createdAt. - Rows written while the exam flips to
published→ confirm-before-send sheet (publish and offline queue interaction, OQ-5).
5. ExamsListCubit
- Paginated fetch
GET /examinations?page&limit(examination.service.ts:67-79); append pages whilehasNext(pagination-query.dto.ts:41-55); pull-to-refresh resets to page 1. - Client-side filter/sort (server ignores
sort/q— OQ-7): chips filter bystatus/typeover the loaded window; sort bystartDateasc locally; a "fetched all pages" footer whenhasNext: false. - Cache: last-good list TTL 24 h; invalidated on create/delete from
ExamFormCubit/ExamDetailCubit(module-internal event). - Realtime: subscribe to
results.publishedWS topic (00-shared/07 §8)(planned)→ re-fetch affected exam row (badgepublished).
6. StudentResultsCubit / ReportCardCubit
- S6:
GET /results/student/:studentId(result.service.ts:36-38) — unpaginated; client groups by exam client-side (names resolved from slots, OQ-9). Cache TTL 5 min; pull-to-refresh bypasses cache. - S7:
GET /results/report-card/:studentId/:examId(result.service.ts:46-98); cacheable 24 h when the exam is published (server stampspublishedAt,examination-result.repository.ts:42-51); otherwise 5 min. 404 ("No subjects found…",result.service.ts:51-53) →NotFoundstate, not retried.
7. Cross-cutting
- Auth/session: all cubits react to
sessionExpired→ preserve unsaved/queued marks, redirect login, restore on re-login (00-shared/06 §3.6). - Permissions:
exam.*/result.*(planned)(OQ-4); until seeded, client gates by role claim defensively — server authoritative (JwtAuthGuardonly today,examination.controller.ts:24). - Testing hooks: pure-Dart cubits with mocked repositories; widget tests for
the row save state machine, max-violation path, publish non-optimistic flip, and
offline queue flush (
00-shared/06 §6).