Extends 00-shared/06 . Bloc/Cubit, one cubit per screen; repository layer only
place touching HTTP; typed ApiException(code, status, fieldDetails).
Cubit Screen State Notes
TimetableGridCubitS1/S2 TimetableGridStatescope (class/teacher), week offset, entries
EntryFormCubitS4 EntryFormStateF1 fields + conflict handling
RoomGridCubitS3 (planned) RoomGridStatemerge of class responses
WeekNavCubitS6 WeekNavStateoffset only, shell-level (forward-looking)
// S1/S2
class TimetableGridState {
LoadState load; // Initial | Loading | Success | Error
GridScope scope; // class(scopeId) | teacher(scopeId)
int weekOffset; // 0 = current week (client-only concept)
List<TimetableEntry> entries; // server-sorted dayOfWeek, startTime
List<TimetableEntry> conflicts; // client-derived same-grid overlaps (OQ-1)
int requestToken; // drop stale scope-switch responses
}
// S4
class EntryFormState {
LoadState submit; // idle | submitting | success | error
EntryFormData data; // field DTO mirror (F1)
Map<String, String> fieldErrors; // from 400 details
ConflictClash? clash; // teacher | room, from 409 + pre-flight
bool dirty;
}
// S3 (planned)
class RoomGridState {
LoadState load;
String roomId;
List<TimetableEntry> merged; // composed from per-class fetches
Map<String, LoadState> perClass; // per-source load status
}
class TimetableRepository {
Future<List<TimetableEntry>> byClass(String classId); // GET /timetable?classId=
Future<List<TimetableEntry>> byTeacher(String teacherId); // GET /timetable?teacherId=
Future<TimetableEntry> create(EntryDraft dto); // POST /timetable
}
Cache key TTL Policy
sl:{tenant}:timetable:class:{classId}5 min stale-while-revalidate (06 §3.3)
sl:{tenant}:timetable:teacher:{teacherId}5 min stale-while-revalidate
sl:{tenant}:timetable:refs:{classes|subjects|teachers|rooms|years}24 h reference data
detail / editor none server-fresh on every submit
RefreshIndicator always bypasses cache (06 §3.3).
LoadGrid(scope), ChangeScope(scope), ChangeWeek(offset), RefreshGrid,
GridCellTap(slot), SlotTap(entry)
SubmitEntry(draft), RetrySubmit, DismissConflict, Discard
PreflightChange(field, value) — runs local teacher/room clash check
Naming per 00-shared/06 §4.
flowchart TD
A[TimetableGridCubit] -->|LoadGrid| B[Repository.byClass/byTeacher]
B --> C[Success: entries → cells\n server-sorted, no re-sort]
C --> D[EntryFormCubit.SubmitEntry]
D --> E{Pre-flight clash?}
E -->|warn| F[ConflictBanner advisory]
E -->|none| G[POST /timetable]
G -->|409| H[clash set → ConflictBanner]
G -->|200| I[emit GridSlotAdded]
I --> J[TimetableGridCubit inserts slot\n AnimatedList m-base]
H --> K[keep form values]
notification.new (WS, 00-shared/07 §8) for own tenant → grid marks
refresh-needed badge (planned); no live mutation (TimetableEntryCreated is
emitted but unrouted — 01 §6).
Teachers module ScheduleTabCubit shares byTeacher cache — refetch on grid
mutation while tab visible.
Dashboard KPIs (proposed): refresh on TimetableEntryCreated when dashboard
visible.
Action Optimistic?
Scope switch, week offset yes (local state)
Grid cell/slot taps yes (local)
Entry create no — server-confirmed; insert after 200 only
Conflict banner yes (local pre-flight) but always server-verifiable
Delete (planned) no (soft-delete confirm, 00-shared/06 §3.5)
409 → ConflictClash from pre-flight data + server status (server sends only
"Schedule conflict detected", timetable.service.ts:28); form values kept.
400 → fieldErrors from error.details (00-shared/07 §3).
404 → grid treats ref as "—" (catalog gone); entries themselves are only deleted
by planned routes.
401 → AuthCubit refresh; fail → session expiry.
5xx → AppErrorState with requestId; retry re-emits event.
TimetableGridCubit pure-Dart unit tests (mock TimetableRepository): scope
switch, week offset, server-sort preservation, conflict derivation.
EntryFormCubit: 409 mapping, fieldErrors, time-format guard (08 F1).
Widget tests: grid 3-state (loading/error/empty), S4 409-banner, S5 banner
announce, TimetableSlotCard golden ×2×2.
AppStateObserver transition logs dev-only (06 §6).