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<bool> _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<String, dynamic>;
          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;
    }
  }
