feat(frontend): multi/single OCR enrollment review and upload for add student screen

This commit is contained in:
ssbg04 committed 2026-08-08 01:45:28 +08:00
1 parent 88c470d924
commit 911c03d61e
3 files changed
+150 -36

No files matched your search

@@ -219,7 +219,7 @@ class StudentMutationNotifier extends AsyncNotifier<void> {
@override
FutureOr<void> build() {}
Future<void> createStudent({
Future<int> createStudent({
required String lrn,
required String firstName,
String? middleName,
@@ -236,7 +236,7 @@ class StudentMutationNotifier extends AsyncNotifier<void> {
state = const AsyncLoading();
try {
final repo = ref.read(studentRepositoryProvider);
await repo.createStudent(
final id = await repo.createStudent(
lrn: lrn,
firstName: firstName,
middleName: middleName,
@@ -253,6 +253,7 @@ class StudentMutationNotifier extends AsyncNotifier<void> {
state = const AsyncData(null);
// Invalidate to refresh the list
ref.invalidate(studentPageProvider);
return id;
} catch (e, st) {
state = AsyncError(e, st);
rethrow;
@@ -12,6 +12,7 @@ import '../../../shared/buttons/primary_button.dart';
import '../../../providers/setup_provider.dart';
import '../../../providers/ocr_provider.dart';
import '../../../providers/student_provider.dart';
import '../../../providers/document_provider.dart';
import '../../documents/widgets/document_preview_modal.dart';
import '../../../shared/inputs/document_source_picker.dart';
import 'ocr_enrollment_validation_modal.dart';
@@ -46,11 +47,12 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
int? _selectedSectionId;
String? _trackStrand;
/// Enrollment accepted from the OCR review dialog; auto-fills the
/// enrollment step until the student is saved.
OcrEnrollmentPrefill? _ocrSavedEnrollment;
/// Enrollments accepted from the OCR review dialog; auto-fill the
/// enrollment step and are created with the student on save.
final List<OcrEnrollmentPrefill> _ocrSavedEnrollments = [];
File? _ocrScannedFile;
String _ocrScannedFileName = '';
String? _selectedOcrDocType;
static const _extSuggestions = [
@@ -260,6 +262,7 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
setState(() {
_errorMessage = null;
_ocrScannedFile = file;
_ocrScannedFileName = fileName;
});
String? docType = _detectDocType(fileName);
@@ -318,9 +321,9 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
}
/// For SF9/SF10 scans with a Grade 7-12 level, let the user review and
/// edit the detected enrollment (year level, grade level, section).
/// On accept the enrollment is saved into [_ocrSavedEnrollment] and the
/// enrollment step auto-fills with it.
/// edit the detected enrollment(s) (academic year, grade level, section).
/// Accepted enrollments are appended to [_ocrSavedEnrollments] and the
/// enrollment step auto-fills with the last accepted one.
Future<void> _offerOcrEnrollment(
OcrResultModel ocrResult,
String docType,
@@ -330,21 +333,27 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
if (gradeNum == null || gradeNum < 7 || gradeNum > 12) return;
if (!mounted) return;
final prefill = await OcrEnrollmentValidationModal.showForAddStudent(
final accepted = await OcrEnrollmentValidationModal.showForAddStudent(
context,
record: OcrEnrollmentRecord(
gradeLevel: gradeNum.toString(),
section: ocrResult.section,
schoolYear: ocrResult.schoolYear,
),
records: [
OcrEnrollmentRecord(
gradeLevel: gradeNum.toString(),
section: ocrResult.section,
schoolYear: ocrResult.schoolYear,
),
],
);
if (!mounted || prefill == null) return;
if (!mounted || accepted.isEmpty) return;
setState(() {
_ocrSavedEnrollment = prefill;
_selectedAcademicYearId = prefill.academicYearId;
_selectedGradeLevel = prefill.gradeLevel;
_selectedSectionId = prefill.sectionId;
_ocrSavedEnrollments.addAll(accepted);
final last = accepted.last;
_selectedAcademicYearId = last.academicYearId;
_selectedGradeLevel = last.gradeLevel;
_selectedSectionId = last.sectionId;
_trackStrand = ocrResult.trackStrand.trim().isEmpty
? null
: ocrResult.trackStrand.trim();
});
}
@@ -415,7 +424,7 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
setState(() => _isLoading = true);
try {
final notifier = ref.read(studentMutationProvider.notifier);
await notifier.createStudent(
final studentId = await notifier.createStudent(
lrn: _lrnController.text.trim(),
firstName: _firstNameController.text.trim(),
middleName: _middleNameController.text.trim().isEmpty
@@ -433,8 +442,65 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
trackStrand: _trackStrand,
is4ps: _is4ps,
);
// Additional enrollments accepted from OCR scans (the primary one
// was already created with the student).
for (final e in _ocrSavedEnrollments) {
if (e.academicYearId == _selectedAcademicYearId &&
e.gradeLevel == _selectedGradeLevel &&
e.sectionId == _selectedSectionId) {
continue;
}
await notifier.addEnrollment(
studentId: studentId,
academicYearId: e.academicYearId,
gradeLevel: e.gradeLevel,
sectionId: e.sectionId!,
);
}
// After the student's directory is created, upload the scanned
// SF9/SF10 document into it.
String? uploadError;
if (_ocrScannedFile != null) {
try {
final bytes = await _ocrScannedFile!.readAsBytes();
await ref.read(documentRepositoryProvider).uploadDocumentBytes(
studentId: studentId,
documentType: _selectedOcrDocType ?? 'SF9',
fileName: _ocrScannedFileName.isEmpty
? _ocrScannedFile!.path.split(RegExp(r'[\\/]')).last
: _ocrScannedFileName,
bytes: bytes,
);
} catch (e) {
final raw = e.toString();
uploadError = raw.startsWith('Exception: ') ? raw.substring(11) : raw;
}
}
if (!mounted) return;
ref.invalidate(studentPageProvider);
if (uploadError != null) {
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Student Created - Upload Failed'),
content: Text(
'The student was created successfully, but the scanned '
'document could not be uploaded to their folder.\n\n'
'$uploadError',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('OK'),
),
],
),
);
}
if (!mounted) return;
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
@@ -786,7 +852,7 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_ocrSavedEnrollment != null) ...[
if (_ocrSavedEnrollments.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(AppSizes.p12),
decoration: BoxDecoration(
@@ -807,17 +873,53 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
const SizedBox(width: 8),
Expanded(
child: Text(
'Enrollment saved from OCR scan: Grade '
'${_ocrSavedEnrollment!.gradeLevel} · '
'${_ocrSavedEnrollment!.sectionName} · '
'SY ${_ocrSavedEnrollment!.schoolYear} — '
'verify before saving the student.',
_ocrSavedEnrollments.length == 1
? 'Enrollment saved from OCR scan: Grade '
'${_ocrSavedEnrollments.first.gradeLevel} · '
'${_ocrSavedEnrollments.first.sectionName} · '
'SY ${_ocrSavedEnrollments.first.schoolYear} — '
'verify before saving the student.'
: '${_ocrSavedEnrollments.length} enrollments saved '
'from OCR scans — verify before saving the '
'student.',
style: const TextStyle(fontSize: 13),
),
),
],
),
),
if (_ocrSavedEnrollments.length > 1) ...[
const SizedBox(height: AppSizes.p8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final e in _ocrSavedEnrollments)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: AppColors.primaryGreen.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.primaryGreen.withValues(alpha: 0.25),
),
),
child: Text(
'Grade ${e.gradeLevel} · ${e.sectionName} · '
'SY ${e.schoolYear}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.primaryGreen,
),
),
),
],
),
],
const SizedBox(height: AppSizes.p16),
],
const SectionLabel(label: 'ENROLLMENT DETAILS'),
@@ -70,19 +70,21 @@ class OcrEnrollmentValidationModal extends ConsumerStatefulWidget {
}
/// Add-student mode: no API writes. Returns the accepted, edited
/// enrollment, or null when the user rejects the record.
static Future<OcrEnrollmentPrefill?> showForAddStudent(
/// enrollments, or an empty list when the user rejects/closes without
/// accepting anything.
static Future<List<OcrEnrollmentPrefill>> showForAddStudent(
BuildContext context, {
required OcrEnrollmentRecord record,
}) {
return showDialog<OcrEnrollmentPrefill>(
required List<OcrEnrollmentRecord> records,
}) async {
final result = await showDialog<List<OcrEnrollmentPrefill>>(
context: context,
barrierDismissible: true,
builder: (_) => OcrEnrollmentValidationModal._(
records: [record],
records: records,
mode: _ValidationMode.addStudent,
),
);
return result ?? const [];
}
@override
@@ -94,6 +96,7 @@ class _OcrEnrollmentValidationModalState
extends ConsumerState<OcrEnrollmentValidationModal> {
late List<_Entry> _entries;
int _acceptedCount = 0;
final List<OcrEnrollmentPrefill> _acceptedPrefills = [];
int? _acceptingIndex;
bool get _isEditMode => widget._mode == _ValidationMode.editStudent;
@@ -192,7 +195,10 @@ class _OcrEnrollmentValidationModalState
entry.record.section.isEmpty ? 'Section' : entry.record.section,
schoolYear: year?.yearRange ?? entry.record.schoolYear,
);
Navigator.of(context).pop(prefill);
setState(() {
_acceptedPrefills.add(prefill);
_entries.removeAt(index);
});
return;
}
@@ -224,7 +230,11 @@ class _OcrEnrollmentValidationModalState
}
void _close() {
Navigator.of(context).pop(_isEditMode ? _acceptedCount : null);
Navigator.of(context).pop(
_isEditMode
? _acceptedCount
: List<OcrEnrollmentPrefill>.from(_acceptedPrefills),
);
}
InputDecoration _deco(String label) {
@@ -247,7 +257,8 @@ class _OcrEnrollmentValidationModalState
final sectionsAsync = ref.watch(sectionsListProvider);
final years = yearsAsync.asData?.value ?? <AcademicYearModel>[];
final allSections = sectionsAsync.asData?.value ?? <SectionModel>[];
final initialCount = _entries.length + _acceptedCount;
final initialCount =
_entries.length + (_isEditMode ? _acceptedCount : _acceptedPrefills.length);
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@@ -320,7 +331,7 @@ class _OcrEnrollmentValidationModalState
Text(
_isEditMode
? '$_acceptedCount of $initialCount accepted'
: 'Accept to add this enrollment',
: '${_acceptedPrefills.length} of $initialCount accepted',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,