15 — Flutter Implementation Guide (Results Module)
- 1. Marks-entry grid performance (S1)
- 2. Offline marks draft
- 3. Report card rendering & cache (S4)
- 4. Grade summary charts (S5)
- 5. Networking & error mapping
- 6. Realtime
- 7. Suggested file layout
- 8. Golden/unit test hooks
Client implementation notes against the v1 API. Stack per 00-shared/11_Flutter_App_Architecture.md; state per 13_State_Management.md; components per 07_Component_Library.md.
(forward-looking)— mobile is out of Phase 1 (see 01_Product_Overview.md).
1. Marks-entry grid performance (S1)
- Never use stock
DataTablefor the entry grid: it builds every cell eagerly. Use a virtualised list:ListView.builderwith fixed row extent (itemExtentorprototypeItem) so off-screen rows are not built.- Keep row height constant; varying remarks text wraps inside a fixed two-line box
(ellipsis) — never
IntrinsicHeight.
- Only visible rows own
TextEditingControllers — create controllers initemBuilderand dispose on scroll-out; a 300-student grid otherwise leaks controllers and rebuilds everything per keystroke. - Local edit buffer:
TextEditingController.textis the only mutable state (ponytail: no per-cell form model). Commit on Enter/onBlur viaMarksEntryCubit.commitRow(...)(13 §3); the cubit owns dirty/saving state. - Numeric input:
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))(decimal allowed —IsNumberhas no integer constraint,examination-subject.dto.ts:53),FontFeature.tabularFigures(),monotype (11 §3). - Client validation mirrors the server rule before any POST:
marksObtained > maximumMarks → inline error, no request(examination.service.ts:145-146). - Debounce nothing; commit is user-initiated (Enter/blur), so per-row saving
state stays sparse. Batch saves (
commitAll) fire sequentially, one in flight per row, others queued — noFuture.waiton the whole grid (rows must stay independent).
2. Offline marks draft
- Local store:
drift/sqflitetablemarks_draft(exam_subject_id, student_id, marks_obtained, grade, remarks, seq INTEGER PRIMARY KEY AUTOINCREMENT)— one row per unsynced edit,seqpreserves entry order (13 §8). - Upsert semantics make replay safe: re-POSTing the same
EnterMarksDtois an update, never a duplicate (examination.service.ts:147-175). No dedupe keys needed. - Replay: on connectivity restore, drain in
seqorder viaMarksEntryCubit.syncOfflineDraft(); per-row failure → keep the row, surface error, continue (09 B8). Row is deleted from the draft only on 2xx. - Before overwriting a draft row, merge by
updatedAtfrom E2 fetch: if the server row is newer than the draft, keep the server value and drop the draft (server wins — last-write-wins on the server,examination.service.ts:152-161). - Conflict policy is deliberately naive (no three-way merge):
(planned)revisit when multi-device grading lands.
3. Report card rendering & cache (S4)
- Cache
GET /api/v1/results/report-card/:studentId/:examIdkeyed(tenant, studentId, examId)TTL 5 min (13 §2); invalidate onresults-publishedin-app event so a published exam's card refreshes immediately (10 I4). - Render server-computed values verbatim —
percentage,overallGrade,generatedAtcome fromresult.service.ts:83-96; the client never recomputes (single source of truth). subjectNameis the raw subject ID (result.service.ts:75) — resolve via a local subject registry (name map fromGET /api/v1/subjects,(planned)-friendly: graceful fallback shows the ID if the registry misses).- 404 with message "No subjects found for this examination."
(
result.service.ts:51-52) →NoSubjectsguided empty state, not an error screen.
4. Grade summary charts (S5)
- No chart package.
R-GradeDistributionChart=ColumnofRow(children: [label, Expanded(FractionallySizedBox(widthFactor: n/total, child: container)), countText])— a dozen lines, semantic labels free. - Counts come from stored
gradestrings (client-supplied;R-GradeChipcolour mapping per 11 §1); ungraded bucket = rows withoutgrade. - Always render the numeric counts as text next to bars (a11y, 06 §S5).
5. Networking & error mapping
- Single
ApiClientwrapper per 00-shared/07 + 11_Flutter_App_Architecture.md. - Map envelope codes → copy:
| Code | UI |
|---|---|
400 VALIDATION_ERROR | inline field errors |
401 UNAUTHENTICATED | force re-login |
403 PERMISSION_DENIED | error screen (today unreachable — no RBAC on routes) |
404 RESOURCE_NOT_FOUND | over-max normalisation OR empty/not-found states (distinguish by route) |
429 RATE_LIMITED | backoff + countdown (S5 publish) |
| 5xx | generic + requestId in snackbar (≥ 4 s) |
- Timeout: marks POST 15 s; reads 10 s. Retry policy: reads ×2 exponential; writes: no auto-retry — user-invoked or offline-draft replay only.
6. Realtime
results-publishedin-app event (event-queue-map.ts:27) →SocketListenerroutes toReportCardCubit.refresh()andMarksEntryCubit.setPublished(rowId)(10 I4). No other realtime surface exists for this module.
7. Suggested file layout
lib/features/results/
├── data/
│ ├── results_repository.dart # E1-E4 (13 §2)
│ └── examinations_repository.dart # E5-E8
├── cubits/ # per 13 §1
│ ├── marks_entry_cubit.dart
│ ├── student_results_cubit.dart
│ ├── report_card_cubit.dart
│ └── grade_summary_cubit.dart
├── screens/ # S1-S5
│ ├── marks_entry_screen.dart
│ ├── exam_subject_detail_screen.dart
│ ├── student_results_screen.dart
│ ├── report_card_screen.dart
│ └── grade_summary_screen.dart
├── widgets/ # 07_Component_Library.md
│ ├── data_table.dart # R-DataTable
│ ├── grade_chip.dart # R-GradeChip
│ ├── marks_field.dart # R-MarksField
│ ├── coverage_bar.dart # R-CoverageBar
│ ├── grade_distribution_chart.dart # R-GradeDistributionChart
│ └── publish_panel.dart # R-PublishPanel
└── offline/
├── marks_draft_store.dart # §2
└── draft_replayer.dart
8. Golden/unit test hooks
computeGradeband edges are server-side — unit-test the client display mapping (11 §1) at90/89.99/80/…againstresult.service.ts:130-138(14 §4).MarksEntryCubitstates viabloc_test(13 §3 state machine).DraftReplayer: offline queue order + partial-failure continuation.