15 — Flutter Implementation Guide (Users Module)
- 1. Module structure
- 2. UsersRepository
- 3. CSV parse in isolate (preview + validation)
- 4. Progress UI (long-running import)
- 5. Import wizard UI
- 6. Key screens & widgets
- 7. Future hooks (planned/forward-looking)
- 8. Tests
Module implementation on top of 00-shared/11_Flutter_App_Architecture.md. Forward-looking (no client repo). Backend constraints cited; CSV parse and progress UI get the deep treatment as required.
1. Module structure
lib/features/users/
├── data/
│ ├── dto/user_dto.dart # envelope data payload → User
│ ├── dto/import_result_dto.dart # {imported, errors[]}
│ ├── dto/import_report_dto.dart # bulk module {entity,totalRows,imported,failed,errors[]}
│ ├── models/user.dart # normalized (status enum, DateTime, avatarFileId?)
│ ├── models/csv_preview.dart # headers, rows, warnings
│ └── repositories/
│ ├── users_repository.dart # CRUD + preferences + avatar
│ └── import_repository.dart # /users/import + /bulk/import/:entity
├── domain/
│ ├── csv_service.dart # isolate-based parse/validate (CSV parse in isolate)
│ └── csv_error_exporter.dart # error CSV download
└── presentation/
├── cubit/ (users_list, user_detail, user_form, preferences,
│ bulk_import, membership, self_profile)
├── pages/ (users_list_page, user_detail_page, user_form_page,
│ import_wizard_page, preferences_page, self_profile_page)
└── widgets/ (user_list_tile, status_badge, role_chips, avatar_uploader,
import_stepper, csv_preview_table, import_error_list)
DTO→model mapping per 00-shared/11 §4 (json_serializable); enums mapped
from UserStatus strings (user.schema.ts:7-12), unknown → fallback
unknown (forward-compat, 07 §3).
2. UsersRepository
list({page, limit, sort='-createdAt', q})→PagedResult<User>from envelopedata+meta(pagination-query.dto.ts:32-55).create(dto),update(id, dto),get(id),remove(id),erase(id),getPreferences(id),updatePreferences(id, full),uploadAvatar(id, File)— multipart fieldfile(users.controller.ts:96), timeout 120 s (00-shared/11 §5).importUsers(File, {onSendProgress})→ImportResult.- Preferences: repository exposes
updatePreferences(id, fullObject)— callers always pass the complete merged object (full-replace contract,users.service.ts:161-163); repository asserts both groups present in dev. - Cache (
00-shared/06 §3.3):sl:{tenant}:users:{query}TTL 5 min via Hive/prefs;refresh=truebypasses. - Typed errors:
ApiException(code, status, fieldDetails)per00-shared/06 §5; 409 exposesmessagefor inline conflict.
3. CSV parse in isolate (preview + validation)
The server's inline parser is naive (split(',') — users.service.ts:251);
the client parser is strict superset (handles quotes) so the preview is
more accurate than the server — warnings surface the mismatch.
// domain/csv_service.dart
class CsvPreview {
final List<String> headers; // lowercased, trimmed (mirror :244-247)
final List<CsvRow> rows; // physical line number included
final List<CsvWarning> warnings; // quotes, dup emails, missing optional cols
}
Future<CsvPreview> parseCsv(File file, {void Function(int, int)? onProgress}) {
// Run in isolate: compute() with a SendPort for progress every ~200 rows.
// NEVER parse on the UI thread — 1000 rows × split is fine, but quoting
// scan + preview table must not jank; 00-shared/11 §13 mandates isolate.
return Isolate.run(() => _parseSync(file, onProgress));
}
- Parse semantics mirror server: first non-empty line = header, lowercased,
trimmed (
users.service.ts:244-247); rows = non-empty lines (:237); required columnemail(:256-259); name aliasesfirstname|first_name,lastname|last_name(:265-266); defaultslanguage=en,timezone=UTC(:270-271); fallback namesUnknown(:265-266). - Warnings (preview must show, not just parse):
- quoted fields present (server will mis-split — OQ-7);
- BOM in first header cell;
- in-file duplicate emails (server rejects 2nd occurrence —
:260-263); - rows that will import as
Unknownname; - header count mismatch with template.
- Row numbers displayed = physical line (must match server's
Row N—users.service.ts:257; precedentbulk-import.service.ts:46). - Isolate pattern:
Isolate.run(() => ...)for one-shot parse (Dart 3); progress viaonProgresscallback only supported with explicitIsolate.spawn+ ports — chooseIsolate.spawnwhen progress UI needs updates;Isolate.runwhen files are small.
4. Progress UI (long-running import)
BulkImportCubit machine (13 §6) renders:
| Phase | Widget | Value source |
|---|---|---|
| ParsingLocal | LinearProgressIndicator(value: parsed/total) | isolate progress (real) |
| Uploading | LinearProgressIndicator(value: sent/total) | dio onSendProgress (real) |
| ServerProcessing | indeterminate bar + elapsed timer | none — honest "Server is importing N rows" (OQ-6) |
| Succeeded | result summary: imported / errors.length (users.service.ts:281) | response |
| Failed | AppErrorState + Retry | ApiException |
- Upload:
dio.FormDatawithMultipartFile.fromFile(..., field: 'file')(users.controller.ts:104-110); timeout 120 s (00-shared/11 §5). - No polling exists (synchronous endpoint) — do not build a poll loop;
when async import lands
(planned)(PLAN.md 2.7), swapServerProcessingfor poll-until-done (see §7). - Retry: safe — server dedups emails (
users.service.ts:260-263); copy: "Already-imported rows will not be duplicated."
5. Import wizard UI
AppStepper(00-shared/03): 1 Upload → 2 Preview → 3 Result.- Step 2
CsvPreviewTable: header map chips, row count, first 5 rows, warning banners (AppBannerseverity warning), template download (email,firstname,lastname,phone,gender,language,timezone). - Step 3
ImportErrorList: mono row numbers + verbatim messages; error CSV export (row,message). - File picking:
file_picker(mimetext/csv); desktop drag-drop viadesktop_dropor file_picker's web support; size guard ≤ 2 MB (OQ-10).
6. Key screens & widgets
- UsersListPage:
RefreshIndicator+ListView.builder(required,00-shared/11 §13);ScrollControllerload-more onhasNext; search viaAppSearchBardebounce 300 ms; filter chips (client-side, labeled(planned)); master-detail ≥ 840 dp viaLayoutBuilder. - UserFormPage:
Form+TextFormFields mirroringCreateUserDto/UpdateUserDto(08 §1-§2); autofill hints;AppDatePickerfor DOB; statusAppDropdown; rolesRoleChips(step 2 of create); submit sequence user→membership withcreatedWithoutRolesretry state. - UserDetailPage: header +
TabBar(Profile/Membership/Preferences); tabs via keep-aliveTabBarView. - SelfProfilePage:
:idfromSessionCubit.sub(auth.service.ts:461); avatar viaAvatarUploader(crop dialog, thenuploadAvatar). - PreferencesPage:
PreferencesPanel; serialize saves.
7. Future hooks (planned/forward-looking)
- Async import:
ImportJobCubitpollingGETjob-status(planned)— wire today'sBulkImportCubit.ServerProcessingto it without UI churn. - Invite flow:
status=invited+ accept endpoint(planned)— badge already renders (07 §3). - Bulk users adapter:
POST /bulk/import/users(planned)— swapImportRepositorystrategy; report UI supports{rowNumber, errors[]}shapes already. - Avatars via storage URL: resolve
avatarFileId→ URL(planned)(E9 returns id only).
8. Tests
- Unit:
csv_service(quotes, BOM, aliases, dup detection, physical line numbers);bulk_import_cubitstate machine (every transition incl. cancel/retry);users_repositoryenvelope mapping (paginated meta); formatters (status labels). - Widget: 3 states per screen (
00-shared/10 §9); golden:StatusBadge×4,ImportErrorList,UserListTile,PreferencesPanel(light/dark, 3 sizes). - Integration: import journey (pick → preview → import → result) against a mocked server; error-CSV download.
- Golden fonts bundled as test assets (
00-shared/11 §12).