Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

13 — State Management (Academics Module)

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 }
EventTransition
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 by academicYearId if 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)

  • ReferenceCubit fetches each collection on first touch with ttl 24 h (00-shared/06 §3.3): GET /academic-years, /grades, /sections, /classes, /subjects (page 1, limit 100 — fetch all pages when hasNext, 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_cache tier (00-shared/06 §3.3).
  • Offline: cache serves reads; writes blocked with AppOfflineBanner (00-shared/07 §10).

4. ReferenceScope (year switcher)

  • selectedYearId lives in ReferenceCubit; default = isCurrent:true year from cache, else first year, else null (academic-year.schema.ts:31-32).
  • Every list screen that supports a year param subscribes: ClassListCubit switches GET /classes/by-year/:id vs GET /classes when "All years" selected; assignment rosters pass academicYearId (subject-assignment.controller.ts:24-35).
  • Switching year re-scopes in-flight queries, cancels stale ones (bloc Emit after isClosed guard), keeps pagination state per year in a Map<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)

ScreenCubitEvents → State
Year listYearListCubitLoad, LoadMore, Refresh, SetCurrent(id), Delete(id){initial, loading, loaded(paged, isCurrentId), empty, error, busyId}
Grade listGradeListCubitLoad, LoadMore, Refresh, Delete(id) → same shape
Class listClassListCubitLoad(scope: all|yearId), LoadMore, Refresh, ScopeChanged, Delete(id) → same + per-year page map
Section listSectionListCubitLoad(scope: all|gradeId), LoadMore, Refresh
Subject listSubjectListCubitLoad, LoadMore, Refresh, Delete(id) + local filter(text) (server q unused — OQ-7)
Class detailClassDetailCubitLoad(id){loading, loaded(class, roster, joined), error}
Assignment matrixAssignmentMatrixCubitLoad, Add(subjectId,teacherId), Replace(oldId, subjectId,teacherId), Remove(id)
Roster (teacher)RosterCubitLoad(teacherId, yearId), Refresh
ExplorerExplorerCubitLoad, Expand(nodeId), Collapse(nodeId), ScopeChanged — tree built from cache (15 §4)
All forms*FormCubitSubmit(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

ActionErrorState →
create (any)409duplicate(serverMsg) → inline banner + autofocus first field
create400error(details[]) → per-field
set-current404refresh list; snackbar "Already current / removed"
assignment add400sheet stays, field error
assignment remove404treat-as-removed → refresh roster
delete404treat-as-deleted → refresh
any5xxgeneric + requestId
any (offline)connectivityblocked + AppOfflineBanner

8. Realtime

  • No WS endpoint for academics today; (planned) pushes: AcademicYearChanged, StructureChanged events to tenant room (00-shared/07 §8). Until then: refresh-on-focus + ReferenceChanged local bus only.
  • set-current performed 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 require authenticated; tenant change (multi-tenant user) wipes ReferenceCubit and 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): events academics.* — year_created, set_current, structure_created(type), assignment_added/removed, conflict_seen (00-shared/10 §8).