API DOCUMENTATION — OMR Grader ================================================================================================ API Name: fileUpload API Type: POST Endpoint: POST /fileUpload Content-Type: multipart/form-data ------------------------------------------------------------------------------------------------ REQUEST — Fields sent to the Backend (multipart body): Field | Type | Description --------------- | ----------------- | ------------------------------------------------------- answer_key | File (PDF) | The answer key file answer_sheets | File(s) | One or more answer sheet files (PDF / JPG / PNG) | | (Multiple entries with the same field name) ------------------------------------------------------------------------------------------------ RESPONSE — Data received at the Frontend: The response is NDJSON (Newline-Delimited JSON). One JSON object is streamed per line as each sheet is graded. Sample Response: {"sheet_name": "answer_sheet_1.pdf", "score": 18, "total": 25, "percent": 72.0, "blank": 2, "wrong_list": [3, 7, 14], "answers": [{"question": 1, "marked": "A", "correct": "A"}, {"question": 2, "marked": "C", "correct": "C"}, {"question": 3, "marked": "B", "correct": "D"}, {"question": 4, "marked": "E", "correct": "E"}, {"question": 5, "marked": "A", "correct": "A"}, {"question": 6, "marked": "B", "correct": "B"}, {"question": 7, "marked": "C", "correct": "E"}, {"question": 8, "marked": "D", "correct": "D"}, {"question": 9, "marked": "A", "correct": "A"}, {"question": 10, "marked": "B", "correct": "B"}, {"question": 11, "marked": "C", "correct": "C"}, {"question": 12, "marked": "D", "correct": "D"}, {"question": 13, "marked": "E", "correct": "E"}, {"question": 14, "marked": "A", "correct": "C"}, {"question": 15, "marked": "B", "correct": "B"}, {"question": 16, "marked": "C", "correct": "C"}, {"question": 17, "marked": "D", "correct": "D"}, {"question": 18, "marked": "E", "correct": "E"}, {"question": 19, "marked": "A", "correct": "A"}, {"question": 20, "marked": "B", "correct": "B"}, {"question": 21, "marked": "", "correct": "C"}, {"question": 22, "marked": "D", "correct": "D"}, {"question": 23, "marked": "E", "correct": "E"}, {"question": 24, "marked": "", "correct": "A"}, {"question": 25, "marked": "B", "correct": "B"}]} {"sheet_name": "answer_sheet_2.pdf", "score": 10, "total": 25, "percent": 40.0, "blank": 5, "wrong_list": [1, 2, 5, 8, 9, 11, 13, 16, 19, 20, 22, 23, 24, 25], "answers": [{"question": 1, "marked": "B", "correct": "A"}, {"question": 2, "marked": "D", "correct": "C"}, {"question": 3, "marked": "E", "correct": "E"}, {"question": 4, "marked": "A", "correct": "A"}, {"question": 5, "marked": "C", "correct": "B"}, {"question": 6, "marked": "D", "correct": "D"}, {"question": 7, "marked": "E", "correct": "E"}, {"question": 8, "marked": "A", "correct": "D"}, {"question": 9, "marked": "B", "correct": "A"}, {"question": 10, "marked": "C", "correct": "C"}, {"question": 11, "marked": "D", "correct": "E"}, {"question": 12, "marked": "E", "correct": "E"}, {"question": 13, "marked": "A", "correct": "B"}, {"question": 14, "marked": "B", "correct": "B"}, {"question": 15, "marked": "C", "correct": "C"}, {"question": 16, "marked": "D", "correct": "A"}, {"question": 17, "marked": "E", "correct": "E"}, {"question": 18, "marked": "A", "correct": "A"}, {"question": 19, "marked": "B", "correct": "D"}, {"question": 20, "marked": "C", "correct": "E"}, {"question": 21, "marked": "", "correct": "B"}, {"question": 22, "marked": "D", "correct": "C"}, {"question": 23, "marked": "E", "correct": "A"}, {"question": 24, "marked": "", "correct": "B"}, {"question": 25, "marked": "A", "correct": "D"}]} Response Fields: Field | Type | Description --------------- | ---------------- | -------------------------------------------------------- sheet_name | string | Name of the answer sheet file score | int | Number of correct answers total | int | Total number of questions percent | double | Score percentage (0.0 to 100.0) blank | int | Number of unanswered questions wrong_list | list of int | Question numbers answered incorrectly ------------------------------------------------------------------------------------------------ Status Code: 200 (or 201) ================================================================================================ // ══════════════════════════════════════════════════════════════════════════ // Flutter API — single multipart streaming call // ══════════════════════════════════════════════════════════════════════════ Future _fileUpload(BuildContext context) async { _cancelled = false; try { debugPrint( 'fileUpload: key=$_answerKeyFileName sheets=${_answerSheetFiles.length}'); final request = http.MultipartRequest( 'POST', Uri.parse('$_kBase/fileUpload'), ); // answer_key field request.files.add(await http.MultipartFile.fromPath( 'answer_key', _answerKeyPath!, contentType: http.MediaType('application', 'pdf'), filename: _answerKeyFileName, )); // answer_sheets field — one entry per file for (final file in _answerSheetFiles) { if (file.path == null) continue; final ext = file.name.split('.').last.toLowerCase(); final mime = switch (ext) { 'pdf' => http.MediaType('application', 'pdf'), 'png' => http.MediaType('image', 'png'), _ => http.MediaType('image', 'jpeg'), }; request.files.add(await http.MultipartFile.fromPath( 'answer_sheets', file.path!, contentType: mime, filename: file.name, )); } debugPrint('fileUpload: sending ${request.files.length} file(s)…'); // Finalize to get full body bytes (enables progress tracking) final bodyBytes = await request.finalize().toBytes(); if (_cancelled) return false; final fullSize = bodyBytes.length; final contentType = request.headers['content-type'] ?? request.headers['Content-Type'] ?? 'multipart/form-data'; // Open raw HTTP request final ioRequest = await HttpClient().postUrl(Uri.parse('$_kBase/fileUpload')); ioRequest.headers.set('Content-Type', contentType); ioRequest.headers.set('Content-Length', fullSize.toString()); // Stream body in chunks with progress updates const chunkSize = 32 * 1024; // 32 KB int sentBytes = 0; for (int offset = 0; offset < bodyBytes.length; offset += chunkSize) { if (_cancelled) { ioRequest.abort(); return false; } final end = (offset + chunkSize).clamp(0, bodyBytes.length); ioRequest.add(bodyBytes.sublist(offset, end)); sentBytes = end; if (mounted) setState(() => _uploadProgress = sentBytes / fullSize); } if (_cancelled) { ioRequest.abort(); return false; } final ioResponse = await ioRequest.close(); debugPrint('fileUpload status: ${ioResponse.statusCode}'); // Upload is complete — server is now grading if (mounted) setState(() => _isGrading = true); if (ioResponse.statusCode != 200 && ioResponse.statusCode != 201) { final body = await ioResponse.transform(utf8.decoder).join(); debugPrint('fileUpload error body: $body'); showToast( 'Server error ${ioResponse.statusCode}, please try again!', context, isShort: false, ); return false; } // Stream NDJSON response — one JSON result object per line await for (final line in ioResponse .transform(utf8.decoder) .transform(const LineSplitter())) { if (_cancelled) break; if (line.trim().isEmpty) continue; try { final result = jsonDecode(line) as Map; if (mounted) setState(() => _gradingResults.add(result)); } catch (e) { debugPrint('Failed to parse result line: $line — $e'); } } return !_cancelled; } catch (e) { if (_cancelled) return false; debugPrint('Exception: $e'); if (e is SocketException) { showToast('Please check your internet connection!', context); return false; } showToast('Unable to process files, please try again!', context); return false; } }