feat: archives file menu, copy (1) naming, excel to pdf, and OCR enrollment validation

- archives: per-file menu (preview/print/download) via icon, right-click, long-press
- documents: copy renames to "name (1).ext" with incrementing collision handling
- documents: convert xls/xlsx/csv to multi-sheet PDF saved in student folder (excel kept)
- students: OCR enrollment scan is review-only with 7-12 grade gate and manual validation modal
- add student: linear OCR progress, SF9/SF10 grade 7-12 validation with auto-fill enrollment
This commit is contained in:
ssbg04 committed 2026-08-08 00:57:04 +08:00
1 parent 2ac3a791ce
commit c5998468b7
13 files changed
+1242 -180

No files matched your search

+20 -2
View File
@@ -644,7 +644,15 @@ exports.copyDocument = (req, res) => {
const ext = path.extname(doc.file_name);
const baseName = path.basename(doc.file_name, ext);
const newFileName = `${baseName} - Copy${ext}`;
const existingNames = db.prepare(
'SELECT file_name FROM documents WHERE student_id = ? AND deleted_at IS NULL'
).all(doc.student_id).map((r) => r.file_name);
let counter = 1;
let newFileName = `${baseName} (${counter})${ext}`;
while (existingNames.includes(newFileName)) {
counter++;
newFileName = `${baseName} (${counter})${ext}`;
}
const dir = path.dirname(originalPath);
const newFilePath = path.join(dir, `${Date.now()}-${newFileName.replace(/[^a-zA-Z0-9.-]/g, '_')}`);
@@ -819,6 +827,7 @@ exports.bulkCopy = (req, res) => {
INSERT INTO documents (student_id, requirement_id, file_name, file_path, document_type, status, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const usedNames = new Set();
for (const id of ids) {
const doc = getStmt.get(id);
@@ -843,7 +852,16 @@ exports.bulkCopy = (req, res) => {
if (fs.existsSync(originalPath)) {
const ext = path.extname(doc.file_name);
const baseName = path.basename(doc.file_name, ext);
const newFileName = `${baseName} - Copy${ext}`;
const existingNames = db.prepare(
'SELECT file_name FROM documents WHERE student_id = ? AND deleted_at IS NULL'
).all(doc.student_id).map((r) => r.file_name);
let counter = 1;
let newFileName = `${baseName} (${counter})${ext}`;
while (existingNames.includes(newFileName) || usedNames.has(newFileName)) {
counter++;
newFileName = `${baseName} (${counter})${ext}`;
}
usedNames.add(newFileName);
const dir = path.dirname(originalPath);
const newFilePath = path.join(dir, `${Date.now()}-${newFileName.replace(/[^a-zA-Z0-9.-]/g, '_')}`);
+28 -25
View File
@@ -888,45 +888,48 @@ exports.scanEnrollmentFromSF = async (req, res) => {
}
const sfEnrollmentService = require('../services/sfEnrollmentService');
let lastResult = null;
let successCount = 0;
let results = [];
let aggregatedRecords = [];
const seenKey = new Set();
const records = [];
let lastExtracted = null;
let lastError = null;
for (const doc of docs) {
const resData = await sfEnrollmentService.autoEnrollFromSF({
studentId,
const result = await sfEnrollmentService.extractEnrollmentData({
file: { path: doc.file_path, originalname: doc.file_name },
documentType: doc.document_type,
userId: req.user?.id,
manual: true
});
results.push(resData);
if (resData.success) {
successCount++;
lastResult = resData;
if (Array.isArray(resData.allRecords)) {
aggregatedRecords.push(...resData.allRecords);
if (!result.ok) {
lastError = result.message;
continue;
}
}
}
if (successCount === 0) {
return res.status(400).json({
success: false,
message: lastResult?.message || 'Failed to extract Academic Year, Grade Level, or Section from the scanned SF10/SF9 documents.',
details: results
lastExtracted = result.extracted;
for (const rec of result.uniqueRecords) {
const gradeNum = parseInt(String(rec.gradeLevel).replace(/\D/g, ''), 10);
const key = `${gradeNum}_${(rec.schoolYear || '').trim()}_${(rec.section || '').trim()}`.toLowerCase();
if (seenKey.has(key)) continue;
seenKey.add(key);
records.push({
gradeLevel: String(gradeNum),
section: (rec.section || '').trim(),
schoolYear: (rec.schoolYear || '').trim(),
adviserName: rec.adviserName || '',
semester: rec.semester || ''
});
}
}
if (aggregatedRecords.length > 0) {
lastResult.allRecords = aggregatedRecords;
if (records.length === 0) {
return res.status(400).json({
success: false,
message: lastError || 'No Grade 7-12 enrollment records could be extracted from the SF10/SF9 documents.'
});
}
return res.json({
success: true,
message: `Successfully scanned SF10/SF9 and processed ${aggregatedRecords.length || 1} enrollment record(s).`,
data: lastResult
message: `Scanned ${records.length} enrollment record(s). Review before saving.`,
data: { extracted: lastExtracted, records }
});
} catch (error) {
console.error('scanEnrollmentFromSF error:', error);
+101 -82
View File
@@ -31,6 +31,101 @@ const findTeacherUser = (db, adviserName) => {
return null;
};
// Extract and parse enrollment data from an SF9/SF10 document without writing to the database.
// Records are gated to Grade 7-12.
exports.extractEnrollmentData = async ({ file, documentType = '', manual = false }) => {
// 1. Determine if SF10 or SF9
const docTypeStr = String(documentType || '');
const fileNameStr = String(file?.originalname || file?.name || '');
const isSF10 = /sf10|sf-10|sf 10|permanent record|form 10/i.test(docTypeStr) ||
/sf10|sf-10|sf 10|permanent record|form 10/i.test(fileNameStr);
const isSF9 = /sf9|sf-9|sf 9|report card|form 9/i.test(docTypeStr) ||
/sf9|sf-9|sf 9|report card|form 9/i.test(fileNameStr);
if (!isSF10 && !isSF9 && !manual) {
return {
ok: false,
skipped: true,
message: 'Document is not an SF10 or SF9.'
};
}
if (!file || !file.path || !fs.existsSync(file.path)) {
return {
ok: false,
message: 'File path not found for OCR scanning.'
};
}
// 2. Extract text from PDF, Image, or Excel
let text = '';
try {
text = await ocrParser.extractTextFromFile(file.path, file.originalname || '', file.mimetype || '');
} catch (err) {
console.error('[sfEnrollmentService] OCR/Excel text extraction error:', err.message);
return {
ok: false,
message: 'Failed to extract text from document: ' + err.message
};
}
if (!text || text.trim().length === 0) {
return {
ok: false,
message: 'No readable text extracted from document.'
};
}
// 3. Parse text using SF10 or SF9 parser
const docLabel = isSF10 ? 'SF10' : (isSF9 ? 'SF9' : 'SF Document');
let extracted = isSF10 ? ocrParser.parseSF10(text) : ocrParser.parseSF9(text);
if (!extracted.schoolYear && !extracted.gradeLevel && !extracted.section && !isSF10) {
// Fallback check: if we tried SF9 and got nothing, try SF10 just in case
extracted = ocrParser.parseSF10(text);
} else if (!extracted.schoolYear && !extracted.gradeLevel && !extracted.section && isSF10) {
extracted = ocrParser.parseSF9(text);
}
const { schoolYear, gradeLevel, section, trackStrand } = extracted;
// 4. Gather all records to enroll (use deduplicated scholasticRecords or fallback to single extracted)
let recordsToEnroll = Array.isArray(extracted.scholasticRecords) && extracted.scholasticRecords.length > 0
? extracted.scholasticRecords
: [{
gradeLevel: extracted.gradeLevel,
section: extracted.section,
schoolYear: extracted.schoolYear,
adviserName: extracted.adviserName || '',
semester: extracted.semester || '',
trackStrand: extracted.trackStrand || null
}];
// Deduplicate: if 2 sem is the same, do not duplicate. Only Grade 7-12 records are accepted.
const uniqueRecords = [];
const seenSemKey = new Set();
for (const rec of recordsToEnroll) {
const gradeNum = parseInt(String(rec.gradeLevel).replace(/\D/g, ''), 10);
if (isNaN(gradeNum) || gradeNum < 7 || gradeNum > 12 || !rec.section) continue;
const syStr = (rec.schoolYear || '').trim();
const secName = (rec.section || '').trim();
const dedupeKey = `${gradeNum}_${syStr}_${secName}`.toLowerCase();
if (!seenSemKey.has(dedupeKey)) {
seenSemKey.add(dedupeKey);
uniqueRecords.push(rec);
}
}
if (uniqueRecords.length === 0) {
return {
ok: false,
message: `Could not extract a Grade 7-12 enrollment from ${docLabel}. Found -> Grade: "${gradeLevel || ''}", Section: "${section || ''}"`
};
}
return { ok: true, isSF10, isSF9, docLabel, extracted, uniqueRecords };
};
exports.autoEnrollFromSF = async ({ studentId, file, documentType = '', userId = null, manual = false }) => {
// 1. If not manual, check toggle setting in system_settings
if (!manual) {
@@ -44,95 +139,19 @@ exports.autoEnrollFromSF = async ({ studentId, file, documentType = '', userId =
}
}
// 2. Determine if SF10 or SF9
const docTypeStr = String(documentType || '');
const fileNameStr = String(file?.originalname || file?.name || '');
const isSF10 = /sf10|sf-10|sf 10|permanent record|form 10/i.test(docTypeStr) ||
/sf10|sf-10|sf 10|permanent record|form 10/i.test(fileNameStr);
const isSF9 = /sf9|sf-9|sf 9|report card|form 9/i.test(docTypeStr) ||
/sf9|sf-9|sf 9|report card|form 9/i.test(fileNameStr);
if (!isSF10 && !isSF9 && !manual) {
// 2. Extract + parse records (Grade 7-12 only, no writes here)
const data = await exports.extractEnrollmentData({ file, documentType, manual });
if (!data.ok) {
return {
success: false,
skipped: true,
message: 'Document is not an SF10 or SF9.'
skipped: data.skipped || false,
message: data.message
};
}
if (!file || !file.path || !fs.existsSync(file.path)) {
return {
success: false,
message: 'File path not found for OCR scanning.'
};
}
// 3. Extract text from PDF, Image, or Excel
let text = '';
try {
text = await ocrParser.extractTextFromFile(file.path, file.originalname || '', file.mimetype || '');
} catch (err) {
console.error('[sfEnrollmentService] OCR/Excel text extraction error:', err.message);
return {
success: false,
message: 'Failed to extract text from document: ' + err.message
};
}
if (!text || text.trim().length === 0) {
return {
success: false,
message: 'No readable text extracted from document.'
};
}
// 4. Parse text using SF10 or SF9 parser
const docLabel = isSF10 ? 'SF10' : (isSF9 ? 'SF9' : 'SF Document');
let extracted = isSF10 ? ocrParser.parseSF10(text) : ocrParser.parseSF9(text);
if (!extracted.schoolYear && !extracted.gradeLevel && !extracted.section && !isSF10) {
// Fallback check: if we tried SF9 and got nothing, try SF10 just in case
extracted = ocrParser.parseSF10(text);
} else if (!extracted.schoolYear && !extracted.gradeLevel && !extracted.section && isSF10) {
extracted = ocrParser.parseSF9(text);
}
const { docLabel, extracted, uniqueRecords } = data;
const { schoolYear, gradeLevel, section, trackStrand } = extracted;
// 5. Gather all records to enroll (use deduplicated scholasticRecords or fallback to single extracted)
let recordsToEnroll = Array.isArray(extracted.scholasticRecords) && extracted.scholasticRecords.length > 0
? extracted.scholasticRecords
: [{
gradeLevel: extracted.gradeLevel,
section: extracted.section,
schoolYear: extracted.schoolYear,
adviserName: extracted.adviserName || '',
semester: extracted.semester || '',
trackStrand: extracted.trackStrand || null
}];
// Deduplicate: if 2 sem is the same, do not duplicate
const uniqueRecords = [];
const seenSemKey = new Set();
for (const rec of recordsToEnroll) {
const gradeNum = parseInt(String(rec.gradeLevel).replace(/\D/g, ''), 10);
if (isNaN(gradeNum) || gradeNum <= 0 || !rec.section) continue;
const syStr = (rec.schoolYear || '').trim();
const secName = (rec.section || '').trim();
const dedupeKey = `${gradeNum}_${syStr}_${secName}`.toLowerCase();
if (!seenSemKey.has(dedupeKey)) {
seenSemKey.add(dedupeKey);
uniqueRecords.push(rec);
}
}
if (uniqueRecords.length === 0) {
return {
success: false,
message: `Could not extract Grade Level and Section from ${docLabel}. Found -> Grade: "${gradeLevel || ''}", Section: "${section || ''}"`
};
}
let lastAction = '';
let processedRecords = [];
+20 -10
View File
@@ -88,7 +88,7 @@ Files backup/restore - mirror mode or zip -->
<!-- ## backend server backup/restore
- do not use zip and unzip -->
# ANDROID APP
<!-- # ANDROID APP
# DOCUMENT SCREEN
- add back prevention active multi select
@@ -115,9 +115,7 @@ Files backup/restore - mirror mode or zip -->
- 1st back, navigates back folder 'Student Folders' tab
- 2nd back, navigates to dashboard screen
- if the open folder is not present
- 1st back, navigate to dashboard
- 1st back, navigate to dashboard -->
<!-- # DOCUMENTS SCREEN
1. apply dark mode
@@ -130,6 +128,23 @@ Files backup/restore - mirror mode or zip -->
- fix searched field didnt hide when searched an user -->
# ARCHIVES SCREEN
- add files menu for print, download, hold for android app, right click for windows app, add icon menu for easy access
# DOCUMENTS SCREEN
- copy file rename format same as orginal file name add (1) or incrementing number if name already exist
- add new feature -> excel to pdf: all tabs export into one pdf, hold/right click, or icon menu for excel files then make a convertion but in pdf do not delete the excel file
# STUDENT SCREEN
1. edit student screen -> enrollments tab -> auto enrollment OCR:
- do not add in database if not 7-12 grade level detect
- add manual validation to add year level, grade level, section before saving to database
- in manual validation add actions accept, decline, or delete, use only icons.
- for icon only icons no container, no background, no border
2. Add new student screen:
- processing OCR remove the circle loading
- auto add enrollment if the OCR scan uploaded is SF9/SF10
---
# TIS_RMS Server Manager (dont do)
@@ -148,13 +163,8 @@ Windows Service - NSSM nodejs
---
## (dont do) add some features
- archives screen, add files menu for print, download
- copy files name
- auto enrollment OCR do not add in database if not 7-12 grade level detect
- auto add enrollment in add student if the OCR upload is SF9/SF10
- dark mode -> ongoing
- documents screen
- excel to pdf: all tabs export into one pdf
---
@@ -0,0 +1,150 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:excel/excel.dart' hide Border;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
/// Converts an Excel workbook into a single multi-page PDF — one page (or
/// chunk of pages) per sheet tab, rendered as a bordered grid.
Future<Uint8List> convertExcelBytesToPdf(
List<int> bytes, {
int maxRows = 500,
int maxCols = 30,
}) async {
final excel = Excel.decodeBytes(bytes);
final doc = pw.Document();
final sheetNames = excel.tables.keys.toList();
if (sheetNames.isEmpty) {
doc.addPage(_noDataPage('Workbook contains no sheets.'));
return doc.save();
}
for (final name in sheetNames) {
final sheet = excel.tables[name];
if (sheet == null || sheet.rows.isEmpty) continue;
final rows = _extractSheetRows(sheet, maxRows, maxCols);
if (rows.isEmpty) continue;
final colCount = rows
.map((r) => r.length)
.fold<int>(0, (acc, len) => len > acc ? len : acc);
const rowsPerPage = 60;
final pageCount = (rows.length / rowsPerPage).ceil();
for (var start = 0; start < rows.length; start += rowsPerPage) {
final end = math.min(start + rowsPerPage, rows.length);
final pageNo = start ~/ rowsPerPage + 1;
doc.addPage(
pw.Page(
pageFormat: PdfPageFormat.legal,
margin: const pw.EdgeInsets.all(16),
build: (_) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
name,
style: pw.TextStyle(
fontSize: 12,
fontWeight: pw.FontWeight.bold,
color: PdfColors.green700,
),
),
if (pageCount > 1) ...[
pw.SizedBox(height: 2),
pw.Text(
'Page $pageNo of $pageCount',
style: pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
],
pw.SizedBox(height: 6),
pw.Table(
border: pw.TableBorder.all(
color: PdfColors.grey400,
width: 0.4,
),
columnWidths: {
for (var c = 0; c < colCount; c++)
c: pw.FlexColumnWidth(1),
},
children: _buildChunkRows(start, end, rows, colCount),
),
],
),
),
);
}
}
return doc.save();
}
pw.Page _noDataPage(String message) {
return pw.Page(
pageFormat: PdfPageFormat.legal,
build: (_) => pw.Center(
child: pw.Text(message, style: const pw.TextStyle(fontSize: 12)),
),
);
}
List<List<String>> _extractSheetRows(Sheet sheet, int maxRows, int maxCols) {
final rows = <List<String>>[];
for (var r = 0; r < sheet.rows.length && r < maxRows; r++) {
final row = sheet.rows[r];
final rowCells = <String>[];
final colLimit = math.min(row.length, maxCols);
for (var c = 0; c < colLimit; c++) {
rowCells.add(_cellToString(row[c]));
}
if (rowCells.any((v) => v.trim().isNotEmpty)) {
rows.add(rowCells);
}
}
return rows;
}
String _cellToString(Data? cell) {
if (cell == null || cell.value == null) return '';
final val = cell.value;
try {
if (val is TextCellValue) return val.value.toString();
if (val is IntCellValue) return val.value.toString();
if (val is DoubleCellValue) return val.value.toString();
if (val is BoolCellValue) return val.value ? 'TRUE' : 'FALSE';
return val.toString();
} catch (_) {
return val.toString();
}
}
List<pw.TableRow> _buildChunkRows(
int start,
int end,
List<List<String>> rows,
int colCount,
) {
final out = <pw.TableRow>[];
for (var r = start; r < end; r++) {
final row = rows[r];
final cells = <pw.Widget>[];
for (var c = 0; c < colCount; c++) {
cells.add(_cellWidget(c < row.length ? row[c] : '', bold: r == 0));
}
out.add(pw.TableRow(children: cells));
}
return out;
}
pw.Widget _cellWidget(String text, {required bool bold}) {
return pw.Container(
padding: const pw.EdgeInsets.all(2),
child: pw.Text(
text,
style: pw.TextStyle(
fontSize: 7,
fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal,
),
),
);
}
@@ -1,5 +1,6 @@
import 'dart:io';
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../core/network/api_constants.dart';
@@ -321,6 +322,34 @@ class DocumentRepository {
}
}
/// Uploads in-memory bytes (e.g. a converted PDF) as a new document in the
/// student's folder.
Future<void> uploadDocumentBytes({
required int studentId,
required String documentType,
int? requirementId,
required String fileName,
required Uint8List bytes,
}) async {
try {
final options = await _getAuthOptions();
final formData = FormData.fromMap({
'studentId': studentId,
'documentType': documentType,
if (requirementId != null) 'requirementId': requirementId,
'document': MultipartFile.fromBytes(
bytes,
filename: fileName,
contentType: DioMediaType('application', 'pdf'),
),
});
await _dio.post('/documents/upload', data: formData, options: options);
} on DioException catch (e) {
final msg = e.response?.data?['message'] ?? 'Failed to upload document.';
throw Exception(msg);
}
}
Future<void> bulkDeleteDocuments(List<int> ids) async {
try {
final options = await _getAuthOptions();
@@ -35,6 +35,33 @@ class StudentPage {
}
}
/// A single enrollment record extracted from an SF9/SF10 OCR scan.
class OcrEnrollmentRecord {
final String gradeLevel;
final String section;
final String schoolYear;
final String adviserName;
final String semester;
const OcrEnrollmentRecord({
required this.gradeLevel,
required this.section,
required this.schoolYear,
this.adviserName = '',
this.semester = '',
});
factory OcrEnrollmentRecord.fromJson(Map<String, dynamic> json) {
return OcrEnrollmentRecord(
gradeLevel: (json['gradeLevel'] as String?) ?? '',
section: (json['section'] as String?) ?? '',
schoolYear: (json['schoolYear'] as String?) ?? '',
adviserName: (json['adviserName'] as String?) ?? '',
semester: (json['semester'] as String?) ?? '',
);
}
}
class StudentRepository {
final Dio _dio = ApiConstants.createDio();
final FlutterSecureStorage _storage = const FlutterSecureStorage();
@@ -441,14 +468,18 @@ class StudentRepository {
// ----------------------------------------------------------------
// Manual SF9/SF10 OCR Scan Enrollment
// ----------------------------------------------------------------
Future<Map<String, dynamic>> scanEnrollmentFromSF(int studentId) async {
Future<List<OcrEnrollmentRecord>> scanEnrollmentFromSF(int studentId) async {
try {
final options = await _getAuthOptions();
final response = await _dio.post(
'/students/$studentId/ocr-enrollment',
options: options,
);
return response.data as Map<String, dynamic>;
final data = response.data as Map<String, dynamic>;
final records = (data['data'] as Map<String, dynamic>?)?['records'] as List?;
return (records ?? [])
.map((r) => OcrEnrollmentRecord.fromJson(r as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
final msg = e.response?.data?['message'] ?? 'Failed to scan SF10/SF9 for enrollment.';
throw Exception(msg);
@@ -466,14 +466,12 @@ class StudentMutationNotifier extends AsyncNotifier<void> {
}
}
Future<Map<String, dynamic>> scanEnrollmentFromSF(int studentId) async {
Future<List<OcrEnrollmentRecord>> scanEnrollmentFromSF(int studentId) async {
state = const AsyncLoading();
try {
final repo = ref.read(studentRepositoryProvider);
final result = await repo.scanEnrollmentFromSF(studentId);
state = const AsyncData(null);
ref.invalidate(studentDetailProvider(studentId));
ref.invalidate(studentPageProvider);
return result;
} catch (e, st) {
state = AsyncError(e, st);
@@ -17,6 +17,10 @@ import '../../shared/inputs/custom_text_field.dart';
import '../../providers/auth_provider.dart';
import '../../providers/navigation_provider.dart';
import '../documents/widgets/document_preview_modal.dart';
import '../documents/widgets/print_queue_modal.dart';
import '../../../core/utils/download_service.dart';
import '../../../core/network/api_constants.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class ArchivesScreen extends ConsumerStatefulWidget {
final String userRole;
@@ -436,6 +440,104 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
showDocumentPreview(context: context, document: doc);
}
// ── Document file menu (print, download, preview) ──────────────
Future<void> _handleDocumentAction(String action, DocumentModel doc) async {
if (action == 'preview') {
_handlePreview(doc);
} else if (action == 'print') {
try {
await ref.read(printQueueMutationProvider.notifier).addToQueue(doc.id);
} catch (_) {
// Already in the print list - still open the queue.
}
if (!mounted) return;
PrintQueueModal.show(context);
} else if (action == 'download') {
try {
final token =
await const FlutterSecureStorage().read(key: 'jwt_token');
if (token == null) return;
final url =
'${ApiConstants.baseUrl}/documents/${doc.id}/view?token=$token&download=true';
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Download started...'),
duration: Duration(seconds: 2),
),
);
await DownloadService.downloadFile(
url: url,
fileName: doc.fileName,
);
if (!context.mounted) return;
showSuccessDialog(
context,
message: 'Document downloaded successfully.',
);
} catch (e) {
if (!context.mounted) return;
showErrorDialog(context, 'Download Failed', e.toString());
}
}
}
List<PopupMenuEntry<String>> _buildDocumentMenuItems() {
return const [
PopupMenuItem(
value: 'preview',
child: Row(
children: [
Icon(Icons.visibility_outlined, size: 18),
SizedBox(width: 12),
Text('Preview', style: TextStyle(fontSize: 14)),
],
),
),
PopupMenuItem(
value: 'print',
child: Row(
children: [
Icon(Icons.print, size: 18),
SizedBox(width: 12),
Text('Print', style: TextStyle(fontSize: 14)),
],
),
),
PopupMenuItem(
value: 'download',
child: Row(
children: [
Icon(Icons.download, size: 18),
SizedBox(width: 12),
Text('Download', style: TextStyle(fontSize: 14)),
],
),
),
];
}
void _showDocumentContextMenu(
BuildContext context,
Offset position,
DocumentModel doc,
) {
if (_isMultiSelectMode) return;
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
showMenu<String>(
context: context,
position: RelativeRect.fromRect(
position & const Size(40, 40),
Offset.zero & overlay.size,
),
items: _buildDocumentMenuItems(),
).then((value) {
if (value != null) _handleDocumentAction(value, doc);
});
}
@override
Widget build(BuildContext context) {
final query = ref.watch(archiveDocumentQueryProvider);
@@ -1669,7 +1771,10 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
Widget _buildDesktopListRow(DocumentModel doc) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final isSelected = _selectedDocumentIds.contains(doc.id);
return InkWell(
return GestureDetector(
onSecondaryTapDown: (details) =>
_showDocumentContextMenu(context, details.globalPosition, doc),
child: InkWell(
onTap: () {
if (_isMultiSelectMode) {
setState(() {
@@ -1770,25 +1875,35 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
),
),
Expanded(child: _buildStatusChip(doc.status)),
if (!_isMultiSelectMode)
SizedBox(
width: 40,
child: IconButton(
icon: const Icon(Icons.visibility_outlined, size: 18),
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
tooltip: 'Preview',
onPressed: () => _handlePreview(doc),
child: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 18),
iconColor: isDark
? AppColors.darkTextSecondary
: AppColors.textSecondary,
padding: EdgeInsets.zero,
onSelected: (a) => _handleDocumentAction(a, doc),
itemBuilder: (_) => _buildDocumentMenuItems(),
),
),
],
),
),
),
);
}
Widget _buildMobileListRow(DocumentModel doc) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final isSelected = _selectedDocumentIds.contains(doc.id);
return InkWell(
return GestureDetector(
onSecondaryTapDown: (details) =>
_showDocumentContextMenu(context, details.globalPosition, doc),
onLongPressStart: (details) =>
_showDocumentContextMenu(context, details.globalPosition, doc),
child: InkWell(
onTap: () {
if (_isMultiSelectMode) {
setState(() {
@@ -1882,18 +1997,23 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildStatusChip(doc.status),
IconButton(
icon: const Icon(Icons.visibility_outlined, size: 20),
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
if (!_isMultiSelectMode)
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 20),
iconColor: isDark
? AppColors.darkTextSecondary
: AppColors.textSecondary,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _handlePreview(doc),
onSelected: (a) => _handleDocumentAction(a, doc),
itemBuilder: (_) => _buildDocumentMenuItems(),
),
],
),
],
),
),
),
);
}
@@ -1925,7 +2045,12 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
itemBuilder: (ctx, i) {
final doc = documents[i];
final isSelected = _selectedDocumentIds.contains(doc.id);
return InkWell(
return GestureDetector(
onSecondaryTapDown: (details) =>
_showDocumentContextMenu(ctx, details.globalPosition, doc),
onLongPressStart: (details) =>
_showDocumentContextMenu(ctx, details.globalPosition, doc),
child: InkWell(
onTap: () {
if (_isMultiSelectMode) {
setState(() {
@@ -1940,7 +2065,9 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
}
},
borderRadius: BorderRadius.circular(12),
child: Container(
child: Stack(
children: [
Container(
decoration: BoxDecoration(
color: isSelected
? AppColors.primaryGreen.withValues(alpha: 0.08)
@@ -2019,6 +2146,24 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
],
),
),
if (!_isMultiSelectMode)
Positioned(
top: 4,
right: 4,
child: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 16),
iconColor: isDark
? AppColors.darkTextSecondary
: AppColors.textSecondary,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onSelected: (a) => _handleDocumentAction(a, doc),
itemBuilder: (_) => _buildDocumentMenuItems(),
),
),
],
),
),
);
},
),
@@ -27,6 +27,7 @@ import '../../../core/network/api_constants.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'widgets/recycle_bin_modal.dart';
import '../../../domain/entities/document_model.dart';
import '../../../core/utils/excel_to_pdf_converter.dart';
class DocumentsScreen extends ConsumerStatefulWidget {
final String userRole;
@@ -306,6 +307,8 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
if (!mounted) return;
showErrorDialog(context, 'Copy Failed', e.toString());
}
} else if (action == 'convert_pdf') {
await _handleConvertToPdf(document);
} else if (action == 'preview') {
showDocumentPreview(context: context, document: document);
} else if (action == 'download') {
@@ -337,6 +340,52 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
}
}
bool _isExcelFile(DocumentModel doc) {
final name = doc.fileName.toLowerCase();
return name.endsWith('.xls') || name.endsWith('.xlsx') || name.endsWith('.csv');
}
Future<void> _handleConvertToPdf(DocumentModel doc) async {
if (doc.studentId == null) {
showErrorDialog(context, 'Conversion Failed', 'No student folder found for this document.');
return;
}
try {
final bytes =
await ref.read(documentRepositoryProvider).downloadDocumentBytes(doc.id);
if (!mounted) return;
final pdfBytes = await convertExcelBytesToPdf(bytes);
if (pdfBytes.length > 10 * 1024 * 1024) {
showErrorDialog(
context,
'Conversion Failed',
'Converted PDF exceeds the 10MB upload limit.',
);
return;
}
final baseName = doc.fileName.replaceFirst(
RegExp(r'\.(xls|xlsx|csv)$', caseSensitive: false),
'',
);
await ref.read(documentRepositoryProvider).uploadDocumentBytes(
studentId: doc.studentId!,
documentType: doc.documentType ?? 'Document',
requirementId: doc.requirementId,
fileName: '$baseName.pdf',
bytes: pdfBytes,
);
if (!mounted) return;
ref.invalidate(documentPageProvider);
showSuccessDialog(
context,
message: 'Converted to PDF and saved in the student folder.',
);
} catch (e) {
if (!mounted) return;
showErrorDialog(context, 'Conversion Failed', e.toString());
}
}
void _confirmDelete(int id) {
showDialog(
context: context,
@@ -2836,6 +2885,18 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
],
),
),
if (_isExcelFile(doc as DocumentModel)) ...[
const PopupMenuItem(
value: 'convert_pdf',
child: Row(
children: [
Icon(Icons.picture_as_pdf, size: 16),
SizedBox(width: 10),
Text('Convert to PDF', style: TextStyle(fontSize: 13)),
],
),
),
],
const PopupMenuDivider(),
const PopupMenuItem(
value: 'view_profile',
@@ -3137,18 +3198,23 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
],
),
),
const PopupMenuDivider(),
];
if (_isExcelFile(doc)) {
items.insert(
4,
const PopupMenuItem(
value: 'view_profile',
value: 'convert_pdf',
child: Row(
children: [
Icon(Icons.person, size: 18, color: AppColors.primaryGreen),
Icon(Icons.picture_as_pdf, size: 18),
SizedBox(width: 12),
Text('View Student Profile', style: TextStyle(fontSize: 14)),
Text('Convert to PDF', style: TextStyle(fontSize: 14)),
],
),
),
];
);
}
if (widget.userRole != 'teacher') {
items.add(const PopupMenuDivider());
@@ -6,12 +6,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../domain/entities/setup_models.dart';
import '../../../../domain/entities/ocr_result_model.dart';
import '../../../../domain/repositories/student_repository.dart';
import '../../../shared/buttons/primary_button.dart';
import '../../../providers/setup_provider.dart';
import '../../../providers/ocr_provider.dart';
import '../../../providers/student_provider.dart';
import '../../documents/widgets/document_preview_modal.dart';
import '../../../shared/inputs/document_source_picker.dart';
import 'ocr_enrollment_validation_modal.dart';
import 'student_form_helpers.dart';
class AddStudentModal extends ConsumerStatefulWidget {
@@ -43,6 +46,10 @@ 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;
File? _ocrScannedFile;
String? _selectedOcrDocType;
@@ -296,6 +303,8 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
_selectedOcrDocType = docType;
_currentStep = 1;
});
await _offerOcrEnrollment(ocrResult, docType);
} catch (e) {
if (!mounted) return;
final raw = e.toString();
@@ -308,6 +317,37 @@ 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.
Future<void> _offerOcrEnrollment(
OcrResultModel ocrResult,
String docType,
) async {
if (docType != 'SF9' && docType != 'SF10') return;
final gradeNum = int.tryParse(ocrResult.gradeLevel.replaceAll(RegExp(r'\D'), ''));
if (gradeNum == null || gradeNum < 7 || gradeNum > 12) return;
if (!mounted) return;
final prefill = await OcrEnrollmentValidationModal.showForAddStudent(
context,
record: OcrEnrollmentRecord(
gradeLevel: gradeNum.toString(),
section: ocrResult.section,
schoolYear: ocrResult.schoolYear,
),
);
if (!mounted || prefill == null) return;
setState(() {
_ocrSavedEnrollment = prefill;
_selectedAcademicYearId = prefill.academicYearId;
_selectedGradeLevel = prefill.gradeLevel;
_selectedSectionId = prefill.sectionId;
});
}
String? _validateLRN(String? value) {
if (value == null || value.trim().isEmpty) return 'LRN is required.';
if (!RegExp(r'^\d{12}$').hasMatch(value.trim())) {
@@ -414,12 +454,19 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
final isDark = Theme.of(context).brightness == Brightness.dark;
if (_isLoading) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSizes.p32),
child: Center(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: AppSizes.p32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
color: AppColors.primaryGreen,
backgroundColor:
isDark ? AppColors.darkSurface2 : const Color(0xFFE0E0E0),
minHeight: 8,
),
),
const SizedBox(height: AppSizes.p16),
Text(
'Processing document with OCR... Please wait',
@@ -431,7 +478,6 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
),
],
),
),
);
}
return Column(
@@ -740,6 +786,40 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_ocrSavedEnrollment != null) ...[
Container(
padding: const EdgeInsets.all(AppSizes.p12),
decoration: BoxDecoration(
color: AppColors.primaryGreen.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(AppSizes.radiusMedium),
border: Border.all(
color: AppColors.primaryGreen.withValues(alpha: 0.25),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.check_circle_outline,
size: 18,
color: AppColors.primaryGreen,
),
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.',
style: const TextStyle(fontSize: 13),
),
),
],
),
),
const SizedBox(height: AppSizes.p16),
],
const SectionLabel(label: 'ENROLLMENT DETAILS'),
const SizedBox(height: AppSizes.p16),
yearsAsync.when(
@@ -942,7 +1022,7 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
totalSteps: 3,
currentStep: _currentStep,
),
if (_isLoading)
if (_isLoading && _currentStep != 0)
const LinearProgressIndicator(
color: AppColors.primaryGreen,
backgroundColor: Color(0xFFE0E0E0),
@@ -11,6 +11,7 @@ import '../../../shared/dialogs/success_dialog.dart';
import '../../../shared/dialogs/error_dialog.dart';
import '../../../providers/student_provider.dart';
import 'edit_enrollment_modal.dart';
import 'ocr_enrollment_validation_modal.dart';
import 'student_form_helpers.dart';
class EditStudentModal extends ConsumerStatefulWidget {
@@ -40,6 +41,7 @@ class _EditStudentModalState extends ConsumerState<EditStudentModal> {
late bool _is4ps;
bool _isLoading = false;
bool _isOcrScanning = false;
String? _errorMessage;
late StudentModel _initialStudent;
@@ -575,50 +577,48 @@ class _EditStudentModalState extends ConsumerState<EditStudentModal> {
}
Future<void> _handleScanEnrollmentFromSF() async {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Scanning SF9/SF10 for enrollment data...'),
],
),
),
),
),
);
setState(() => _isOcrScanning = true);
try {
final res = await ref
final records = await ref
.read(studentMutationProvider.notifier)
.scanEnrollmentFromSF(widget.student.id);
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
if (!mounted) return;
if (records.isEmpty) {
showErrorDialog(
context,
'OCR Scan Failed',
'No Grade 7-12 enrollment records were detected from the '
'SF9/SF10 documents.',
);
return;
}
final msg = res['message'] as String? ??
'Successfully scanned enrollment from SF10/SF9.';
final accepted = await OcrEnrollmentValidationModal.show(
context,
studentId: widget.student.id,
records: records,
);
if (!mounted) return;
if (mounted) {
await showSuccessDialog(context, message: msg);
ref.invalidate(studentDetailProvider(widget.student.id));
ref.invalidate(studentPageProvider);
if (accepted > 0) {
await showSuccessDialog(
context,
message: '$accepted enrollment record(s) added.',
);
}
} catch (e) {
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
if (!mounted) return;
final raw = e.toString();
final msg = raw.startsWith('Exception: ') ? raw.substring(11) : raw;
showErrorDialog(context, 'OCR Scan Failed', msg);
}
} finally {
if (mounted) setState(() => _isOcrScanning = false);
}
}
@@ -642,10 +642,20 @@ class _EditStudentModalState extends ConsumerState<EditStudentModal> {
Widget buildHeaderButtons({required bool expanded}) {
final ocrButton = OutlinedButton.icon(
onPressed: _handleScanEnrollmentFromSF,
icon: const Icon(Icons.document_scanner, size: 18),
onPressed: _isOcrScanning ? null : _handleScanEnrollmentFromSF,
icon: _isOcrScanning
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.document_scanner, size: 18),
label: Text(
isMobile ? 'OCR Scan' : 'OCR Scan (SF9/SF10)',
isMobile
? (_isOcrScanning ? 'Scanning...' : 'OCR Scan')
: (_isOcrScanning
? 'Scanning...'
: 'OCR Scan (SF9/SF10)'),
overflow: TextOverflow.ellipsis,
),
style: OutlinedButton.styleFrom(
@@ -0,0 +1,503 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../domain/entities/setup_models.dart';
import '../../../../domain/repositories/student_repository.dart';
import '../../../providers/setup_provider.dart';
import '../../../providers/student_provider.dart';
import '../../../shared/dialogs/error_dialog.dart';
/// Accepted enrollment chosen in add-student (prefill) mode.
class OcrEnrollmentPrefill {
final int academicYearId;
final int gradeLevel;
final int? sectionId;
final String sectionName;
final String schoolYear;
const OcrEnrollmentPrefill({
required this.academicYearId,
required this.gradeLevel,
required this.sectionName,
required this.schoolYear,
this.sectionId,
});
}
enum _ValidationMode { editStudent, addStudent }
class _Entry {
final OcrEnrollmentRecord record;
int? academicYearId;
int gradeLevel;
int? sectionId;
_Entry({
required this.record,
required this.gradeLevel,
});
}
class OcrEnrollmentValidationModal extends ConsumerStatefulWidget {
final int? studentId;
final List<OcrEnrollmentRecord> records;
final _ValidationMode _mode;
const OcrEnrollmentValidationModal._({
super.key,
this.studentId,
required this.records,
required _ValidationMode mode,
}) : _mode = mode;
/// Edit-student mode: accept writes the enrollment via the API.
/// Returns the number of accepted enrollments.
static Future<int> show(
BuildContext context, {
required int studentId,
required List<OcrEnrollmentRecord> records,
}) async {
final result = await showDialog<int>(
context: context,
barrierDismissible: false,
builder: (_) => OcrEnrollmentValidationModal._(
studentId: studentId,
records: records,
mode: _ValidationMode.editStudent,
),
);
return result ?? 0;
}
/// Add-student mode: no API writes. Returns the accepted, edited
/// enrollment, or null when the user rejects the record.
static Future<OcrEnrollmentPrefill?> showForAddStudent(
BuildContext context, {
required OcrEnrollmentRecord record,
}) {
return showDialog<OcrEnrollmentPrefill>(
context: context,
barrierDismissible: true,
builder: (_) => OcrEnrollmentValidationModal._(
records: [record],
mode: _ValidationMode.addStudent,
),
);
}
@override
ConsumerState<OcrEnrollmentValidationModal> createState() =>
_OcrEnrollmentValidationModalState();
}
class _OcrEnrollmentValidationModalState
extends ConsumerState<OcrEnrollmentValidationModal> {
late List<_Entry> _entries;
int _acceptedCount = 0;
int? _acceptingIndex;
bool get _isEditMode => widget._mode == _ValidationMode.editStudent;
@override
void initState() {
super.initState();
_entries = widget.records.map((r) {
final gradeNum = int.tryParse(r.gradeLevel.replaceAll(RegExp(r'\D'), ''));
return _Entry(
record: r,
gradeLevel: (gradeNum != null && gradeNum >= 7 && gradeNum <= 12)
? gradeNum
: 7,
);
}).toList();
_initDefaults();
}
Future<void> _initDefaults() async {
try {
final years = await ref.read(academicYearsListProvider.future);
final sections = await ref.read(sectionsListProvider.future);
if (!mounted) return;
setState(() {
for (final e in _entries) {
e.academicYearId = _matchYear(years, e.record.schoolYear);
e.sectionId = _matchSection(sections, e);
}
});
} catch (_) {
// Lists unavailable (e.g. teacher without sections) - leave manual.
}
}
int? _matchYear(List<AcademicYearModel> years, String schoolYear) {
final sy = schoolYear.trim();
for (final y in years) {
if (y.yearRange.toLowerCase() == sy.toLowerCase()) return y.id;
}
for (final y in years) {
if (y.status.toLowerCase() == 'active') return y.id;
}
return years.isEmpty ? null : years.first.id;
}
int? _matchSection(List<SectionModel> sections, _Entry entry) {
final name = entry.record.section.trim().toLowerCase();
for (final s in sections) {
final yearOk = s.academicYearId == null ||
entry.academicYearId == null ||
s.academicYearId == entry.academicYearId;
if (s.gradeLevel == entry.gradeLevel &&
yearOk &&
s.name.trim().toLowerCase() == name) {
return s.id;
}
}
return null;
}
List<SectionModel> _sectionsForEntry(
List<SectionModel> allSections,
_Entry entry,
) {
return allSections
.where((s) =>
s.gradeLevel == entry.gradeLevel &&
(s.academicYearId == null ||
entry.academicYearId == null ||
s.academicYearId == entry.academicYearId))
.toList();
}
Future<void> _accept(int index) async {
final entry = _entries[index];
if (entry.academicYearId == null || entry.sectionId == null) {
showErrorDialog(
context,
'Incomplete',
'Select Academic Year and Section before accepting.',
);
return;
}
if (!_isEditMode) {
final years = ref.read(academicYearsListProvider).asData?.value ?? [];
final year = years
.where((y) => y.id == entry.academicYearId)
.firstOrNull;
final prefill = OcrEnrollmentPrefill(
academicYearId: entry.academicYearId!,
gradeLevel: entry.gradeLevel,
sectionId: entry.sectionId,
sectionName:
entry.record.section.isEmpty ? 'Section' : entry.record.section,
schoolYear: year?.yearRange ?? entry.record.schoolYear,
);
Navigator.of(context).pop(prefill);
return;
}
setState(() => _acceptingIndex = index);
try {
await ref.read(studentMutationProvider.notifier).addEnrollment(
studentId: widget.studentId!,
academicYearId: entry.academicYearId!,
gradeLevel: entry.gradeLevel,
sectionId: entry.sectionId!,
);
if (!mounted) return;
setState(() {
_acceptedCount++;
_entries.removeAt(index);
});
} catch (e) {
if (!mounted) return;
final raw = e.toString();
final msg = raw.startsWith('Exception: ') ? raw.substring(11) : raw;
showErrorDialog(context, 'Accept Failed', msg);
} finally {
if (mounted) setState(() => _acceptingIndex = null);
}
}
void _remove(int index) {
setState(() => _entries.removeAt(index));
}
void _close() {
Navigator.of(context).pop(_isEditMode ? _acceptedCount : null);
}
InputDecoration _deco(String label) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return InputDecoration(
labelText: label,
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
filled: true,
fillColor: isDark ? AppColors.darkSurface2 : Colors.white,
);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final yearsAsync = ref.watch(academicYearsListProvider);
final sectionsAsync = ref.watch(sectionsListProvider);
final years = yearsAsync.asData?.value ?? <AcademicYearModel>[];
final allSections = sectionsAsync.asData?.value ?? <SectionModel>[];
final initialCount = _entries.length + _acceptedCount;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 560),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.document_scanner,
size: 22,
color: AppColors.primaryGreen,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_isEditMode
? 'Validate Enrollment from OCR'
: 'Review Enrollment from Scan',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 4),
Text(
'Edit Year Level, Grade Level, and Section. '
'Accept to save, decline/delete to skip without saving.',
style: TextStyle(
fontSize: 12,
color: isDark
? AppColors.darkTextSecondary
: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
if (_entries.isEmpty)
Expanded(
child: Center(
child: Text(
'No records left.',
style: TextStyle(
color: isDark
? AppColors.darkTextSecondary
: AppColors.textSecondary,
),
),
),
)
else
Expanded(
child: ListView.separated(
itemCount: _entries.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) =>
_buildEntryRow(index, years, allSections, isDark),
),
),
const SizedBox(height: 12),
Row(
children: [
Text(
_isEditMode
? '$_acceptedCount of $initialCount accepted'
: 'Accept to add this enrollment',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
TextButton(
onPressed: _close,
child: const Text(
'CLOSE',
style: TextStyle(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
),
);
}
Widget _buildEntryRow(
int index,
List<AcademicYearModel> years,
List<SectionModel> allSections,
bool isDark,
) {
final entry = _entries[index];
final sections = _sectionsForEntry(allSections, entry);
final sectionValueOk = entry.sectionId != null &&
sections.any((s) => s.id == entry.sectionId);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurface2 : const Color(0xFFF6F8FA),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isDark
? AppColors.darkBorder
: Colors.black.withValues(alpha: 0.06),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
entry.record.schoolYear.isEmpty
? 'Detected Enrollment'
: 'Detected: ${entry.record.schoolYear} · Grade ${entry.record.gradeLevel} · ${entry.record.section}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.primaryGreen,
),
),
),
// Icon-only actions: no container, no background, no border.
IconButton(
onPressed: _acceptingIndex == index
? null
: () => _accept(index),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: _acceptingIndex == index
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(
Icons.check_circle_outline,
size: 22,
color: AppColors.primaryGreen,
),
),
const SizedBox(width: 2),
IconButton(
onPressed: _acceptingIndex == index
? null
: () => _remove(index),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: const Icon(
Icons.block,
size: 20,
color: AppColors.warning,
),
),
if (_isEditMode) ...[
const SizedBox(width: 2),
IconButton(
onPressed:
_acceptingIndex == index ? null : () => _remove(index),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: const Icon(
Icons.delete_outline,
size: 20,
color: AppColors.error,
),
),
],
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
SizedBox(
width: 190,
child: DropdownButtonFormField<int>(
initialValue: entry.academicYearId,
isExpanded: true,
decoration: _deco('Year Level'),
items: years
.map((y) => DropdownMenuItem<int>(
value: y.id, child: Text(y.yearRange)))
.toList(),
onChanged: _acceptingIndex == index
? null
: (v) => setState(() {
entry.academicYearId = v;
entry.sectionId =
_matchSection(allSections, entry);
}),
),
),
SizedBox(
width: 130,
child: DropdownButtonFormField<int>(
initialValue: entry.gradeLevel,
isExpanded: true,
decoration: _deco('Grade Level'),
items: [7, 8, 9, 10, 11, 12]
.map((g) => DropdownMenuItem<int>(
value: g, child: Text('Grade $g')))
.toList(),
onChanged: _acceptingIndex == index
? null
: (v) => setState(() {
entry.gradeLevel = v!;
entry.sectionId =
_matchSection(allSections, entry);
}),
),
),
SizedBox(
width: 170,
child: DropdownButtonFormField<int>(
initialValue: sectionValueOk ? entry.sectionId : null,
isExpanded: true,
decoration: _deco('Section'),
items: sections
.map((s) =>
DropdownMenuItem<int>(value: s.id, child: Text(s.name)))
.toList(),
onChanged: _acceptingIndex == index
? null
: (v) => setState(() => entry.sectionId = v),
),
),
],
),
],
),
);
}
}