Extends 00-shared/11 . Forward-looking: no client repo exists (shared ledger A1).
Everything below derives from src/modules/timetable/**,
src/modules/academics/**, src/modules/teachers/**, src/modules/rooms/**,
src/modules/rbac/** (perms at permissions.constants.ts:44-45).
lib/features/timetable/
├── data/
│ ├── dto/timetable_entry_dto.dart # envelope payload mapper
│ ├── dto/entry_draft_dto.dart # create payload (F1)
│ ├── models/timetable_entry.dart # + DayOfWeek enum (6 values)
│ └── repositories/timetable_repository.dart
├── domain/
│ └── timetable_service.dart # grid assembly + conflict derivation
└── presentation/
├── cubit/timetable_grid_cubit.dart
├── cubit/entry_form_cubit.dart
├── pages/timetable_grid_page.dart
├── widgets/timetable_grid.dart, timetable_slot_card.dart,
│ day_header.dart, time_gutter.dart, grid_cell.dart,
│ conflict_banner.dart, week_navigator.dart, entry_editor_sheet.dart
enum DayOfWeek { monday, tuesday, wednesday, thursday, friday, saturday }
// parse via EnumByName; sunday → parse error → model invalid (server enum, timetable.schema.ts:7-14)
class TimetableEntry {
final String id, classId, subjectId, teacherId;
final String? roomId;
final DayOfWeek dayOfWeek;
final String startTime, endTime; // zero-padded "HH:MM"
final String academicYearId;
}
DTO→model: strict fromJson; times kept as strings (server contract —
DateTime parse is display-only, never re-serialized; 08 F1).
Ref-name resolution: TimetableService.joinNames(entry, catalogs) — catalogs
(classes/subjects/teachers/rooms/years) loaded once, cached 24 h (13 §3).
class TimetableRepository {
TimetableRepository(this._dio); // AppDio (00-shared/11 §5)
Future<List<TimetableEntry>> byClass(String classId); // GET /timetable?classId=
Future<List<TimetableEntry>> byTeacher(String teacherId); // GET /timetable?teacherId=
Future<TimetableEntry> create(EntryDraftDto dto); // POST /timetable
}
Errors: interceptor maps envelope → ApiException(code, status, fieldDetails)
(00-shared/11 §5); 409 exposes a conflict flavor (server message is generic —
map via pre-flight data, 06 §S4).
Always send exactly one query param (both → classId wins; none → [];
timetable.controller.ts:26-28).
TimetableGridCubit — scope + weekOffset + entries; never re-sorts (server
contract dayOfWeek, startTime, timetable.service.ts:48-60); derives
conflicts list (same-grid teacher/room overlaps) for slot badges (OQ-1).
EntryFormCubit — mirrors EntryDraftDto; time-format guard (24 h HH:MM,
end > start); pre-flight teacher/room clash check against cached grids; on 409
keeps form + sets clash.
RoomGridCubit (planned) — merges byClass responses per room usage.
WeekNavCubit — offset only (client concept; no server date dimension).
// go_router additions (00-shared/05 §4 + 00-shared/11 §6)
GoRoute(path: '/academics/timetable', builder: TimetableGridPage.new), // ?classId=
GoRoute(path: '/academics/timetable/teacher/:teacherId', builder: TimetableGridPage.new),
// (planned) room view + edit/delete surfaces
GoRoute(path: '/academics/timetable/room/:roomId', builder: RoomGridPage.new),
GoRoute(path: '/my/schedule', builder: MySchedulePage.new), // teacher self
Guards: permissionGuard('timetable.read') / ('timetable.create') client-side
mirror of permissions.constants.ts:44-45 (server is JWT-only today — 01 §5).
Deep link: studylyon://timetable?classId=:id.
All tokens via AppTheme (00-shared/04); module components in 07; conflict
colors only inside TimetableSlotCard conflict state + ConflictBanner (11 §7).
Times rendered mono + tabularFigures (02 §2) in gutter, cards, editor.
timetable.title, timetable.class, timetable.teacher, timetable.room,
timetable.scope.{class,teacher,room}, timetable.addSlot, timetable.slot.detail,
timetable.slot.duplicate, timetable.slot.edit, timetable.slot.delete, // edit/delete (planned)
timetable.empty.{class,teacher,room}, timetable.conflict.teacher,
timetable.conflict.room, timetable.conflict.preflight, timetable.slot.doubleBooked,
timetable.saved, timetable.week.{prev,next,today}, timetable.drag.copy,
timetable.day.{monday,tuesday,wednesday,thursday,friday,saturday},
timetable.field.{class,subject,teacher,room,day,startTime,endTime,year},
errors.server, errors.rateLimited
Desktop: TimetableSlotCard wrapped in Draggable<TimetableEntry> (feedback =
card at e-4 + 0.95 scale); GridCell wraps DragTarget<TimetableEntry>:
onAcceptWithDetails → open EntryEditorSheet prefilled (day/time from target
cell, subject/teacher/room from dragged entry) — create semantics, no PATCH
(10 §2, 09 §5; copy banner "This creates a new slot; original stays until
delete (planned)").
Mobile: no drag — long-press opens the slot popover (10 §1); LongPressDraggable
is deliberately unused on touch platforms.
Invalid target (occupied cell): reject + errorContainer outline; return animation
m-base spring.
Technique Why
Lazy rows: ListView.builder (or CustomScrollView) over day columns; cells built on demand 6×N grid with ≥ 40 slots — no eager 60-widget build
Slot cards wrapped in RepaintBoundary drag/hover repaints don't relayout the whole grid
TimeGutter + DayHeader pinned via sticky headers, single shared ScrollControllerconstant header during horizontal/vertical scroll
Cache-extent tuning + const where possible jank-free 60 fps on low-end (14 QA-32)
Semantics merged per slot (one node)a11y tree stays small
Fonts: mono + tabularFigures times no width jitter while scrolling
Grid assembly: Map<DayOfWeek, List<TimetableEntry>> from the server-sorted list
(O(n) pass); row keys = ascending union of startTime; slot height =
(end−start) / periodMinutes × rowHeight, min 1 row.
Layer Cases
Unit TimetableGridCubit scope/week/conflict-derivation; EntryFormCubit 409 mapping, time-format guard (08 F1), pre-flight clash; DTO↔model mappers (sunday → invalid)
Widget grid 3-state (skeleton/error/empty), S4 409-banner, TimetableSlotCard golden ×2×2 (normal/conflict × light/dark), drop-target accept/reject
Integration create → 409 → fix → grid shows slot; scope switch refetch; week nav no-refetch
E2E P0: full create/conflict journey + cross-tenant 404 + bare GET /timetable never sent (per 00-shared/10 §9)