Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

15 — Flutter Implementation Guide (Bulk Module)

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)MultipartRequest POST /api/v1/bulk/import/:entity, field file (filename students.csv, contentType: text/csv) — matches FileInterceptor('file') (bulk.controller.ts:38-48); body decoded UTF-8 server-side (:47). Returns ImportReport from envelope data.
  • 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 the Content-Disposition filename.
  • 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

PlatformPackage / APINotes
web/desktopfile_picker (FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['csv']))also universal_html drag-drop for the drop zone
Android/iOSfile_selector/file_picker with mime text/csv, text/comma-separated-values, application/csvsome providers return .txt for CSV — accept by content sniff, not extension alone
Allread 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 — csv package with shouldParseNumbers: false (never let the parser coerce types; server rows are Record<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: truefirst row = header; rows → Map<String,String>
trim: truetrim each cell after split
skip_empty_lines: truedrop blank lines before mapping
quoting (RFC-4180)default csv behaviour — must match; test quoted commas (14 §2)
header normalizationnone 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): when IMPLEMENTATION_PLAN.md:172 workers land (queue + progress + polling), ImportProgressPanel accepts progress: 0..1 and the cubit adds jobId/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

  • ImportSummaryCard asserts imported + 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 in 08 §2.2.
  • Error CSV download is client-synthesized: original file rows + appended error column joined from the report by rowNumber. No server endpoint exists.
  • "Fix errors" passes failedRowsQueue (row numbers) back to SS1 for highlighting after re-pick (03 J3).

7. Export

  • exportCsv via repository; on mobile hand bytes to a share sheet (share_plus); on web trigger an anchor download from the response bytes with filename="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.