15 — Flutter Implementation Guide (Bulk Module)
- 1. Module structure
- 2. BulkRepository
- 3. File picker
- 4. Isolate CSV parse (deep treatment)
- 5. Progress UI (deep treatment)
- 6. Result rendering & error CSV
- 7. Export
- 8. Offline, permissions, analytics
- 9. Tests (forward-looking,
00-shared/10)
Module implementation on top of 00-shared/11_Flutter_App_Architecture.md. Forward-looking (no client repo). Backend constraints cited; isolate CSV parse, progress UI, and file picker get the deep treatment as required.
1. Module structure
lib/features/bulk/
├── data/
│ ├── dto/import_report_dto.dart # {entity,totalRows,imported,failed,errors[{rowNumber,errors[]}]}
│ ├── dto/import_row_error_dto.dart # {rowNumber, errors[]}
│ ├── models/import_report.dart # normalized + invariant assert
│ ├── models/csv_file.dart # name, size, hash, rows (parsed in isolate)
│ └── repositories/
│ └── bulk_repository.dart # importCsv(entity, file) + exportCsv(entity)
├── domain/
│ ├── csv_parse_service.dart # isolate wrapper, mirrors server options
│ └── csv_error_exporter.dart # original row + error column → CSV
└── presentation/
├── cubit/ (bulk_home, bulk_preview, bulk_import, bulk_export)
├── pages/ (bulk_home_page, bulk_preview_page, bulk_confirm_page,
│ bulk_result_page, bulk_export_page)
└── widgets/ (file_drop_zone, import_stepper, csv_preview_table,
column_chip, import_summary_card, error_review_table,
error_row_tile, import_progress_panel)
DTO→model mapping per 00-shared/11 §4 (json_serializable); the report DTO
mirrors import-adapter.interface.ts:14-25 exactly — unknown fields ignored,
errors default [] (forward-compat, 07 §3).
2. BulkRepository
importCsv(String entity, Uint8List bytes)→MultipartRequestPOST /api/v1/bulk/import/:entity, fieldfile(filenamestudents.csv,contentType: text/csv) — matchesFileInterceptor('file')(bulk.controller.ts:38-48); body decoded UTF-8 server-side (:47). ReturnsImportReportfrom envelopedata.exportCsv(String entity)→GET /api/v1/bulk/export/:entity(bulk.controller.ts:50-60); returns bytes for download/share; on web use anchor download with theContent-Dispositionfilename.- Error mapping (
00-shared/06 §5): 400 file-level (malformed/empty), 404 entity, 429 countdown, 5xx generic + requestId; row errors live in the 200 body, not the envelope.
3. File picker
| Platform | Package / API | Notes |
|---|---|---|
| web/desktop | file_picker (FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['csv'])) | also universal_html drag-drop for the drop zone |
| Android/iOS | file_selector/file_picker with mime text/csv, text/comma-separated-values, application/csv | some providers return .txt for CSV — accept by content sniff, not extension alone |
| All | read as Uint8List; never as decoded string (encoding detection needs raw bytes) |
- Size guard before parse: reject > 2 MB with warning copy (10 §6); row-cap check happens after parse (1000 rows).
- BOM strip: if bytes start
EF BB BF, drop the prefix before parsing (14 §2).
4. Isolate CSV parse (deep treatment)
The client preview must agree with the server by construction: same
parser, same options as bulk-import.service.ts:26-30
(columns:true, skip_empty_lines:true, trim:true).
- Same library: use the Dart port of the same CSV spec —
csvpackage withshouldParseNumbers: false(never let the parser coerce types; server rows areRecord<string, string>,bulk-import.service.ts:24). - Isolate: parsing 1000+ rows + header mapping must never jank the UI:
Future<CsvParseResult> parseInIsolate(Uint8List bytes) async {
final result = await compute(_parseWorker, bytes, debugLabel: 'bulk-csv');
if (result.error != null) throw CsvParseException(result.error!);
return result;
}
CsvParseResult _parseWorker(Uint8List bytes) {
try {
final text = utf8.decode(bytes); // BOM stripped upstream
final rows = const CsvToListConverter(
shouldParseNumbers: false,
eol: '\n',
allowInvalid: false,
).convert(text);
// mirror csv-parse options:
// columns:true → first row = headers
// trim:true → trim cell values
// skip_empty_lines:true → drop blank lines
// then reject: unclosed quote / ragged rows → CsvParseException
...
} catch (e) {
return CsvParseResult(error: 'Malformed CSV: could not parse file.');
}
}
- Isolate mirror table (server vs client):
csv-parse option (bulk-import.service.ts:26-30) | Dart csv equivalent |
|---|---|
columns: true | first row = header; rows → Map<String,String> |
trim: true | trim each cell after split |
skip_empty_lines: true | drop blank lines before mapping |
| quoting (RFC-4180) | default csv behaviour — must match; test quoted commas (14 §2) |
| header normalization | none on server — keep keys verbatim; flag whitespace headers (06 §2.2) |
- Result of the isolate:
CsvParseResult {headers[], rows: Map<String,String>[], physicalRowNumbers[], error?}. Physical row numbers are the file line numbers (header = 1) so the preview and the server report agree (bulk-import.service.ts:46). - File hash (
package:cross_file/sha-256 of bytes) keys the wizard state and enables "re-pick same file" detection (10 §1.5).
5. Progress UI (deep treatment)
The backend is synchronous — there are no progress events
(bulk-import.service.ts:22-65). The honest UI is an indeterminate
LinearProgressIndicator on ImportProgressPanel (07 §9):
- Navigate to SS4 before awaiting the request (10 §3.1); the cubit owns
the
Future. - Copy per
06 §4.1a: "validated and created server-side in one request — keep this tab open". - Request timeout ≥ 120 s; on timeout set
requestState: timedOut→ partial-upload guidance (rows may exist; re-upload is duplicate-safe,students-import.adapter.ts:49-56). - Async seam
(planned): whenIMPLEMENTATION_PLAN.md:172workers land (queue + progress + polling),ImportProgressPanelacceptsprogress: 0..1and the cubit addsjobId/polling(13 §4.2). Keep the panel's API(panel.progress?, phase)so the swap is a field, not a rebuild. - Row-level feedback beyond the report is impossible today — no partial reports, no resume (14 §5).
6. Result rendering & error CSV
ImportSummaryCardassertsimported + failed === totalRows; mismatch → data-error banner with raw report (06 §4.2).- Error groups derived by string prefix per
04 §7(duplicates / references / format) — grouping logic keyed on the exact strings in08 §2.2. - Error CSV download is client-synthesized: original file rows + appended
errorcolumn joined from the report byrowNumber. No server endpoint exists. - "Fix errors" passes
failedRowsQueue(row numbers) back to SS1 for highlighting after re-pick (03 J3).
7. Export
exportCsvvia repository; on mobile hand bytes to a share sheet (share_plus); on web trigger an anchor download from the response bytes withfilename="students.csv"(bulk.controller.ts:55-58).- Empty roster → still download header-only CSV (06 §5.2).
8. Offline, permissions, analytics
- No offline writes: import/export require connectivity
(
00-shared/06 §3.5); reads none in this module. - Permission gate: client-side admin check today; handle 403 gracefully once
the server RBAC guard lands
(planned)(permissions.constants.ts:10). - Analytics
(proposed):bulk.upload.pick,bulk.preview.warnings,bulk.confirm.start(n),bulk.result.view(imported,failed),bulk.result.reupload,bulk.export.download.
9. Tests (forward-looking, 00-shared/10)
- Isolate parser vs server spec table (quoted commas, BOM, CRLF, empty lines, trim, whitespace headers — 14 §2/§3).
- Report DTO decode incl. invariant violation path.
- Cubit state machine transitions (13 §4.4) incl. timeout → timedOut.
- Golden: SS4 partial-success rendering with a fixture report.