13 — State Management (Students Module)
- 1. Module data model (client)
- 2. Cubit map
- 3. Roster state machine (pagination + filters)
- 4. Detail tabs lazy-load
- 5. Create wizard state
- 6. Import wizard state (progress contract)
- 7. Documents upload state
- 8. Events & actions map (UI → Cubit → Repository)
- 9. Realtime & cross-screen
- 10. Error states per action
- 11. Testing hooks (
00-shared/06 §6)
Per-screen Cubits (Flutter/bloc; proposal — 00-shared/06) + repositories for the endpoints in 12_API_Mapping.md. Conventions: one Cubit per screen,
LoadStatemachine (00-shared/06 §3.1),PaginatedListMixinfor the roster (00-shared/06 §3.2).
1. Module data model (client)
class Student {
String id; String userId;
String admissionNumber; String? rollNumber;
String academicYearId; String? campusId;
String gradeId; String sectionId; String classId; String? houseId;
DateTime? admissionDate; String? admissionType; StudentStatus status;
bool transportRequired; bool hostelRequired; String? medicalNotes;
Map<String, dynamic>? metadata;
// hydrated (joined):
UserRef? user; // name/email/phone/avatarUrl from GET /users/:id
ClassRef? classRef; // label from academics refs (dropdown sources)
}
enum StudentStatus { active, inactive, graduated, transferred, archived }
class Enrollment {
String id; String studentId; String classId; String academicYearId;
String? rollNumber; DateTime joinedAt; DateTime? leftAt; EnrollmentStatus status;
}
class StudentDocument {
String id; String studentId; String fileName; String mimeType;
int size; String fileId; String? category; String? uploadedBy; DateTime createdAt;
}
class ParentLink {
String id; String studentId; String parentId; RelationshipType relationship;
bool isPrimaryGuardian; bool financialResponsibility; bool pickupAllowed;
int emergencyPriority;
}
class ImportReport {
String entity; int totalRows; int imported; int failed;
List<ImportRowError> errors; // {rowNumber, errors[]}
}
Enums map 1:1 to server (student.schema.ts:7-13, class-enrollment.schema.ts:7-11,
student-parent-link.schema.ts:7-14). user/classRef hydration is
client-side join (GET /users/:id + academics refs) — no server join exists.
2. Cubit map
| Screen | Cubit | Events → State |
|---|---|---|
| Roster | StudentsListCubit | Load(page1), LoadMore, Refresh, ChangeFilters(status/class/year), Search(q), Retry → {load: LoadState, items[], meta, filters, query, searchMode} |
| Detail | StudentDetailCubit | Load(id), Refresh, `RunAction(Action.graduate |
| Create wizard | CreateStudentCubit | `IdentityStep(createUser |
| Enroll sheet | EnrollCubit | Submit(classId, yearId, roll) → {idle, submitting, success, error} |
| Transfer | TransferCubit | Load(current), Submit(form) → {idle, loading, submitting, blocked(status), success, error} |
| Documents | DocumentsCubit | Load, Upload(file, category), RetryUpload → {load, docs[], uploading{tile}, error} |
| History | HistoryCubit | Load → {load, enrollments[]} (academic-history + active enrollments merged) |
| Parents tab | ParentLinksCubit | Load, CreateParent(form), Link(dto), Unlink(id) → {load, links[], sheetState} |
| Import wizard | ImportCubit | DownloadTemplate, PickFile(parsed), Upload, Retry → {step, file, report?, submitting} |
3. Roster state machine (pagination + filters)
stateDiagram-v2
[*] --> initial
initial --> loading : Load
loading --> success(data,meta) : 200
loading --> error(code) : 4xx/5xx
success --> loadingMore : LoadMore (hasNext)
loadingMore --> success (append) | error(tail banner)
success --> loading : Refresh / ChangeFilters / Search (reset page)
success --> empty : totalItems == 0 | filters no match
error --> loading : Retry
PaginatedListMixincontract:page,limit(20),hasNext,items,loadFirst/loadMore/pullToRefresh(00-shared/06 §3.2).- Server
q/sortignored (OQ-2) → filters/search run client-side over accumulated pages; when filter set,LoadMorekeeps paging server-side and applies filter locally.searchModeflag toggles helper text (honesty rule09 §1). - Cache: last-good list per key
sl:{tenant}:students:list:{page}(Hive/prefs, TTL 5 min(proposed)) — served instantly offline, refreshed in background.
4. Detail tabs lazy-load
flowchart TD
A[StudentDetailCubit Load] --> B[profile: GET /students/:id]
B --> C{tab activated}
C -->|Documents| D[GET /students/:id/documents]
C -->|History| E[GET /students/:id/academic-history<br/>+ GET /students/:id/enrollments]
C -->|Attendance/Fees/Results| F[other-module repos — forward-looking]
D & E --> G[kept alive; Refresh re-fetches]
- Each tab owns its LoadState; failures isolated (tab shows compact
AppErrorState, screen stays). - After
RunActionsuccess →Refresh()profile + badge; history refresh on transfer/enroll; documents refresh after upload.
5. Create wizard state
class CreateStudentState {
int step; // 0..3 (identity, academics, extras, review)
CreateUserDraft user; // firstName, middleName?, lastName, email, phone?
bool reuseExistingUser; String? userId;
ClassSelection selection; // academicYearId, gradeId, sectionId, classId
ExtrasDraft extras; // admissionDate?, admissionType?, campusId?, houseId?,
// transportRequired, hostelRequired, medicalNotes?, rollNumber?
bool submitting; String? createdId; WizardError? error; // field-targeted
}
Submitsequence (exact): if new user →usersRepo.create→ on 409 switch toreuseExistingUserprompt; thenstudentsRepo.create→createdId→ navigate/students/:id.- No draft persistence across app kills (YAGNI —
09 §2). - 400 field errors mapped via
details[].field; 409 admission → field error on admission number.
6. Import wizard state (progress contract)
class ImportState {
int step; // template | file | uploading | report
ParsedCsv? preview; // headers + first rows + column warnings
bool submitting;
ImportReport? report;
ImportPhase phase; // idle | uploading(simulated) | done
}
- Server import is synchronous (loop in request,
bulk-import.service.ts:45-63) — no job id, no progress API (OQ-12). UI: indeterminate progress + elapsed rows readout (client estimatetotalRows), disable back during upload. - Report render from
ImportReport;rowNumber-grouped errors; "Done" resets list cache (invalidate roster).
7. Documents upload state
UploadingDoc {fileName, size, progress(0..1), phase: queued|uploading|done|error}
in DocumentsCubit; multipart via dio FormData; retry re-posts buffered bytes;
no chunked resume (00-shared/12 B7).
8. Events & actions map (UI → Cubit → Repository)
| UI event | Cubit method | Repository call |
|---|---|---|
| Roster open / refresh | load() / refresh() | studentsRepo.list(page, limit) |
| Infinite scroll | loadMore() | studentsRepo.list(page+1, limit) |
| Search / filters | search(q) / setFilters(f) | client-side (plus server page fetch) |
| Row → View | select(id) | detail load(id) |
| Create submit | submit() | usersRepo.create → studentsRepo.create |
| Enroll | submit(form) | studentsRepo.enroll(id, dto) |
| Transfer | submit(form) | studentsRepo.transfer(id, dto) |
| Graduate/Archive/Restore | runAction(a) | `studentsRepo.graduate |
| Delete | runAction(delete) | studentsRepo.delete(id) |
| Documents load/upload | load() / upload(file, cat) | documentsRepo.list(id) / documentsRepo.upload(id, file, cat) |
| History load | load() | studentsRepo.academicHistory(id) + enrollments(id) |
| Parents load | load() | parentsRepo.linksByStudent(id) |
| Create parent | createParent(draft) | usersRepo.create → parentsRepo.create |
| Link | link(dto) | parentsRepo.link(studentId, dto) |
| Unlink | unlink(linkId) | parentsRepo.unlink(linkId) |
| Import template | downloadTemplate() | bulkRepo.exportCsv('students') → file save/share |
| Import upload | upload(file) | bulkRepo.importCsv('students', file) |
9. Realtime & cross-screen
- No student WS topics (
00-shared/07 §8); roster freshness via refresh + focus re-fetch;roster.changed(forward-looking). StudentCreatedin-app notification tap → deep link/students/:id(proposed).- Academic-year switcher (
00-shared/06 §4selectors) affects class cascade defaults in create/enroll/transfer forms.
10. Error states per action
| Action | Error | State → |
|---|---|---|
| create | 409 admission / email | field error; reuse-user prompt |
| create | 400 | field map |
| transfer | 409 status | blocked(status) → read-only banner |
| lifecycle | 409 same-state | snackbar + refresh |
| unlink/delete | 404 | treat as removed |
| upload | network loss | tile error + retry |
| import | 400 parse | wizard banner |
| any | 429 | countdown |
| any | 5xx | generic + requestId |
11. Testing hooks (00-shared/06 §6)
- Pure-Dart cubits with mocked repositories; roster state-machine matrix (initial→loading→success→empty→error→loadingMore); wizard sequence test (create user 409 → reuse path); import report rendering (mixed success).
- Widget tests per screen: 3 states (loading/error/success+empty) per
00-shared/10 §9.