13 — State Management (Academics Module)
- 1. Module state model
- 2. Cascading pickers (Cubit)
- 3. Reference-data caching (24 h)
- 4. ReferenceScope (year switcher)
- 5. Refresh propagation to timetable / attendance
- 6. Per-screen Cubits (events → state)
- 7. Error states per action
- 8. Realtime
- 9. Testing hooks (
00-shared/06 §6) - 10. Cross-cutting interplay
Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) + the module-wide reference cache and year scope that every Academics screen and downstream consumer (timetable/attendance) depends on. Backed by repositories calling the endpoints in 12_API_Mapping.md.
1. Module state model
ReferenceCubit (single instance per tenant scope)
├─ years: [AcademicYear] (list cache)
├─ grades: [Grade] (list cache)
├─ sections: [Section] (list cache)
├─ classes: [Class] (list cache)
├─ subjects: [Subject] (list cache)
├─ rosters: {classId: [SubjectAssignment]} (per-class cache, LRU 20)
├─ selectedYearId: String? (ReferenceScope)
└─ ttl: 24 h per collection, refreshed by pull-to-refresh cascade
Screen cubits (scoped, ephemeral)
├─ YearListCubit / GradeListCubit / ClassListCubit / SectionListCubit / SubjectListCubit
├─ YearFormCubit / GradeFormCubit / ClassFormCubit / SectionFormCubit / SubjectFormCubit
├─ AssignmentMatrixCubit
├─ ExplorerCubit
└─ RosterCubit (by-teacher)
2. Cascading pickers (Cubit)
Class create / section create drive the cascades (07 §6). Backed by
CascadingPickerCubit:
state: { yearId?, gradeId?, sectionId?, grades[], sections[], loadingLevel, error }
| Event | Transition |
|---|---|
YearSelected(id) | set yearId; load grades (GET /grades, cached); clear gradeId,sectionId; enable grade dropdown |
GradeSelected(id) | set gradeId; load sections — GET /sections/by-grade/:id if not in cache, else filter cache by gradeId; clear sectionId; enable section dropdown |
SectionSelected(id) | set sectionId; emit complete {yearId, gradeId, sectionId} |
Clear (parent reset) | cascade children disabled + reset |
- Sources per
12 §2-§4: year list from cache; grade list from cache (filtered byacademicYearIdif the grade carries one — grades may float,grade.schema.ts:9-10— otherwise shown in all years); sections from cache/by-grade. - Auto-suggested class name:
"Grade {grade.name} - {section.name}"(example "Grade 10 - A",create-class.dto.ts:22) — editable, not forced. - Errors: fetch failure at any level → retry affordance on that level only; the rest of the form stays intact.
3. Reference-data caching (24 h)
ReferenceCubitfetches each collection on first touch withttl 24 h(00-shared/06 §3.3):GET /academic-years,/grades,/sections,/classes,/subjects(page 1, limit 100 — fetch all pages whenhasNext, since the cache serves joins everywhere).- Invalidation:
refreshAll()(pull-to-refresh cascade) re-fetches all five; writes (create/update/delete/set-current) invalidate the single affected collection (not all) and merge the server response doc. - Persistence: collections cached in
SharedPreferences/Hive(non-sensitive reference data;00-shared/11 §11); loaded synchronously on boot → screens render instantly, revalidate in background. Seed on first boot. - Budget: 12 grades × 4 sections × 40 classes × 30 subjects ≈ < 4 MB raw JSON;
acceptable for
local_cachetier (00-shared/06 §3.3). - Offline: cache serves reads; writes blocked with
AppOfflineBanner(00-shared/07 §10).
4. ReferenceScope (year switcher)
selectedYearIdlives inReferenceCubit; default =isCurrent:trueyear from cache, else first year, else null (academic-year.schema.ts:31-32).- Every list screen that supports a year param subscribes:
ClassListCubitswitchesGET /classes/by-year/:idvsGET /classeswhen "All years" selected; assignment rosters passacademicYearId(subject-assignment.controller.ts:24-35). - Switching year re-scopes in-flight queries, cancels stale ones (bloc
EmitafterisClosedguard), keeps pagination state per year in aMap<yearId, PageState>.
5. Refresh propagation to timetable / attendance
Server has no publish/notify on structure change; the client propagates via the
ReferenceChanged domain event (00-shared/06 §3.4 — local event bus):
ReferenceCubit ── ReferenceChanged (after any write / refreshAll) ──▶
├─ TimetableCubit → re-fetch scoped entries (classId/teacherId filter,
│ timetable.controller.ts:20-29) — drop cached entries whose
│ classId/subjectId/teacherId no longer resolve
├─ AttendanceCubit → re-fetch day lists (classId filter, attendance.schema.ts:28-29)
└─ RosterCubit → re-fetch by-teacher rows
- Consumers keep their own page state; they only reload when the event fires while their screen is mounted, or on focus (app-resume/focus handler).
- Class deletion emits
ReferenceChanged→ timetable/attendance screens show a "class no longer exists" notice instead of a broken roster (they keep stale rows server-side — OQ-3; client hides by re-resolving).
6. Per-screen Cubits (events → state)
| Screen | Cubit | Events → State |
|---|---|---|
| Year list | YearListCubit | Load, LoadMore, Refresh, SetCurrent(id), Delete(id) → {initial, loading, loaded(paged, isCurrentId), empty, error, busyId} |
| Grade list | GradeListCubit | Load, LoadMore, Refresh, Delete(id) → same shape |
| Class list | ClassListCubit | Load(scope: all|yearId), LoadMore, Refresh, ScopeChanged, Delete(id) → same + per-year page map |
| Section list | SectionListCubit | Load(scope: all|gradeId), LoadMore, Refresh |
| Subject list | SubjectListCubit | Load, LoadMore, Refresh, Delete(id) + local filter(text) (server q unused — OQ-7) |
| Class detail | ClassDetailCubit | Load(id) → {loading, loaded(class, roster, joined), error} |
| Assignment matrix | AssignmentMatrixCubit | Load, Add(subjectId,teacherId), Replace(oldId, subjectId,teacherId), Remove(id) |
| Roster (teacher) | RosterCubit | Load(teacherId, yearId), Refresh |
| Explorer | ExplorerCubit | Load, Expand(nodeId), Collapse(nodeId), ScopeChanged — tree built from cache (15 §4) |
| All forms | *FormCubit | Submit(dto) → {idle, submitting, success(doc), duplicate(serverMsg), error(code)} |
All list cubits mix in the shared PaginationCubit (00-shared/06 §3.2).
7. Error states per action
| Action | Error | State → |
|---|---|---|
| create (any) | 409 | duplicate(serverMsg) → inline banner + autofocus first field |
| create | 400 | error(details[]) → per-field |
| set-current | 404 | refresh list; snackbar "Already current / removed" |
| assignment add | 400 | sheet stays, field error |
| assignment remove | 404 | treat-as-removed → refresh roster |
| delete | 404 | treat-as-deleted → refresh |
| any | 5xx | generic + requestId |
| any (offline) | connectivity | blocked + AppOfflineBanner |
8. Realtime
- No WS endpoint for academics today;
(planned)pushes:AcademicYearChanged,StructureChangedevents to tenant room (00-shared/07 §8). Until then: refresh-on-focus +ReferenceChangedlocal bus only. set-currentperformed elsewhere (another device) is caught on next focus: years list re-read → current badge corrects itself.
9. Testing hooks (00-shared/06 §6)
- Pure-Dart cubits; unit-test: cascade transitions (parent clear → children reset), per-year pagination maps, TTL expiry + revalidate, duplicate-merge on 409.
- Widget tests: class form cascade (year → grade → section disable logic), roster conflict rendering, explorer expand/collapse with ≤ 50 open nodes.
- Golden tests:
HierarchyTree,SectionChips,SubjectAssignmentRow(00-shared/10 §9).
10. Cross-cutting interplay
AuthCubit: academics screens requireauthenticated; tenant change (multi-tenant user) wipesReferenceCubitand reseeds for the new tenantId.ConnectivityCubit: gates writes; cache stays readable.FeatureFlagsCubit:(planned)gates — coaching extension (docs/IMPLEMENTATION_PLAN.md:314-317) flips section→batch semantics when enabled; no code today.AnalyticsService(proposed): eventsacademics.*— year_created, set_current, structure_created(type), assignment_added/removed, conflict_seen (00-shared/10 §8).