UI polishing and layout fixes for students, archives, and documents screens

This commit is contained in:
ssbg04 committed 2026-06-23 02:01:33 +08:00
1 parent d983c0d2d8
commit 6ded3a45e4
5 files changed
+226 -131

No files matched your search

@@ -14,6 +14,7 @@ class FolderModel {
final String? studentLastName;
final String? createdByUsername;
final int? documentCount;
final String? studentStatus;
// JHS / SHS completion tracking
/// 'JHS' or 'SHS' — the tier that applies to this student
@@ -44,6 +45,7 @@ class FolderModel {
this.studentLastName,
this.createdByUsername,
this.documentCount,
this.studentStatus,
this.studentTier,
this.jhsTotal = 0,
this.jhsCompleted = 0,
@@ -94,6 +96,7 @@ class FolderModel {
studentLastName: json['last_name'] as String?,
createdByUsername: json['created_by_username'] as String?,
documentCount: json['document_count'] as int?,
studentStatus: json['student_status'] as String?,
studentTier: json['student_tier'] as String?,
jhsTotal: (json['jhs_total'] as num?)?.toInt() ?? 0,
jhsCompleted: (json['jhs_completed'] as num?)?.toInt() ?? 0,
@@ -1097,7 +1097,7 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
),
if (!isMobile) ...[
Expanded(
child: _buildStudentStatusChip('Archived'),
child: _buildStudentStatusChip(folder.studentStatus ?? 'Archived'),
),
Expanded(
child: Text(
@@ -1202,6 +1202,8 @@ class _ArchivesScreenState extends ConsumerState<ArchivesScreen>
),
),
const SizedBox(height: 4),
_buildStudentStatusChip(folder.studentStatus ?? 'Archived'),
const SizedBox(height: 4),
Text(
'${folder.documentCount ?? 0} docs',
style: const TextStyle(
@@ -743,7 +743,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
),
),
if (defaultTargetPlatform != TargetPlatform.windows &&
(_tabController.index == 1 || isFolderOpened) &&
_tabController.index != 2 &&
!_isMultiSelectMode) ...[
const SizedBox(height: 12),
FloatingActionButton(
@@ -755,19 +755,21 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
),
child: const Icon(Icons.print, color: AppColors.primaryGreen),
),
const SizedBox(height: 12),
FloatingActionButton(
heroTag: 'upload_fab',
backgroundColor: AppColors.primaryGreen,
onPressed: () => showDialog(
context: context,
builder: (_) => UploadOcrModal(
prefilledStudentId:
_openedFolderStudentId ?? widget.initialStudentId,
if (_tabController.index == 1 || isFolderOpened) ...[
const SizedBox(height: 12),
FloatingActionButton(
heroTag: 'upload_fab',
backgroundColor: AppColors.primaryGreen,
onPressed: () => showDialog(
context: context,
builder: (_) => UploadOcrModal(
prefilledStudentId:
_openedFolderStudentId ?? widget.initialStudentId,
),
),
child: const Icon(Icons.cloud_upload, color: Colors.white),
),
child: const Icon(Icons.cloud_upload, color: Colors.white),
),
],
]
],
),
@@ -1030,26 +1032,6 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
),
],
// Desktop action buttons (Upload available to all roles)
if (!isMobile && _tabController.index != 2) ...[
const SizedBox(width: 8),
_buildPrintQueueButton(),
const SizedBox(width: 8),
SizedBox(
height: 38,
width: 120,
child: PrimaryButton(
label: 'UPLOAD',
onPressed: () => showDialog(
context: context,
builder: (_) => UploadOcrModal(
prefilledStudentId:
_openedFolderStudentId ?? widget.initialStudentId,
),
),
),
),
],
],
),
@@ -1073,7 +1055,28 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
if (!_searchFocusNode.hasFocus) ...[
// Multi-select toggle (Documents tab, opened folder, or Recycle Bin)
if (_tabController.index == 1 || isFolderOpened) ...[
_buildMultiSelectToggle(true),
_buildMultiSelectToggle(isMobile),
const SizedBox(width: 8),
],
// Desktop action buttons (Upload available to all roles)
if (!isMobile && _tabController.index != 2) ...[
_buildPrintQueueButton(),
const SizedBox(width: 8),
SizedBox(
height: 38,
width: 120,
child: PrimaryButton(
label: 'UPLOAD',
onPressed: () => showDialog(
context: context,
builder: (_) => UploadOcrModal(
prefilledStudentId:
_openedFolderStudentId ?? widget.initialStudentId,
),
),
),
),
const SizedBox(width: 8),
],
@@ -1169,11 +1172,6 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
onSelected: (value) {
if (value == 'grid_list') {
setState(() => _isGridView = !_isGridView);
} else if (value == 'print') {
showDialog(
context: context,
builder: (_) => const PrintQueueModal(),
);
} else if (value == 'recycle_bin') {
showDialog(
context: context,
@@ -1199,24 +1197,6 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
],
),
),
if (!isMobile)
const PopupMenuItem(
value: 'print',
child: Row(
children: [
Icon(
Icons.print,
size: 20,
color: AppColors.textSecondary,
),
SizedBox(width: 8),
Text(
'Print List',
style: TextStyle(fontSize: 14),
),
],
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'recycle_bin',
@@ -1766,6 +1746,7 @@ class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
flex: 2,
child: Text(
'Requirement Progress',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
@@ -35,12 +35,43 @@ class _DocumentPreviewDialogState extends State<_DocumentPreviewDialog> {
bool _imageLoaded = false;
String? _token;
final PdfViewerController _pdfViewerController = PdfViewerController();
final TransformationController _imageTransformationController = TransformationController();
void _zoomImageIn() {
final matrix = _imageTransformationController.value.clone();
matrix.scale(1.2, 1.2, 1.0);
_imageTransformationController.value = matrix;
}
void _zoomImageOut() {
final matrix = _imageTransformationController.value.clone();
matrix.scale(1 / 1.2, 1 / 1.2, 1.0);
_imageTransformationController.value = matrix;
}
void _zoomPdfIn() {
_pdfViewerController.zoomLevel = _pdfViewerController.zoomLevel + 0.5;
}
void _zoomPdfOut() {
final newZoom = _pdfViewerController.zoomLevel - 0.5;
_pdfViewerController.zoomLevel = newZoom < 1.0 ? 1.0 : newZoom;
}
@override
void initState() {
super.initState();
_loadToken();
}
@override
void dispose() {
_pdfViewerController.dispose();
_imageTransformationController.dispose();
super.dispose();
}
Future<void> _loadToken() async {
final token = await const FlutterSecureStorage().read(key: 'jwt_token');
if (mounted) {
@@ -216,10 +247,50 @@ class _DocumentPreviewDialogState extends State<_DocumentPreviewDialog> {
}
Widget _buildContent(bool isMobile) {
if (_isImage) return _buildImagePreview();
if (_isPdf) return _buildPdfInfo(isMobile);
if (_isOffice) return _buildOfficeInfo(isMobile);
return _buildGenericInfo(isMobile);
Widget content;
if (_isImage) {
content = _buildImagePreview();
} else if (_isPdf) {
content = _buildPdfInfo(isMobile);
} else if (_isOffice) {
content = _buildOfficeInfo(isMobile);
} else {
content = _buildGenericInfo(isMobile);
}
if (_isImage || _isPdf) {
return Stack(
children: [
Positioned.fill(child: content),
Positioned(
right: 16,
bottom: 16,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton.small(
heroTag: 'zoom_in',
backgroundColor: AppColors.surfaceWhite,
foregroundColor: AppColors.primaryGreen,
onPressed: _isImage ? _zoomImageIn : _zoomPdfIn,
child: const Icon(Icons.zoom_in),
),
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'zoom_out',
backgroundColor: AppColors.surfaceWhite,
foregroundColor: AppColors.primaryGreen,
onPressed: _isImage ? _zoomImageOut : _zoomPdfOut,
child: const Icon(Icons.zoom_out),
),
],
),
),
],
);
}
return content;
}
// ── Image preview ─────────────────────────────────────────
@@ -233,8 +304,10 @@ class _DocumentPreviewDialogState extends State<_DocumentPreviewDialog> {
children: [
// Main image
InteractiveViewer(
transformationController: _imageTransformationController,
minScale: 0.5,
maxScale: 5.0,
constrained: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: Image.network(
@@ -323,6 +396,7 @@ class _DocumentPreviewDialogState extends State<_DocumentPreviewDialog> {
color: Colors.grey.shade100,
child: SfPdfViewer.network(
_fileUrl,
controller: _pdfViewerController,
canShowScrollHead: false,
canShowScrollStatus: false,
),
@@ -108,6 +108,65 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
}
}
void _showBulkActionMenu(bool isDesktop) {
if (isDesktop) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Bulk Actions'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.group_add, color: AppColors.primaryGreen),
title: const Text('Bulk Enroll'),
onTap: () {
Navigator.pop(ctx);
_showBulkEnrollModal();
},
),
ListTile(
leading: const Icon(Icons.school, color: Colors.blue),
title: const Text('Bulk Graduate'),
onTap: () {
Navigator.pop(ctx);
_showBulkGraduateConfirm();
},
),
],
),
),
);
} else {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.group_add, color: AppColors.primaryGreen),
title: const Text('Bulk Enroll'),
onTap: () {
Navigator.pop(ctx);
_showBulkEnrollModal();
},
),
ListTile(
leading: const Icon(Icons.school, color: Colors.blue),
title: const Text('Bulk Graduate'),
onTap: () {
Navigator.pop(ctx);
_showBulkGraduateConfirm();
},
),
],
),
),
);
}
}
void _showBulkEnrollModal() {
showDialog(
context: context,
@@ -116,14 +175,15 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
onSuccess: () {
setState(() {
_selectedStudentIds.clear();
_showMultiSelect = false;
});
showSuccessDialog(context, message: 'Students successfully enrolled.');
},
),
);
}
void _showBulkGraduateConfirm() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@@ -143,13 +203,16 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
try {
await ref.read(studentMutationProvider.notifier).bulkGraduate(_selectedStudentIds);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Students successfully graduated.'), backgroundColor: AppColors.success));
setState(() => _selectedStudentIds.clear());
setState(() {
_selectedStudentIds.clear();
_showMultiSelect = false;
});
showSuccessDialog(context, message: 'Students successfully graduated.');
}
} catch (e) {
if (mounted) {
final errMsg = e.toString().replaceAll('Exception: ', '');
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(errMsg), backgroundColor: AppColors.error));
showErrorDialog(context, 'Error', errMsg);
}
}
}
@@ -288,7 +351,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
return Scaffold(
backgroundColor: Colors.transparent,
floatingActionButton: _searchFocusNode.hasFocus ? null : Column(
floatingActionButton: !isMobile ? null : Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
@@ -395,15 +458,6 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
color: AppColors.textPrimary,
),
),
// Desktop: show full button in header
if (widget.userRole != 'teacher' && isDesktop)
SizedBox(
width: 180,
child: PrimaryButton(
label: 'ADD',
onPressed: () => _openModal(),
),
),
],
),
const SizedBox(height: AppSizes.p16),
@@ -423,6 +477,29 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
const SizedBox(width: 8),
_buildMultiSelectToggle(false),
],
if (isDesktop) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => _openFilterDialog(query, ref.read(academicYearsListProvider), ref.read(sectionsListProvider)),
icon: const Icon(Icons.tune_rounded, size: 20),
label: const Text('Filter'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryGreen,
side: const BorderSide(color: AppColors.primaryGreen),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
if (widget.userRole != 'teacher') ...[
const SizedBox(width: 8),
SizedBox(
width: 100,
child: PrimaryButton(
label: 'ADD',
onPressed: () => _openModal(),
),
),
],
],
],
),
@@ -458,7 +535,8 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
PopupMenuButton<String>(
GestureDetector(
onTap: () => _showBulkActionMenu(isDesktop),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
@@ -476,23 +554,6 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
],
),
),
onSelected: (value) {
if (value == 'enroll') {
_showBulkEnrollModal();
} else if (value == 'graduate') {
_showBulkGraduateConfirm();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'enroll',
child: Row(children: [Icon(Icons.group_add, color: AppColors.primaryGreen, size: 18), SizedBox(width: 10), Text('Bulk Enroll', style: TextStyle(fontSize: 14))]),
),
const PopupMenuItem(
value: 'graduate',
child: Row(children: [Icon(Icons.school, color: Colors.blue, size: 18), SizedBox(width: 10), Text('Bulk Graduate', style: TextStyle(fontSize: 14))]),
),
],
),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
@@ -524,7 +585,8 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
Row(
children: [
Expanded(
child: PopupMenuButton<String>(
child: GestureDetector(
onTap: () => _showBulkActionMenu(isDesktop),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
@@ -542,23 +604,6 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
],
),
),
onSelected: (value) {
if (value == 'enroll') {
_showBulkEnrollModal();
} else if (value == 'graduate') {
_showBulkGraduateConfirm();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'enroll',
child: Row(children: [Icon(Icons.group_add, color: AppColors.primaryGreen, size: 18), SizedBox(width: 10), Text('Bulk Enroll', style: TextStyle(fontSize: 14))]),
),
const PopupMenuItem(
value: 'graduate',
child: Row(children: [Icon(Icons.school, color: Colors.blue, size: 18), SizedBox(width: 10), Text('Bulk Graduate', style: TextStyle(fontSize: 14))]),
),
],
),
),
const SizedBox(width: 8),
@@ -1168,7 +1213,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 17,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
@@ -1181,6 +1226,7 @@ class _StudentsScreenState extends ConsumerState<StudentsScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
@@ -1599,27 +1645,16 @@ class _BulkEnrollDialogState extends ConsumerState<BulkEnrollDialog> {
children: [
yearsAsync.when(
data: (years) {
return DropdownButtonFormField<int>(
value: _selectedAcademicYearId,
decoration: const InputDecoration(
labelText: 'Academic Year',
prefixIcon: Icon(Icons.calendar_today),
border: OutlineInputBorder(),
),
items: years.map((y) => DropdownMenuItem<int>(value: y.id, child: Text(y.yearRange))).toList(),
onChanged: (val) {
setState(() {
_selectedAcademicYearId = val;
_selectedSectionId = null;
});
},
validator: (v) => v == null ? 'Academic year is required.' : null,
);
if (_selectedAcademicYearId == null && years.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _selectedAcademicYearId = years.first.id);
});
}
return const SizedBox.shrink();
},
loading: () => const Center(child: Padding(padding: EdgeInsets.all(8), child: CircularProgressIndicator())),
error: (e, _) => Text('Error: $e'),
loading: () => const SizedBox.shrink(),
error: (e, _) => const SizedBox.shrink(),
),
const SizedBox(height: AppSizes.p16),
gradeLevelsAsync.when(
data: (grades) {
return DropdownButtonFormField<int>(