06 — Screen Specifications (Exams Module)
- 1. Exams List —
/exams(S1) - 2. Exam Detail —
/exams/:examId(S2) - 3. Marks Entry —
/exams/:examId/slots/:slotId/marks(S3) - 4. Add Subject Slot —
/exams/:examId/slots/new(S4) - 5. Publish Confirm — dialog on S2 (S5)
- 6. Student Results —
/results(S6) - 7. Report Card —
/results/:examId/report-card(S7) - 8. Create / Edit Exam —
/exams/new·/exams/:examId/edit(S8 / S8a)
Production-grade specs for each Exams screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. All components reference 00-shared/03 and 07 (module). Every data binding cites the exact schema/service field.
1. Exams List — /exams (S1)
Chrome: AppBar "Exams" (titleLarge, El-e1); AppFilterChip row below
(status: draft|active|completed|published + type: midterm|final|unit_test|quarterly| other — enums examination.schema.ts:15-20, 28-33); body RefreshIndicator +
ListView.builder; AppFAB.extended "New exam" (Icons.add, unique heroTag).
Bottom: AppPaginationBar (page/limit, 00-shared/07 §5).
Row — ExamCard:
[type chip] [status badge: draft/active/completed/published]
Name titleMedium, 2 lines max
Term 2026-27 · Midterm bodySmall onSurfaceVariant (academicYearId resolved)
15 Aug – 20 Aug · in 5d bodyMedium; startDate/endDate (ISO) → local dates
Slots 5 · Marks 120/240 bodySmall (derived: slots + marked/total)
Data: GET /examinations?page&limit (examination.controller.ts:30-32);
fields name, type, startDate, endDate, status, academicYearId
(examination.schema.ts:9-36). Sorting: server ignores sort/q (OQ-7) —
client sorts by startDate asc locally, filters chips client-side.
States:
- Loading:
AppSkeleton(list)6 rows. - Success: cards; pagination meta from
buildPaginationMeta(meta.page/limit/ totalItems/totalPages/hasNext/hasPrevious,pagination-query.dto.ts:41-55). - Empty:
AppEmptyStateiconassignment, "No exams yet" + subtitle "Plan your first exam for this academic year" + FAB remains; if empty and past academic year → "No exams in this term". - Error:
AppErrorState; 5xx showsrequestId; retry re-fetches. - Offline:
AppOfflineBanner+ cached list (TTL 24 h reference data,00-shared/06 §3.3). - Pagination: infinite-scroll on scroll end (guard
hasNext); "Loading more" footer row; end-of-list footer whenhasNext: false.
A11y: card = Semantics(button) "{name}, {type}, status {status}, starts {date}";
badge read appended; focus order: filter chips → list → FAB; SwitchAccessible.
Dark/light: token-only (00-shared/02); status badge published =
tertiaryContainer/tertiary-tinted container with icon verified + label (never
color-only, 00-shared/09). Tablet/desktop (≥840 dp): two-pane master–detail
(list 360–400 dp + S2); N shortcut = new exam; hover tint on cards.
Motion: rows fade-in Mo.fast (stagger 30 ms); FAB hero to S8.
2. Exam Detail — /exams/:examId (S2)
Chrome: AppBar title = exam name, trailing AppMenu (Edit / Delete — hidden
when status: 'published', OQ-5). Body RefreshIndicator + CustomScrollView.
Header card (surfaceContainerLow, r-lg, e-1)
name headlineMedium · type chip · status badge
AppInfoRow: Academic year (resolved) · Term dates (startDate – endDate)
AppInfoRow: Grading scheme (gradingSchemeId? — hidden when absent,
examination.schema.ts:35-36)
Segmented control: [Schedule] [Coverage]
Schedule tab: ExamSubjectCard list (per slot, see 07)
Coverage tab: per-slot CoverageTile: subject, class, marked/total, progress bar,
max/pass marks
Action bar (bottom, pinned):
[OutlinedButton "Add subject"] (exam.update)
[FilledButton "Publish"] (exam.publish; hidden when published)
Data: GET /examinations/:id (examination.controller.ts:33-35 →
examination.service.ts:61-65); slots GET /examinations/:id/subjects
(examination.controller.ts:51-53 → examination.service.ts:133-137); coverage
per slot GET /results/exam-subject/:examSubjectId (result.controller.ts:21-25).
States:
- Loading: header skeleton + 3 slot skeletons.
- Empty (no slots):
AppEmptyState"No subjects scheduled" + "Add subject" CTA; report-card entry on such exam → server 404 (result.service.ts:51-53). - Error 404 ("Examination not found.",
examination.service.ts:63): full-screenAppErrorState"This exam was removed" + back; auto-pop if opened via stale list. - Publish in progress: action bar button → spinner, dialog (S5) shows progress;
on success badge flips to
published(server response-driven, never optimistic). - Offline: cached header (last-good) + "stale" chip on slots (TTL 5 min); actions disabled (online-only).
- Disabled: when
published→ Publish hidden, menu Edit/Delete hidden, Add subject hidden (immutability contract, OQ-5); whencompleted→ Publish still allowed (server allows; no guard, OQ-1).
A11y: status badge changes announced via Semantics(liveRegion: true);
segmented control = radio semantics; publish button label includes exam name.
Responsive: tablet adds a right rail: selected slot's coverage panel.
Motion: header fade/slide Mo.base (200 ms); segmented switch Mo.fast;
slot list stagger Mo.fast.
3. Marks Entry — /exams/:examId/slots/:slotId/marks (S3)
Chrome: AppBar "Marks" + slot context in titleSmall; body
SlotHeaderCard:
Subject name · Class name titleMedium
Date 15 Aug · 09:00–12:00 bodyMedium (date + startTime/endTime strings,
examination-subject.schema.ts:18-25)
Maximum 100 · Pass 33 bodySmall; "Passing" styled tertiary when
passingMarks > maximumMarks (invalid, OQ-6)
Then MarksSummaryBar: marked/total · save-state (saved|queued|failed) with
animated progress width. Roster ListView.builder of MarksRow
(from Students module by slot classId; marks from
GET /results/exam-subject/:examSubjectId).
Row:
[avatar initials] Student name bodyMedium
roll/admission no bodySmall onSurfaceVariant
[ MarksTextField ] [GradeChip optional] [save status icon]
MarksTextField: numeric (digits only,FormattedNumberInput), max length = digits ofmaximumMarks;@Min(0)mirrors DTO (examination-subject.dto.ts:51-55).- Row-level save: submit on focus-loss or explicit save tap (see
10_Interaction_Specification.md §3for cadence).
Save flow: POST /results/exam-subject/:examSubjectId/marks
(result.controller.ts:26-31 → examination.service.ts:139-195). Upsert: existing
(studentId, examinationSubjectId) doc updated in place (examination.service.ts:147-175);
unique index {tenantId, studentId, examinationSubjectId} (examination-result.schema.ts:31-33).
Payload {studentId, marksObtained, grade?, remarks?} (EnterMarksDto,
examination-subject.dto.ts:47-66).
States:
- Loading: roster skeleton rows.
- Roster empty:
AppEmptyState"No students in this class" (slotclassIdhas no students). - Slot gone (404 "Exam subject not found.",
examination.service.ts:144): banner + auto-pop to exam detail (refresh slots). - Per-row saving: inline spinner in save-status slot; optimistic green check on
success (
Mo.fastcheck animation); failure → row shakeMo.shake, "Retry" affordance; error toast for 404 "Marks cannot exceed maximum." (examination.service.ts:145-146) → open marks dialog clamped tomaximumMarks. - Offline: row flagged
queued(dashed border + cloud icon); offline queue flush on reconnect (13_State_Management.md §4); coverage bar shows queued count. - Disabled: after exam
published, editing allowed server-side (OQ-5) but rows show "published" chip + confirm-before-edit sheet; publish-in-flight disables all fields.
A11y: row semantics "{student name}, {marks} of {maximum}"; keyboard: Tab
between fields, Enter = save current row + focus next; Semantics(liveRegion) on
coverage bar. Dark/light: token-only; failed row errorContainer tint.
Responsive: tablet shows a right-side detail sheet (remarks/grade editor) instead
of inline expansion. Motion: save check Mo.fast, error shake Mo.shake
(00-shared/08), coverage bar width 200 ms ease.
4. Add Subject Slot — /exams/:examId/slots/new (S4)
Chrome: full-screen form page (> 3 fields rule, 00-shared/05 §5); AppBar
"Add subject" + Save TextButton; body form list Sp.16 gaps,
resizeToAvoidBottomInset: true. Field order (mirrors CreateExaminationSubjectDto,
examination-subject.dto.ts:11-45):
1 subject AppDropdown (Academics subjects; required; @IsMongoId)
2 class AppDropdown (Academics classes; required; @IsMongoId)
3 date AppDatePicker (required; @IsDateString; client min = exam startDate,
max = exam endDate)
4 startTime AppTextField "Start time" (HH:mm, keyboard=time; example '09:00')
5 endTime AppTextField "End time" (HH:mm; client rule endTime > startTime)
6 maxMarks AppTextField number (required; @Min(1); @IsNumber)
7 passMarks AppTextField number (required; @Min(1); client rule ≤ maxMarks)
Client pre-validation (server lacks — OQ-2/OQ-3/OQ-6):
- Overlap: same date + time window touching another existing slot of this exam →
inline warning +
AppDialog"Overlaps with Subject X 09:00–12:00 — add anyway?" (server has no conflict logic; confirm proceeds). - Duplicate: subject already added to this exam → warning "Subject already scheduled (date 15 Aug)" — proceed allowed (no server dedupe, OQ-3).
passingMarks > maximumMarks→ inline error before submit.- Date outside exam window → inline error.
Submit: POST /examinations/:id/subjects (examination.controller.ts:45-50 →
examination.service.ts:112-131) with examinationId taken from route (never typed).
States: field errors (400 VALIDATION_ERROR → inline, focus first invalid);
submitting spinner on Save; success → pop + new slot visible; 404 exam missing →
pop with snackbar. A11y/other: as S8 (§8). Motion: inline error slide
Mo.fast.
5. Publish Confirm — dialog on S2 (S5)
Chrome: AppDialog (00-shared/03), width ≤ 480 dp:
Icon warning_amber (large, tertiary)
"Publish results?"
"Results for {N} subjects will be released to students and parents.
Unpublished marks stay hidden." ← server truth: publish stamps publishedAt
(examination-result.repository.ts:42-51)
Coverage recap: 3 slots · 118/120 marks entered (from slot result counts)
[Cancel TextButton] [Publish FilledButton (tertiary)]
States: initial; submitting → button spinner, dialog non-dismissable, barrierDismissible: false; success → close, badge published, snackbar "Results published";
error 404/5xx → reopen dialog with error line + requestId, Publish re-enabled.
Note: server allows re-publish and post-publish edits (OQ-5) — the client
enforces immutability; dialog is the single confirm gate. A11y: focus trap,
Escape = cancel, confirm label "Publish". Motion: scale-in Mo.base, success
check Mo.fast.
6. Student Results — /results (S6)
Chrome: AppBar "My Results" (+ parent: "Name's Results"); body
RefreshIndicator + grouped list by exam (GET /results/student/:studentId,
result.controller.ts:16-20 → result.service.ts:36-38).
Row — ResultRowCard: exam name (resolved via exam id? — payload only carries
examinationSubjectId; client groups by slot's exam; subject names resolved
client-side from slot ids — OQ-9), subjectName · marksObtained/maximumMarks · grade chip, publishedAt formatted ("Published 15 Aug"), remarks preview.
States: loading; empty "No results yet — check after your next exam"; error;
offline banner + cache (TTL 5 min). Note: the endpoint returns unpaginated
arrays — client slices by exam client-side (OQ-7 pattern).
A11y: each row one Semantics label "{subject}, {marks} of {max}, grade {g}".
Responsive: tablet = table layout (columns subject/marks/grade/remarks).
Motion: row stagger Mo.fast.
7. Report Card — /results/:examId/report-card (S7)
Chrome: AppBar "Report Card" + share action (Icons.share → image share,
(proposed)). Body card-stack on surfaceContainerLow:
Header: exam name · generatedAt (ISO → "Generated 15 Aug 14:30")
Overall card (primaryContainer, r-lg):
percentage displaySmall mono tabular ("82.5%")
overallGrade GradeBadge "A" + threshold caption ("80–89%")
totals row: totalMarksObtained / totalMaximumMarks
Subject table (ReportCardTable, tablet=real columns / phone=rows):
subjectName (resolved, OQ-9) · marksObtained/maximumMarks ·
grade (client-supplied, may be empty) · remarks (may be empty)
Data: GET /results/report-card/:studentId/:examId (result.controller.ts:32-37
→ result.service.ts:46-98): subjects[], totalMarksObtained,
totalMaximumMarks, percentage (2 dp), overallGrade (A+/A/B+/B/C/D/F at
90/80/70/60/50/40 — result.service.ts:130-138), generatedAt.
States: loading skeleton; empty/404 ("No subjects found for this examination.",
result.service.ts:51-53) → AppEmptyState "No subjects scheduled for this exam";
error; offline (cached card, publishedAt-stamped cards are cacheable 24 h).
Edge rendering: subject with no result → row shows 0/max + muted "not marked"
hint (payload cannot distinguish 0 vs absent — result.service.ts:70); empty
grade → "—"; remarks overflow → 2-line clamp + expand. If all subjects are
unmarked → percentage 0, grade F — show explanatory note, not an error.
A11y: table headers announced; GradeBadge label "A, 80 to 89 percent".
Responsive: phone = card rows, ≥840 dp = real table; landscape keeps table.
Motion: rows fade Mo.fast; badge pop Mo.base.
8. Create / Edit Exam — /exams/new · /exams/:examId/edit (S8 / S8a)
Chrome: full-screen form; AppBar "New exam" / "Edit exam" + Save; body Sp.16
gaps. Fields mirror CreateExaminationDto (examination.dto.ts:4-26) /
UpdateExaminationDto (examination.dto.ts:28-53):
1 academicYear AppDropdown (Academic years; create only; required; @IsMongoId)
2 name AppTextField (required; @IsString; maxLength 100 client)
3 type AppDropdown midterm|final|unit_test|quarterly|other
(required; schema enum examination.schema.ts:15-20; DTO is IsString
only — client constrains, OQ note)
4 startDate AppDatePicker (required; @IsDateString)
5 endDate AppDatePicker (required; client rule endDate ≥ startDate — OQ-6)
6 status (edit) read-only chip (draft|active|completed|published) — client never
sends status on create; edit sends only explicit transitions (OQ-1)
Submit: create → POST /examinations → 201 doc, status: 'draft' forced
(examination.service.ts:48); edit → PATCH /examinations/:id partial
(examination.service.ts:81-97), $set only sent fields, version +1
(base.repository.ts:57-66). Edit prefill from GET /examinations/:id.
States: field validation; submitting spinner; success → pop to detail +
snackbar; 400 field errors inline; 404 (edit of deleted exam,
examination.service.ts:87) → pop + "Exam no longer exists"; status edit rule:
chips for draft → active only (free-string status risk, OQ-5); after published
the edit route is unreachable from UI.
A11y/keyboard: labeled fields, error summaries announced, autofocus name;
submit via keyboard done. Responsive: single column ≤ 600 dp, two-column grid
≥ 840 dp. Motion: inline error slide Mo.fast.