feat(frontend): complete phase 3 screen decomposition and phase 4 platform optimizations

This commit is contained in:
ssbg04 committed 2026-08-08 22:42:40 +08:00
1 parent 6f2d8865c9
commit c98fbb6a6e
7 files changed
+317 -194

No files matched your search

+20 -4
View File
@@ -45,10 +45,26 @@ void main() async {
);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
final view = WidgetsBinding.instance.platformDispatcher.views.first;
final physicalSize = view.physicalSize;
final devicePixelRatio = view.devicePixelRatio;
final width = physicalSize.width / devicePixelRatio;
final height = physicalSize.height / devicePixelRatio;
final shortestSide = width < height ? width : height;
if (shortestSide < 600) {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
} else {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
}
await NotificationService().initialize();
@@ -26,8 +26,9 @@ import '../../../core/utils/download_service.dart';
import '../../../core/network/api_constants.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'widgets/recycle_bin_modal.dart';
import 'widgets/bulk_operations_bar.dart';
import 'widgets/documents_header.dart';
import '../../../domain/entities/document_model.dart';
import '../../../core/utils/excel_to_pdf_converter.dart';
class DocumentsScreen extends ConsumerStatefulWidget {
final String userRole;
@@ -307,8 +308,6 @@ 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') {
@@ -334,52 +333,6 @@ 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,
@@ -600,117 +553,35 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
}
Widget _buildInlineMultiSelectHeader() {
final isDark = Theme.of(context).brightness == Brightness.dark;
final count = _selectedDocumentIds.length;
final isAdmin = widget.userRole != 'teacher';
final pageDocs = ref.watch(documentPageProvider).value?.documents ?? [];
final pageIds = pageDocs.map((d) => d.id).toSet();
final bool allSelected = pageIds.isNotEmpty && pageIds.every((id) => _selectedDocumentIds.contains(id));
final buttonColor = isDark ? Colors.white : Colors.black;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurfaceCard : AppColors.primaryGreen.withValues(alpha: 0.1),
border: Border(
bottom: BorderSide(color: isDark ? AppColors.darkBorder : AppColors.primaryGreen.withValues(alpha: 0.2)),
),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Tooltip(
message: 'Cancel selection',
child: IconButton(
icon: Icon(Icons.close, color: buttonColor),
onPressed: () => setState(() {
return BulkOperationsBar(
selectedCount: _selectedDocumentIds.length,
allSelected: allSelected,
isAdmin: widget.userRole != 'teacher',
onCancel: () => setState(() {
_selectedDocumentIds.clear();
_isMultiSelectMode = false;
}),
onToggleSelectAll: pageDocs.isEmpty
? () {}
: () {
setState(() {
if (allSelected) {
_selectedDocumentIds.clear();
_isMultiSelectMode = false;
}),
),
),
const SizedBox(width: 8),
Text(
count == 0 ? 'Select items' : '$count selected',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
const SizedBox(width: 8),
Tooltip(
message: allSelected ? 'Unselect All' : 'Select All',
child: IconButton(
icon: Icon(
allSelected ? Icons.deselect : Icons.select_all,
color: buttonColor,
),
onPressed: pageDocs.isEmpty
? null
: () {
setState(() {
if (allSelected) {
_selectedDocumentIds.clear();
} else {
_selectedDocumentIds.addAll(pageIds);
}
});
},
),
),
const SizedBox(width: 16),
Container(width: 1, height: 24, color: isDark ? AppColors.darkBorder : Colors.grey.shade400),
const SizedBox(width: 12),
Tooltip(
message: 'Print',
child: IconButton(
icon: Icon(Icons.print_rounded, color: buttonColor),
onPressed: count == 0 ? null : _handleBatchPrint,
),
),
Tooltip(
message: 'Copy',
child: IconButton(
icon: Icon(Icons.copy_rounded, color: buttonColor),
onPressed: count == 0 ? null : _handleBatchCopy,
),
),
Tooltip(
message: 'Download',
child: IconButton(
icon: Icon(Icons.download_rounded, color: buttonColor),
onPressed: count == 0 ? null : _handleBatchDownload,
),
),
Tooltip(
message: 'Complete',
child: IconButton(
icon: Icon(Icons.check_circle_outline_rounded, color: buttonColor),
onPressed: count == 0 ? null : () => _handleBatchStatus('Completed'),
),
),
Tooltip(
message: 'Archive',
child: IconButton(
icon: Icon(Icons.archive_outlined, color: buttonColor),
onPressed: count == 0 ? null : () => _handleBatchStatus('Archived'),
),
),
if (isAdmin)
Tooltip(
message: 'Delete',
child: IconButton(
icon: Icon(Icons.delete_outline_rounded, color: buttonColor),
onPressed: count == 0 ? null : _handleBatchDelete,
),
),
],
),
),
} else {
_selectedDocumentIds.addAll(pageIds);
}
});
},
onBatchPrint: _handleBatchPrint,
onBatchCopy: _handleBatchCopy,
onBatchDownload: _handleBatchDownload,
onBatchStatus: _handleBatchStatus,
onBatchArchive: () => _handleBatchStatus('Archived'),
onBatchDelete: _handleBatchDelete,
);
}
@@ -2870,18 +2741,6 @@ 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',
@@ -3185,22 +3044,6 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
),
];
if (_isExcelFile(doc)) {
items.insert(
4,
const PopupMenuItem(
value: 'convert_pdf',
child: Row(
children: [
Icon(Icons.picture_as_pdf, size: 18),
SizedBox(width: 12),
Text('Convert to PDF', style: TextStyle(fontSize: 14)),
],
),
),
);
}
if (widget.userRole != 'teacher') {
items.add(const PopupMenuDivider());
items.add(
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../core/utils/theme_extension.dart';
class BulkOperationsBar extends StatelessWidget {
final int selectedCount;
final bool allSelected;
final bool isAdmin;
final VoidCallback onCancel;
final VoidCallback onToggleSelectAll;
final VoidCallback onBatchPrint;
final VoidCallback onBatchCopy;
final VoidCallback onBatchDownload;
final ValueChanged<String> onBatchStatus;
final VoidCallback onBatchArchive;
final VoidCallback? onBatchDelete;
const BulkOperationsBar({
super.key,
required this.selectedCount,
required this.allSelected,
required this.isAdmin,
required this.onCancel,
required this.onToggleSelectAll,
required this.onBatchPrint,
required this.onBatchCopy,
required this.onBatchDownload,
required this.onBatchStatus,
required this.onBatchArchive,
this.onBatchDelete,
});
@override
Widget build(BuildContext context) {
final isDark = context.isDark;
final buttonColor = isDark ? Colors.white : Colors.black;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurfaceCard : AppColors.primaryGreen.withValues(alpha: 0.1),
border: Border(
bottom: BorderSide(
color: isDark ? AppColors.darkBorder : AppColors.primaryGreen.withValues(alpha: 0.2),
),
),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Tooltip(
message: 'Cancel selection',
child: IconButton(
icon: Icon(Icons.close, color: buttonColor),
onPressed: onCancel,
),
),
const SizedBox(width: 8),
Text(
selectedCount == 0 ? 'Select items' : '$selectedCount selected',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
const SizedBox(width: 8),
Tooltip(
message: allSelected ? 'Unselect All' : 'Select All',
child: IconButton(
icon: Icon(
allSelected ? Icons.deselect : Icons.select_all,
color: buttonColor,
),
onPressed: onToggleSelectAll,
),
),
const SizedBox(width: 16),
Container(
width: 1,
height: 24,
color: isDark ? AppColors.darkBorder : Colors.grey.shade400,
),
const SizedBox(width: 12),
Tooltip(
message: 'Print',
child: IconButton(
icon: Icon(Icons.print_rounded, color: buttonColor),
onPressed: selectedCount == 0 ? null : onBatchPrint,
),
),
Tooltip(
message: 'Copy',
child: IconButton(
icon: Icon(Icons.copy_rounded, color: buttonColor),
onPressed: selectedCount == 0 ? null : onBatchCopy,
),
),
Tooltip(
message: 'Download',
child: IconButton(
icon: Icon(Icons.download_rounded, color: buttonColor),
onPressed: selectedCount == 0 ? null : onBatchDownload,
),
),
Tooltip(
message: 'Complete',
child: IconButton(
icon: Icon(Icons.check_circle_outline_rounded, color: buttonColor),
onPressed: selectedCount == 0 ? null : () => onBatchStatus('Completed'),
),
),
Tooltip(
message: 'Archive',
child: IconButton(
icon: Icon(Icons.archive_outlined, color: buttonColor),
onPressed: selectedCount == 0 ? null : onBatchArchive,
),
),
if (isAdmin) ...[
Tooltip(
message: 'Delete',
child: IconButton(
icon: const Icon(Icons.delete_outline_rounded, color: AppColors.error),
onPressed: selectedCount == 0 ? null : onBatchDelete,
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../core/utils/theme_extension.dart';
class DocumentsHeader extends StatelessWidget {
final String? openedFolderName;
final int tabIndex;
final bool isStudentFiltered;
final bool isMobile;
final VoidCallback onBackToFolders;
final VoidCallback onClearStudentFilter;
final VoidCallback onOpenSearch;
final VoidCallback onClearSearch;
final bool hasActiveSearch;
const DocumentsHeader({
super.key,
required this.openedFolderName,
required this.tabIndex,
required this.isStudentFiltered,
required this.isMobile,
required this.onBackToFolders,
required this.onClearStudentFilter,
required this.onOpenSearch,
required this.onClearSearch,
required this.hasActiveSearch,
});
@override
Widget build(BuildContext context) {
final isDark = context.isDark;
final isFolderOpened = openedFolderName != null;
return Row(
children: [
Expanded(
child: isFolderOpened
? Row(
children: [
InkWell(
onTap: onBackToFolders,
child: Text(
'Student Folders',
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.w500,
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
),
),
),
Text(
' / ',
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.w500,
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
),
),
Expanded(
child: Text(
openedFolderName!,
style: TextStyle(
fontSize: isMobile ? 17 : 21,
fontWeight: FontWeight.bold,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
),
],
)
: Text(
tabIndex == 0
? 'Student Folders'
: tabIndex == 1
? (isStudentFiltered ? 'Student Documents' : 'All Documents')
: 'Recycle Bin',
style: TextStyle(
fontSize: isMobile ? 17 : 21,
fontWeight: FontWeight.bold,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
),
if (isStudentFiltered && !isFolderOpened) ...[
const SizedBox(width: 12),
Flexible(
child: TextButton.icon(
onPressed: onClearStudentFilter,
icon: const Icon(Icons.close, size: 14),
label: const Text('All Students', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryGreen,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
],
if (!isFolderOpened) ...[
IconButton(
icon: Icon(
hasActiveSearch ? Icons.close : Icons.search,
size: 28,
color: isDark ? AppColors.darkTextPrimary : Colors.black87,
),
tooltip: hasActiveSearch ? 'Clear Search' : 'Search Documents',
onPressed: hasActiveSearch ? onClearSearch : onOpenSearch,
),
],
],
);
}
}
@@ -1287,7 +1287,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
crossAxisCount: cols,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
mainAxisExtent: 145,
mainAxisExtent: 160,
),
children: [
_buildPrimaryKpiCard(
@@ -1,4 +1,5 @@
import 'dart:io';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -1131,9 +1132,21 @@ class _AddStudentModalState extends ConsumerState<AddStudentModal> {
minHeight: 3,
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: stepContent,
child: DropTarget(
onDragDone: (details) async {
if (details.files.isNotEmpty && _currentStep == 0) {
final path = details.files.first.path;
final droppedFile = File(path);
final fileName = path.split(RegExp(r'[\\/]')).last;
final length = await droppedFile.length();
final fileSize = '${(length / 1024).toStringAsFixed(1)} KB';
_handleOcrScan(droppedFile, fileName, fileSize);
}
},
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: stepContent,
),
),
),
Builder(
@@ -333,17 +333,19 @@ class _BulkOcrImportDialogState extends ConsumerState<BulkOcrImportDialog> {
item.trackStrand ??= _sharedTrackStrand;
}
// Validate enrollment completeness
// Validate enrollment completeness & grade level bounds (7-12)
final missingEnr = _items
.where((i) =>
i.status == _FileStatus.done &&
(i.academicYearId == null ||
i.gradeLevel == null ||
i.gradeLevel! < 7 ||
i.gradeLevel! > 12 ||
i.sectionId == null))
.toList();
if (missingEnr.isNotEmpty && mounted) {
showErrorDialog(context, 'Missing Enrollment',
'Please select Academic Year, Grade Level, and Section before importing.');
showErrorDialog(context, 'Invalid Enrollment',
'Please select a valid Academic Year, Grade Level (Grades 7–12), and Section before importing.');
return;
}