15 — Flutter Implementation Guide (Reports Module)
- 1. Packages
- 2. Service layer
- 3. Polling engine (shared widget-level service)
- 4. Cubit wiring (
13 §1) - 5. Download & viewer
(planned) - 6. Scheduler & notifications
(forward-looking) - 7. Offline
- 8. Testing
Concrete Flutter build notes for the Reports module. App architecture per 00-shared/11 (recommended: Cubit, 00-shared/06); state layout 13; polling 10; contracts 12.
1. Packages
- Existing shared stack only — no new dependencies for polling (core
dart:async Timer). - Downloads/viewer
(planned)— see §5 for recommended additions once the server endpoint exists:flutter_downloaderordio+path_provider(follow 00-shared/11). - No PDF viewer today (no PDFs exist).
2. Service layer
class ReportsApi {
final Dio dio; // shared client, `00-shared/11`
Future<JobCreated> generate(GenerateReportDto dto) async {
final r = await dio.post('/reports/generate', data: dto.toJson());
return JobCreated.fromJson(r.data['data']); // {jobId, status}
}
Future<ReportJobDoc> getJob(String jobId) async {
final r = await dio.get('/reports/$jobId');
return ReportJobDoc.fromJson(r.data['data']);
}
}
Envelope unwrap per 00-shared/07; DTO mirrors GenerateReportDto
(generate-report.dto.ts:5-34): type enum string, optional MongoIds as
strings, dates as ISO strings.
3. Polling engine (shared widget-level service)
class JobPoller {
JobPoller(this.api, {this.interval = const Duration(seconds: 2)});
Timer? _t;
void start(String jobId, void Function(ReportJobDoc) onDoc,
void Function(JobPollerError) onError) {
_t ??= Timer.periodic(interval + _jitter(), () async {
try {
final doc = await api.getJob(jobId);
onDoc(doc); // cubit maps status
if (doc.status.isTerminal) stop();
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout) onError(/* pause */);
}
});
}
void stop() { _t?.cancel(); _t = null; }
}
Rules: 2 s ± 400 ms jitter; pause after 2 consecutive failures; stop() on
terminal/404; cancel in dispose (10 §1). ReportJobStatus maps
queued|processing|completed|failed (report-job.schema.ts:13-18).
4. Cubit wiring (13 §1)
class ReportJobDetailCubit extends Cubit<JobDetailState> {
ReportJobDetailCubit(this.api) : super(JobDetailState.initial());
void open(String jobId) {
poller.start(jobId, (doc) => emit(state.copyWith(
job: doc, status: doc.status,
// terminal → emit completed/failed; cubit stops poller
)), (e) => emit(state.copyWith(pollPaused: true)));
}
void retry() {
// re-POST params (reports.service.ts:31-43) → new jobId → pushReplacement
}
}
Never predict status; mirror the server doc only (13 §2 mermaid).
5. Download & viewer (planned)
Until GET /reports/:jobId/download (04-Modules/Reports.md:26) exists:
- Hide export CTAs (
06 §S5); no download manager code ships.
When it lands:
- Download manager:
GET /reports/:jobId/download→ stream to app docs dir (path_provider),Content-Dispositionfilename parsed from headers (files.controller.ts:55-64pattern —attachment; filename="…"); notify completion; keep per-tenant folder. - Viewer: open PDF via
flutter_pdfviewor share viashare_plus; never load the whole file into memory for large exports (streaming,:49). - Alternate path: server stores via files module → client uses
GET /files/:id/download(file.read,files.controller.ts:55-64).
6. Scheduler & notifications (forward-looking)
- Scheduled jobs (
attendance-report.job.ts:13-24) surface via history once a list API exists (12 E4). - Completion notification: subscribe when server emits
ReportGenerated(04-Modules/Reports.md:33) — deep link/reports/jobs/:jobId.
7. Offline
- History + last-good job/result cached (
13 §3); banner + paused poll (10 §4); no writes offline (generate requires network).
8. Testing
- Unit: cubit transitions, poller pause/resume/jitter, renderer mapping
(
result→ models per06 §S5). - Widget: S4 status swaps with fake poller; S2 validation (
08 §3). - Golden: metric cards with
tabularFigures(11 §4). - E2E (
00-shared/10): mock API — full journey J1/J2/J3 (03); failure paths: 404, job failed, network drop mid-poll.