2935 lines
106 KiB
Dart
2935 lines
106 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/foundation.dart'
|
|
show defaultTargetPlatform, TargetPlatform;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../../core/constants/app_colors.dart';
|
|
import '../../../domain/entities/folder_model.dart';
|
|
import '../../shared/inputs/app_search_bar.dart';
|
|
import '../../providers/document_provider.dart';
|
|
import '../../providers/student_provider.dart';
|
|
import '../../providers/auth_provider.dart';
|
|
import '../../shared/dialogs/document_properties_dialog.dart';
|
|
import '../../shared/widgets/app_pagination.dart';
|
|
import '../../shared/widgets/app_error_state.dart';
|
|
import '../../providers/navigation_provider.dart';
|
|
import '../../providers/conversion_provider.dart';
|
|
|
|
import '../../shared/dialogs/success_dialog.dart';
|
|
import '../../shared/dialogs/error_dialog.dart';
|
|
import 'widgets/file_folder_card.dart';
|
|
import 'widgets/upload_ocr_modal.dart';
|
|
import 'widgets/print_queue_modal.dart';
|
|
import 'widgets/student_profile_modal.dart';
|
|
import 'widgets/document_preview_modal.dart';
|
|
import 'widgets/download_guide_dialog.dart';
|
|
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/student_archives_modal.dart';
|
|
import 'widgets/bulk_operations_bar.dart';
|
|
import '../../../domain/entities/document_model.dart';
|
|
|
|
class DocumentsScreen extends ConsumerStatefulWidget {
|
|
final String userRole;
|
|
|
|
/// When set, the screen auto-filters to this student's documents
|
|
final int? initialStudentId;
|
|
|
|
const DocumentsScreen({
|
|
super.key,
|
|
this.userRole = 'teacher',
|
|
this.initialStudentId,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<DocumentsScreen> createState() => _DocumentsScreenState();
|
|
}
|
|
|
|
class _DocumentsScreenState extends ConsumerState<DocumentsScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final FocusNode _searchFocusNode = FocusNode();
|
|
final FocusNode _shortcutFocusNode = FocusNode();
|
|
final GlobalKey _searchBarKey = GlobalKey();
|
|
Timer? _debounce;
|
|
bool _isGridView = false;
|
|
late final TabController _tabController;
|
|
|
|
// --- Windows Explorer State Variables ---
|
|
int? _openedFolderStudentId;
|
|
String? _openedFolderName;
|
|
|
|
// Filter values — kept in sync with provider
|
|
String _selectedStatus = 'All Statuses';
|
|
String _selectedDocumentType = 'All Types';
|
|
String _selectedGradeLevel = 'All Grades';
|
|
String _selectedSchoolYear = 'All Years';
|
|
|
|
String _pendingStatus = 'All Statuses';
|
|
String _pendingDocumentType = 'All Types';
|
|
String _pendingGradeLevel = 'All Grades';
|
|
String _pendingSchoolYear = 'All Years';
|
|
|
|
// Cached doc type lists for filter expansion
|
|
List<String> _jhsItems = [];
|
|
List<String> _shsItems = [];
|
|
|
|
// Selection states
|
|
bool _isMultiSelectMode = false;
|
|
final Set<int> _selectedDocumentIds = {};
|
|
|
|
Timer? _pollingTimer;
|
|
ProviderSubscription<String>? _tabListener;
|
|
ProviderSubscription<OpenedFolderData?>? _folderListener;
|
|
|
|
// Pagination for folders
|
|
int _foldersPage = 1;
|
|
final int _foldersPerPage = 20;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// 2 tabs: 0=Folders, 1=Documents
|
|
_tabController = TabController(length: 2, vsync: this);
|
|
_searchFocusNode.addListener(_onSearchFocusChanged);
|
|
|
|
_pollingTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
|
if (mounted &&
|
|
ref.read(authProvider).value != null &&
|
|
ref.read(activeTabProvider) == 'Documents') {
|
|
if (_tabController.index == 0) {
|
|
ref.invalidate(foldersProvider);
|
|
ref.invalidate(studentFoldersProvider);
|
|
} else if (_tabController.index == 1) {
|
|
ref.invalidate(documentPageProvider);
|
|
}
|
|
}
|
|
});
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
// If a specific student was passed, jump to Folders tab and open that folder
|
|
final initialFolder = ref.read(openedFolderProvider);
|
|
if (initialFolder != null) {
|
|
setState(() {
|
|
_openedFolderStudentId = initialFolder.id;
|
|
_openedFolderName = initialFolder.name;
|
|
});
|
|
ref.read(documentQueryProvider.notifier).setStudentId(initialFolder.id);
|
|
} else if (widget.initialStudentId != null) {
|
|
setState(() {
|
|
_openedFolderStudentId = widget.initialStudentId;
|
|
_openedFolderName = 'Student Documents';
|
|
});
|
|
ref
|
|
.read(documentQueryProvider.notifier)
|
|
.setStudentId(widget.initialStudentId);
|
|
}
|
|
});
|
|
|
|
_tabController.addListener(() {
|
|
setState(() {}); // Ensure header title updates immediately on tab change
|
|
// Refresh and reset states strictly when the tab transition has completed
|
|
if (!_tabController.indexIsChanging) {
|
|
setState(() {
|
|
_isMultiSelectMode = false;
|
|
_selectedDocumentIds.clear();
|
|
});
|
|
|
|
_clearFilters();
|
|
|
|
// Refresh data to keep UI real-time
|
|
ref.invalidate(documentPageProvider);
|
|
ref.invalidate(foldersProvider);
|
|
ref.invalidate(studentFoldersProvider);
|
|
ref.invalidate(trashDocumentsProvider);
|
|
|
|
if (_tabController.index != 0 && _openedFolderStudentId != null) {
|
|
setState(() {
|
|
_openedFolderStudentId = null;
|
|
_openedFolderName = null;
|
|
});
|
|
ref.read(openedFolderProvider.notifier).setFolder(null);
|
|
ref.read(documentQueryProvider.notifier).setStudentId(null);
|
|
}
|
|
}
|
|
});
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
if (ref.read(activeTabProvider) == 'Documents') {
|
|
_shortcutFocusNode.requestFocus();
|
|
}
|
|
ref.read(documentQueryProvider.notifier).setPage(1);
|
|
|
|
_tabListener = ref.listenManual<String>(activeTabProvider, (
|
|
previous,
|
|
next,
|
|
) {
|
|
if (!mounted) return;
|
|
if (next != 'Documents') {
|
|
ref.read(openedFolderProvider.notifier).setFolder(null);
|
|
setState(() {
|
|
_openedFolderStudentId = null;
|
|
_openedFolderName = null;
|
|
_isMultiSelectMode = false;
|
|
if (_searchController.text.isNotEmpty) _searchController.clear();
|
|
_selectedDocumentIds.clear();
|
|
});
|
|
ref.read(documentQueryProvider.notifier).reset();
|
|
if (mounted && _tabController.index != 0) {
|
|
_tabController.index = 0;
|
|
}
|
|
} else {
|
|
Future.delayed(const Duration(milliseconds: 120), () {
|
|
if (mounted) {
|
|
_shortcutFocusNode.requestFocus();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
_folderListener = ref.listenManual<OpenedFolderData?>(
|
|
openedFolderProvider,
|
|
(previous, current) {
|
|
if (!mounted) return;
|
|
if (current != null && current.id != _openedFolderStudentId) {
|
|
setState(() {
|
|
_openedFolderStudentId = current.id;
|
|
_openedFolderName = current.name;
|
|
});
|
|
if (mounted && _tabController.index != 0) {
|
|
_tabController.index = 0;
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
ref.read(documentQueryProvider.notifier).setStudentId(current.id);
|
|
});
|
|
}
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabListener?.close();
|
|
_folderListener?.close();
|
|
_pollingTimer?.cancel();
|
|
_tabController.dispose();
|
|
_searchController.dispose();
|
|
_searchFocusNode.removeListener(_onSearchFocusChanged);
|
|
_searchFocusNode.dispose();
|
|
_shortcutFocusNode.dispose();
|
|
_debounce?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onSearchFocusChanged() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
void _onSearchChanged(String query) {
|
|
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
|
_debounce = Timer(const Duration(milliseconds: 500), () {
|
|
setState(() => _foldersPage = 1);
|
|
ref.read(documentQueryProvider.notifier).setSearch(query);
|
|
});
|
|
}
|
|
|
|
void _onSearchSubmitted(String query) {
|
|
_debounce?.cancel();
|
|
setState(() => _foldersPage = 1);
|
|
ref.read(documentQueryProvider.notifier).setSearch(query);
|
|
}
|
|
|
|
void _applyFilters() {
|
|
setState(() => _foldersPage = 1);
|
|
final n = ref.read(documentQueryProvider.notifier);
|
|
n.setStatus(_selectedStatus);
|
|
|
|
String docTypeFilter = '';
|
|
if (_selectedDocumentType == 'All JHS') {
|
|
docTypeFilter = _jhsItems.join(',');
|
|
} else if (_selectedDocumentType == 'All SHS') {
|
|
docTypeFilter = _shsItems.join(',');
|
|
} else if (_selectedDocumentType != 'All Types') {
|
|
docTypeFilter = _selectedDocumentType;
|
|
}
|
|
n.setDocumentType(docTypeFilter);
|
|
|
|
n.setGradeLevel(
|
|
_selectedGradeLevel == 'All Grades' ? '' : _selectedGradeLevel,
|
|
);
|
|
n.setSchoolYear(
|
|
_selectedSchoolYear == 'All Years' ? '' : _selectedSchoolYear,
|
|
);
|
|
}
|
|
|
|
void _clearFilters() {
|
|
setState(() {
|
|
_foldersPage = 1;
|
|
_selectedStatus = 'All Statuses';
|
|
_selectedDocumentType = 'All Types';
|
|
_selectedGradeLevel = 'All Grades';
|
|
_selectedSchoolYear = 'All Years';
|
|
_searchController.clear();
|
|
});
|
|
ref.read(documentQueryProvider.notifier).reset();
|
|
_applyFilters();
|
|
}
|
|
|
|
void _handleAction(String action, DocumentModel document) async {
|
|
final documentId = document.id;
|
|
final studentId = document.studentId;
|
|
|
|
if (action == 'select') {
|
|
setState(() {
|
|
_isMultiSelectMode = true;
|
|
_selectedDocumentIds.add(documentId);
|
|
});
|
|
} else if (action == 'properties') {
|
|
DocumentPropertiesDialog.show(context, document: document);
|
|
} else if (action == 'view_profile' && studentId != null) {
|
|
showStudentProfileModal(
|
|
context,
|
|
studentId: studentId,
|
|
userRole: widget.userRole,
|
|
hideEnrollmentActions: true,
|
|
);
|
|
} else if (action == 'delete') {
|
|
_confirmDelete(documentId);
|
|
} else if (action == 'queue') {
|
|
try {
|
|
await ref
|
|
.read(printQueueMutationProvider.notifier)
|
|
.addToQueue(documentId);
|
|
if (!mounted) return;
|
|
showSuccessDialog(context, message: 'Added to Print List.');
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final msg = e.toString().replaceFirst('Exception: ', '');
|
|
showErrorDialog(context, 'Failed to Add', msg);
|
|
}
|
|
} else if (action == 'copy') {
|
|
try {
|
|
await ref
|
|
.read(documentMutationProvider.notifier)
|
|
.copyDocument(documentId);
|
|
if (!mounted) return;
|
|
showSuccessDialog(context, message: 'Document copied.');
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(context, 'Copy Failed', e.toString());
|
|
}
|
|
} else if (action == 'preview') {
|
|
showDocumentPreview(context: context, document: document);
|
|
} else if (action == 'download') {
|
|
try {
|
|
final token = await const FlutterSecureStorage().read(key: 'jwt_token');
|
|
if (token == null) return;
|
|
final url =
|
|
'${ApiConstants.baseUrl}/documents/${document.id}/view?token=$token&download=true';
|
|
|
|
final savedPath = await DownloadService.downloadFile(
|
|
url: url,
|
|
fileName: document.fileName,
|
|
);
|
|
if (!mounted) return;
|
|
showSuccessDialog(
|
|
context,
|
|
message: 'Document downloaded successfully.',
|
|
filePath: savedPath,
|
|
notes: 'The document has been saved to your downloads folder.',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(context, 'Download Failed', e.toString());
|
|
}
|
|
} else if (action == 'archive') {
|
|
_confirmArchive(document);
|
|
} else if (action == 'convert_pdf') {
|
|
try {
|
|
final converted = await ref
|
|
.read(conversionProvider.notifier)
|
|
.convertToPdf(documentId);
|
|
if (!mounted) return;
|
|
showSuccessDialog(
|
|
context,
|
|
message: 'Excel converted to PDF successfully as "${converted.fileName}".',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(
|
|
context,
|
|
'Conversion Failed',
|
|
e.toString().replaceFirst('Exception: ', ''),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _confirmArchive(DocumentModel document) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
title: const Row(
|
|
children: [
|
|
Icon(Icons.archive_outlined, color: AppColors.primaryGreen),
|
|
SizedBox(width: 8),
|
|
Text('Archive Document'),
|
|
],
|
|
),
|
|
content: Text(
|
|
'Are you sure you want to archive "${document.fileName}"?\n\nIt will be moved to the Archive Screen and hidden from active documents.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: const Text('CANCEL'),
|
|
),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primaryGreen,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
onPressed: () async {
|
|
Navigator.pop(ctx);
|
|
try {
|
|
await ref
|
|
.read(documentMutationProvider.notifier)
|
|
.updateStatus(document.id, 'Archived');
|
|
if (!mounted) return;
|
|
showSuccessDialog(
|
|
context,
|
|
message: 'Document moved to Archive Screen.',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(context, 'Archive Failed', e.toString());
|
|
}
|
|
},
|
|
child: const Text('ARCHIVE'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmDelete(int id) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
title: const Row(
|
|
children: [
|
|
Icon(Icons.warning_amber_rounded, color: AppColors.error),
|
|
SizedBox(width: 8),
|
|
Text('Delete Document', style: TextStyle(color: AppColors.error)),
|
|
],
|
|
),
|
|
content: const Text(
|
|
'Are you sure you want to move this document to the Recycle Bin?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: const Text('CANCEL'),
|
|
),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.error,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
onPressed: () async {
|
|
Navigator.pop(ctx);
|
|
try {
|
|
await ref
|
|
.read(documentMutationProvider.notifier)
|
|
.deleteDocument(id);
|
|
if (!mounted) return;
|
|
showSuccessDialog(
|
|
context,
|
|
message: 'Document moved to Recycle Bin.',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(context, 'Delete Failed', e.toString());
|
|
}
|
|
},
|
|
child: const Text('DELETE'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// NEW FOLDER & BATCH ACTIONS HELPERS
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
Future<void> _handleBatchPrint() async {
|
|
try {
|
|
await ref
|
|
.read(documentMutationProvider.notifier)
|
|
.bulkAddToPrintQueue(_selectedDocumentIds.toList());
|
|
setState(() {
|
|
_selectedDocumentIds.clear();
|
|
_isMultiSelectMode = false;
|
|
});
|
|
if (!mounted) return;
|
|
showSuccessDialog(
|
|
context,
|
|
message: 'Selected documents added to Print List.',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(
|
|
context,
|
|
'Print Queue Failed',
|
|
e.toString().replaceFirst('Exception: ', ''),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleBatchCopy() async {
|
|
try {
|
|
await ref
|
|
.read(documentMutationProvider.notifier)
|
|
.bulkCopy(_selectedDocumentIds.toList());
|
|
setState(() {
|
|
_selectedDocumentIds.clear();
|
|
_isMultiSelectMode = false;
|
|
});
|
|
if (!mounted) return;
|
|
showSuccessDialog(
|
|
context,
|
|
message: 'Selected documents copied successfully.',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(
|
|
context,
|
|
'Copy Failed',
|
|
e.toString().replaceFirst('Exception: ', ''),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleBatchDownload() async {
|
|
try {
|
|
final token = await const FlutterSecureStorage().read(key: 'jwt_token');
|
|
if (token == null) return;
|
|
|
|
final docs = ref.read(documentPageProvider).value?.documents ?? [];
|
|
int successCount = 0;
|
|
|
|
for (final docId in _selectedDocumentIds) {
|
|
final doc = docs.firstWhere((d) => d.id == docId);
|
|
final url =
|
|
'${ApiConstants.baseUrl}/documents/${doc.id}/view?token=$token&download=true';
|
|
await DownloadService.downloadFile(url: url, fileName: doc.fileName);
|
|
successCount++;
|
|
}
|
|
|
|
setState(() {
|
|
_selectedDocumentIds.clear();
|
|
_isMultiSelectMode = false;
|
|
});
|
|
if (!mounted) return;
|
|
showSuccessDialog(
|
|
context,
|
|
message: 'Successfully downloaded $successCount documents.',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(
|
|
context,
|
|
'Download Failed',
|
|
e.toString().replaceFirst('Exception: ', ''),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleBatchStatus(String status) async {
|
|
try {
|
|
await ref
|
|
.read(documentMutationProvider.notifier)
|
|
.bulkUpdateStatus(_selectedDocumentIds.toList(), status);
|
|
setState(() {
|
|
_selectedDocumentIds.clear();
|
|
_isMultiSelectMode = false;
|
|
});
|
|
if (!mounted) return;
|
|
showSuccessDialog(
|
|
context,
|
|
message: 'Status updated to "$status" for selected documents.',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(
|
|
context,
|
|
'Status Update Failed',
|
|
e.toString().replaceFirst('Exception: ', ''),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleBatchDelete() async {
|
|
final count = _selectedDocumentIds.length;
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
title: const Row(
|
|
children: [
|
|
Icon(Icons.warning_amber_rounded, color: AppColors.error),
|
|
SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(
|
|
'Delete Documents',
|
|
style: TextStyle(color: AppColors.error, fontSize: 17),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
content: Text(
|
|
'Are you sure you want to move $count document${count > 1 ? 's' : ''} to the Recycle Bin?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('CANCEL'),
|
|
),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.error,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: const Text('DELETE'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true) return;
|
|
try {
|
|
await ref
|
|
.read(documentMutationProvider.notifier)
|
|
.bulkDelete(_selectedDocumentIds.toList());
|
|
setState(() {
|
|
_selectedDocumentIds.clear();
|
|
_isMultiSelectMode = false;
|
|
});
|
|
if (!mounted) return;
|
|
showSuccessDialog(
|
|
context,
|
|
message: 'Moved $count document${count > 1 ? 's' : ''} to Recycle Bin.',
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showErrorDialog(
|
|
context,
|
|
'Delete Failed',
|
|
e.toString().replaceFirst('Exception: ', ''),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildInlineMultiSelectHeader() {
|
|
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));
|
|
|
|
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();
|
|
} else {
|
|
_selectedDocumentIds.addAll(pageIds);
|
|
}
|
|
});
|
|
},
|
|
onBatchPrint: _handleBatchPrint,
|
|
onBatchCopy: _handleBatchCopy,
|
|
onBatchDownload: _handleBatchDownload,
|
|
onBatchStatus: _handleBatchStatus,
|
|
onBatchArchive: () => _handleBatchStatus('Archived'),
|
|
onBatchDelete: _handleBatchDelete,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final docState = ref.watch(documentPageProvider);
|
|
final query = ref.watch(documentQueryProvider);
|
|
final requirementsAsync = ref.watch(documentRequirementsProvider);
|
|
final academicYearsAsync = ref.watch(academicYearsProvider);
|
|
final statusesAsync = ref.watch(documentStatusesProvider);
|
|
final foldersAsync = ref.watch(foldersProvider);
|
|
final screenW = MediaQuery.of(context).size.width;
|
|
final isMobile = screenW < 700;
|
|
final isStudentFiltered = query.studentId != null;
|
|
final isFolderOpened = _openedFolderStudentId != null;
|
|
|
|
return CallbackShortcuts(
|
|
bindings: <ShortcutActivator, VoidCallback>{
|
|
const SingleActivator(LogicalKeyboardKey.keyF, control: true): () {
|
|
_showSearchDialog(context);
|
|
},
|
|
},
|
|
child: Focus(
|
|
focusNode: _shortcutFocusNode,
|
|
autofocus: true,
|
|
child: PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, result) {
|
|
if (didPop) return;
|
|
if (ref.read(activeTabProvider) != 'Documents') return;
|
|
if (_isMultiSelectMode) {
|
|
setState(() {
|
|
_isMultiSelectMode = false;
|
|
_selectedDocumentIds.clear();
|
|
if (_openedFolderStudentId != null) {
|
|
_openedFolderStudentId = null;
|
|
_openedFolderName = null;
|
|
if (_tabController.index != 0) {
|
|
_tabController.index = 0;
|
|
}
|
|
}
|
|
});
|
|
ref.read(openedFolderProvider.notifier).setFolder(null);
|
|
ref.read(documentQueryProvider.notifier).setStudentId(null);
|
|
return;
|
|
}
|
|
if (_openedFolderStudentId != null) {
|
|
setState(() {
|
|
_openedFolderStudentId = null;
|
|
_openedFolderName = null;
|
|
if (_tabController.index != 0) {
|
|
_tabController.index = 0;
|
|
}
|
|
});
|
|
ref.read(openedFolderProvider.notifier).setFolder(null);
|
|
ref.read(documentQueryProvider.notifier).setStudentId(null);
|
|
return;
|
|
}
|
|
ref.read(activeTabProvider.notifier).setTab('Dashboard');
|
|
},
|
|
child: GestureDetector(
|
|
onTap: () => FocusScope.of(context).unfocus(),
|
|
behavior: HitTestBehavior.translucent,
|
|
child: Scaffold(
|
|
resizeToAvoidBottomInset: false,
|
|
backgroundColor: Colors.transparent,
|
|
floatingActionButton: null,
|
|
bottomNavigationBar: null,
|
|
body: SafeArea(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// ── Top Header or Inline Multi-Select Header ──
|
|
if (_isMultiSelectMode)
|
|
_buildInlineMultiSelectHeader()
|
|
else
|
|
_buildTopHeader(
|
|
isMobile,
|
|
isStudentFiltered,
|
|
query,
|
|
isFolderOpened,
|
|
requirementsAsync,
|
|
academicYearsAsync,
|
|
statusesAsync,
|
|
),
|
|
|
|
// ── Tab Body ──
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
children: [
|
|
// Tab 0: Student Folders
|
|
_buildFoldersTab(
|
|
foldersAsync,
|
|
docState,
|
|
query,
|
|
isMobile,
|
|
screenW,
|
|
),
|
|
|
|
// Tab 1: All Documents
|
|
Column(
|
|
children: [
|
|
Expanded(
|
|
child: docState.when(
|
|
loading: () => const Center(
|
|
child: CircularProgressIndicator(
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
),
|
|
error: (e, _) =>
|
|
_buildErrorState(e.toString()),
|
|
data: (pageData) => pageData.documents.isEmpty
|
|
? _buildEmptyState()
|
|
: _isGridView
|
|
? _buildGridView(
|
|
pageData.documents,
|
|
pageData.totalPages,
|
|
query.page,
|
|
)
|
|
: _buildListView(
|
|
pageData.documents,
|
|
pageData.totalPages,
|
|
query.page,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showSearchDialog(BuildContext context) async {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_searchFocusNode.requestFocus();
|
|
}
|
|
});
|
|
|
|
await showDialog(
|
|
context: context,
|
|
barrierColor: Colors.black54,
|
|
builder: (context) {
|
|
return Align(
|
|
alignment: Alignment.topCenter,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, kToolbarHeight + 24, 16, 0),
|
|
child: Material(
|
|
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
|
elevation: 4,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: AppSearchBar(
|
|
key: _searchBarKey,
|
|
hint: 'Search by name, LRN, file…',
|
|
controller: _searchController,
|
|
focusNode: _searchFocusNode,
|
|
collapsible: false,
|
|
maxWidth: 600,
|
|
onChanged: _onSearchChanged,
|
|
onSubmitted: (value) {
|
|
Navigator.of(context).pop();
|
|
_onSearchSubmitted(value);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
if (mounted) {
|
|
_shortcutFocusNode.requestFocus();
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// TOP HEADER (Unified with Segmented Tabs, styled like Multi-Select)
|
|
// ══════════════════════════════════════════════════════════════
|
|
void _closeFolder() {
|
|
setState(() {
|
|
_openedFolderStudentId = null;
|
|
_openedFolderName = null;
|
|
});
|
|
ref.read(openedFolderProvider.notifier).setFolder(null);
|
|
ref.read(documentQueryProvider.notifier).setStudentId(null);
|
|
_clearFilters();
|
|
ref.invalidate(documentPageProvider);
|
|
ref.invalidate(foldersProvider);
|
|
ref.invalidate(studentFoldersProvider);
|
|
}
|
|
|
|
Widget _buildSegmentedTabSwitcher(bool isDark, bool isMobile, bool isMobileOrAndroid) {
|
|
final activeIndex = _tabController.index;
|
|
return Container(
|
|
height: 36,
|
|
padding: const EdgeInsets.all(3),
|
|
decoration: BoxDecoration(
|
|
color: isDark ? AppColors.darkSurface2 : const Color(0xFFF1F3F5),
|
|
borderRadius: BorderRadius.circular(9),
|
|
border: Border.all(
|
|
color: isDark ? AppColors.darkBorder : const Color(0xFFE9ECEF),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildSegmentedTabItem(
|
|
index: 0,
|
|
icon: Icons.folder_outlined,
|
|
activeIcon: Icons.folder_rounded,
|
|
label: isMobile ? 'Folders' : 'Student Folders',
|
|
isSelected: activeIndex == 0,
|
|
isDark: isDark,
|
|
isMobile: isMobile,
|
|
isMobileOrAndroid: isMobileOrAndroid,
|
|
),
|
|
const SizedBox(width: 2),
|
|
_buildSegmentedTabItem(
|
|
index: 1,
|
|
icon: Icons.description_outlined,
|
|
activeIcon: Icons.description_rounded,
|
|
label: isMobile ? 'Documents' : 'All Documents',
|
|
isSelected: activeIndex == 1,
|
|
isDark: isDark,
|
|
isMobile: isMobile,
|
|
isMobileOrAndroid: isMobileOrAndroid,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSegmentedTabItem({
|
|
required int index,
|
|
required IconData icon,
|
|
required IconData activeIcon,
|
|
required String label,
|
|
required bool isSelected,
|
|
required bool isDark,
|
|
required bool isMobile,
|
|
required bool isMobileOrAndroid,
|
|
}) {
|
|
return Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(7),
|
|
onTap: () {
|
|
if (_tabController.index == index) {
|
|
if (index == 0 && _openedFolderStudentId != null) {
|
|
_closeFolder();
|
|
}
|
|
} else {
|
|
_tabController.animateTo(index);
|
|
}
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeInOut,
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: isMobile ? 8 : 13,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? AppColors.primaryGreen : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(7),
|
|
boxShadow: isSelected && !isDark && !isMobileOrAndroid
|
|
? [
|
|
BoxShadow(
|
|
color: AppColors.primaryGreen.withValues(alpha: 0.25),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 1),
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
isSelected ? activeIcon : icon,
|
|
size: 15,
|
|
color: isSelected
|
|
? Colors.white
|
|
: (isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary),
|
|
),
|
|
const SizedBox(width: 5),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: isMobile ? 12 : 12.5,
|
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
|
color: isSelected
|
|
? Colors.white
|
|
: (isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTopHeader(
|
|
bool isMobile,
|
|
bool isStudentFiltered,
|
|
DocumentQueryParams query,
|
|
bool isFolderOpened,
|
|
AsyncValue<List<dynamic>> requirementsAsync,
|
|
AsyncValue<List<dynamic>> academicYearsAsync,
|
|
AsyncValue<List<String>> statusesAsync,
|
|
) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final isMobileOrAndroid = isMobile || defaultTargetPlatform == TargetPlatform.android;
|
|
|
|
return RepaintBoundary(
|
|
child: Container(
|
|
height: 52,
|
|
margin: EdgeInsets.symmetric(
|
|
horizontal: isMobile ? 12 : 20,
|
|
vertical: 8,
|
|
),
|
|
padding: EdgeInsets.symmetric(horizontal: isMobile ? 8 : 12),
|
|
decoration: BoxDecoration(
|
|
color: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: isDark ? AppColors.darkBorder : AppColors.borderLight,
|
|
width: 1.0,
|
|
),
|
|
boxShadow: isMobileOrAndroid
|
|
? null
|
|
: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.05),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
// Left side: Folder breadcrumb or Segmented Tabs
|
|
if (isFolderOpened) ...[
|
|
IconButton(
|
|
icon: const Icon(
|
|
Icons.arrow_back_ios_new_rounded,
|
|
size: 18,
|
|
),
|
|
color: AppColors.primaryGreen,
|
|
tooltip: 'Back to Folders',
|
|
padding: const EdgeInsets.all(8),
|
|
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
|
onPressed: _closeFolder,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
if (!isMobile) ...[
|
|
InkWell(
|
|
onTap: _closeFolder,
|
|
child: Text(
|
|
'Student Folders',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
' / ',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
Flexible(
|
|
child: Text(
|
|
_openedFolderName!,
|
|
style: TextStyle(
|
|
fontSize: isMobile ? 14.5 : 15,
|
|
fontWeight: FontWeight.bold,
|
|
color: isDark
|
|
? AppColors.darkTextPrimary
|
|
: AppColors.textPrimary,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
] else ...[
|
|
_buildSegmentedTabSwitcher(isDark, isMobile, isMobileOrAndroid),
|
|
// Clear student filter chip (compact)
|
|
if (isStudentFiltered) ...[
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: TextButton.icon(
|
|
onPressed: () => ref
|
|
.read(documentQueryProvider.notifier)
|
|
.setStudentId(null),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
const Spacer(),
|
|
],
|
|
|
|
// Action buttons
|
|
if (!isFolderOpened) ...[
|
|
Tooltip(
|
|
richMessage: (_searchController.text.isNotEmpty || query.search.isNotEmpty)
|
|
? const TextSpan(text: 'Clear Search')
|
|
: const TextSpan(
|
|
text: 'Search Documents ',
|
|
children: [
|
|
TextSpan(
|
|
text: '(Ctrl+F)',
|
|
style: TextStyle(fontStyle: FontStyle.italic),
|
|
),
|
|
],
|
|
),
|
|
child: IconButton(
|
|
icon: Icon(
|
|
(_searchController.text.isNotEmpty || query.search.isNotEmpty)
|
|
? Icons.close
|
|
: Icons.search,
|
|
size: 20,
|
|
color: isDark ? AppColors.darkTextPrimary : Colors.black87,
|
|
),
|
|
padding: const EdgeInsets.all(8),
|
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
|
onPressed: () {
|
|
if (_searchController.text.isNotEmpty ||
|
|
query.search.isNotEmpty) {
|
|
_searchController.clear();
|
|
ref.read(documentQueryProvider.notifier).setSearch('');
|
|
setState(() => _foldersPage = 1);
|
|
ref.invalidate(foldersProvider);
|
|
ref.invalidate(studentFoldersProvider);
|
|
ref.invalidate(documentPageProvider);
|
|
} else {
|
|
_showSearchDialog(context);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
|
|
const SizedBox(width: 2),
|
|
|
|
// Multi-Select Toggle (Desktop non-Windows only, icon only, no background, no border)
|
|
if (!isMobile && defaultTargetPlatform != TargetPlatform.windows && widget.userRole != 'teacher' && (_tabController.index == 1 || isFolderOpened)) ...[
|
|
Tooltip(
|
|
message: _isMultiSelectMode ? 'Exit Multi-Select' : 'Multi-Select',
|
|
child: IconButton(
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.transparent,
|
|
side: BorderSide.none,
|
|
shadowColor: Colors.transparent,
|
|
padding: const EdgeInsets.all(8),
|
|
),
|
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
|
onPressed: () {
|
|
setState(() {
|
|
_isMultiSelectMode = !_isMultiSelectMode;
|
|
if (!_isMultiSelectMode) _selectedDocumentIds.clear();
|
|
});
|
|
},
|
|
icon: Icon(
|
|
Icons.checklist_rounded,
|
|
size: 20,
|
|
color: _isMultiSelectMode
|
|
? AppColors.primaryGreen
|
|
: (isDark ? AppColors.darkTextPrimary : AppColors.textSecondary),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
],
|
|
|
|
// Filter button (Icon only, between Search and Upload/Bulk Add)
|
|
if (_tabController.index == 1 || isFolderOpened) ...[
|
|
Tooltip(
|
|
message: 'Filter Documents',
|
|
child: IconButton(
|
|
padding: const EdgeInsets.all(8),
|
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
|
onPressed: () => _openFilterDialog(
|
|
requirementsAsync,
|
|
academicYearsAsync,
|
|
statusesAsync,
|
|
),
|
|
icon: Badge(
|
|
isLabelVisible: _getActiveFilterCount() > 0,
|
|
label: Text(_getActiveFilterCount().toString()),
|
|
child: Icon(
|
|
Icons.tune_rounded,
|
|
size: 20,
|
|
color: _getActiveFilterCount() > 0
|
|
? AppColors.primaryGreen
|
|
: (isDark ? AppColors.darkTextPrimary : Colors.black87),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
],
|
|
|
|
// Mobile action buttons (moved from FAB to header)
|
|
if (isMobile) ...[
|
|
// 1. Multi-Select (for non-teachers on Tab 1 or opened folder)
|
|
if (widget.userRole != 'teacher' && (_tabController.index == 1 || isFolderOpened)) ...[
|
|
Tooltip(
|
|
message: _isMultiSelectMode ? 'Exit Multi-Select' : 'Multi-Select',
|
|
child: IconButton(
|
|
padding: const EdgeInsets.all(8),
|
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
|
onPressed: () {
|
|
setState(() {
|
|
_isMultiSelectMode = !_isMultiSelectMode;
|
|
if (!_isMultiSelectMode) _selectedDocumentIds.clear();
|
|
});
|
|
},
|
|
icon: Icon(
|
|
Icons.checklist_rounded,
|
|
size: 20,
|
|
color: _isMultiSelectMode
|
|
? AppColors.primaryGreen
|
|
: (isDark ? AppColors.darkTextPrimary : AppColors.textSecondary),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
],
|
|
|
|
// 2. Print List (for non-teachers)
|
|
if (widget.userRole != 'teacher') ...[
|
|
_buildPrintQueueButton(compact: true),
|
|
const SizedBox(width: 2),
|
|
],
|
|
|
|
// 3. Upload Document (Tab 1 or opened folder)
|
|
if (_tabController.index == 1 || isFolderOpened) ...[
|
|
Tooltip(
|
|
message: 'Upload Document',
|
|
child: IconButton(
|
|
padding: const EdgeInsets.all(8),
|
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
|
icon: const Icon(
|
|
Icons.cloud_upload_rounded,
|
|
size: 22,
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
onPressed: () {
|
|
UploadOcrModal.show(
|
|
context,
|
|
prefilledStudentId:
|
|
_openedFolderStudentId ?? widget.initialStudentId,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
],
|
|
],
|
|
|
|
// Desktop action buttons (Upload available to admins and teachers)
|
|
if (!isMobile && _tabController.index != 2) ...[
|
|
SizedBox(
|
|
height: 36,
|
|
child: ElevatedButton.icon(
|
|
onPressed: () => UploadOcrModal.show(
|
|
context,
|
|
prefilledStudentId:
|
|
_openedFolderStudentId ?? widget.initialStudentId,
|
|
),
|
|
icon: const Icon(Icons.cloud_upload_outlined, size: 16),
|
|
label: const Text(
|
|
'Upload',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primaryGreen,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
],
|
|
|
|
if (defaultTargetPlatform != TargetPlatform.android && !isMobile && widget.userRole != 'teacher') ...[
|
|
SizedBox(
|
|
height: 36,
|
|
child: _buildPrintQueueButton(compact: false),
|
|
),
|
|
const SizedBox(width: 6),
|
|
],
|
|
|
|
// Dropdown Menu
|
|
SizedBox(
|
|
height: 36,
|
|
width: 36,
|
|
child: _buildMoreOptionsDropdown(isMobile),
|
|
),
|
|
|
|
if (!isMobile) ...[
|
|
const SizedBox(width: 2),
|
|
// Info Button for Download Guide (Desktop)
|
|
IconButton(
|
|
icon: const Icon(
|
|
Icons.info_outline,
|
|
color: AppColors.primaryGreen,
|
|
size: 20,
|
|
),
|
|
padding: const EdgeInsets.all(8),
|
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
|
tooltip: 'Download Guide',
|
|
onPressed: () {
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => const DownloadGuideDialog(),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
int _getActiveFilterCount() {
|
|
int count = 0;
|
|
if (_selectedDocumentType != 'All Types') count++;
|
|
if (_selectedGradeLevel != 'All Grades') count++;
|
|
if (_selectedSchoolYear != 'All Years') count++;
|
|
return count;
|
|
}
|
|
|
|
Widget _buildMoreOptionsDropdown(bool isMobile) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final isFolderOpened = _openedFolderStudentId != null;
|
|
|
|
return PopupMenuButton<String>(
|
|
icon: Icon(
|
|
Icons.more_vert,
|
|
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
|
),
|
|
tooltip: 'More Options',
|
|
position: PopupMenuPosition.under,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
onSelected: (value) {
|
|
if (value == 'multi_select') {
|
|
setState(() {
|
|
_isMultiSelectMode = !_isMultiSelectMode;
|
|
if (!_isMultiSelectMode) {
|
|
_selectedDocumentIds.clear();
|
|
}
|
|
});
|
|
} else if (value == 'grid_list') {
|
|
setState(() => _isGridView = !_isGridView);
|
|
} else if (value == 'archives') {
|
|
if (_openedFolderStudentId != null) {
|
|
StudentArchivesModal.show(
|
|
context,
|
|
studentId: _openedFolderStudentId!,
|
|
studentName: _openedFolderName ?? 'Student',
|
|
userRole: widget.userRole,
|
|
);
|
|
}
|
|
} else if (value == 'recycle_bin') {
|
|
showDialog(context: context, builder: (_) => const RecycleBinModal());
|
|
} else if (value == 'download_guide') {
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => const DownloadGuideDialog(),
|
|
);
|
|
}
|
|
},
|
|
itemBuilder: (context) => [
|
|
if (!isMobile &&
|
|
widget.userRole != 'teacher' &&
|
|
(_tabController.index == 1 || isFolderOpened)) ...[
|
|
PopupMenuItem(
|
|
value: 'multi_select',
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.checklist_rounded,
|
|
size: 20,
|
|
color: isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
_isMultiSelectMode ? 'Exit Multi-Select' : 'Select Multiple',
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const PopupMenuDivider(),
|
|
],
|
|
PopupMenuItem(
|
|
value: 'grid_list',
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
_isGridView ? Icons.view_list_rounded : Icons.grid_view_rounded,
|
|
size: 20,
|
|
color: isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
_isGridView ? 'Switch to List' : 'Switch to Grid',
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (isFolderOpened) ...[
|
|
const PopupMenuDivider(),
|
|
PopupMenuItem(
|
|
value: 'archives',
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.inventory_2_outlined,
|
|
size: 20,
|
|
color: isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
const Text('Archives', style: TextStyle(fontSize: 14)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
const PopupMenuDivider(),
|
|
PopupMenuItem(
|
|
value: 'recycle_bin',
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.delete_sweep,
|
|
size: 20,
|
|
color: isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
const Text('Recycle Bin', style: TextStyle(fontSize: 14)),
|
|
],
|
|
),
|
|
),
|
|
if (isMobile) ...[
|
|
const PopupMenuDivider(),
|
|
PopupMenuItem(
|
|
value: 'download_guide',
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.info_outline,
|
|
size: 20,
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Download Guide',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildPrintQueueButton({bool compact = false}) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final queueAsync = ref.watch(printQueueProvider);
|
|
final count = queueAsync.maybeWhen(
|
|
data: (items) => items.length,
|
|
orElse: () => 0,
|
|
);
|
|
|
|
if (compact) {
|
|
return Tooltip(
|
|
message: 'Print List',
|
|
child: IconButton(
|
|
padding: const EdgeInsets.all(8),
|
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
|
onPressed: () => PrintQueueModal.show(context),
|
|
icon: Badge(
|
|
isLabelVisible: count > 0,
|
|
label: Text(count.toString()),
|
|
child: Icon(
|
|
Icons.print_rounded,
|
|
size: 20,
|
|
color: isDark ? AppColors.darkTextPrimary : Colors.black87,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Tooltip(
|
|
message: 'Print List',
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => PrintQueueModal.show(context),
|
|
icon: Badge(
|
|
isLabelVisible: count > 0,
|
|
label: Text(count.toString()),
|
|
child: const Icon(Icons.print_outlined, size: 16),
|
|
),
|
|
label: compact
|
|
? const SizedBox.shrink()
|
|
: Text(
|
|
count > 0 ? 'Print List ($count)' : 'Print List',
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
|
side: BorderSide(color: isDark ? AppColors.darkBorder : Colors.grey.shade300),
|
|
padding: compact
|
|
? const EdgeInsets.all(10)
|
|
: const EdgeInsets.symmetric(horizontal: 14),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// FILTER MODAL
|
|
// ══════════════════════════════════════════════════════════════
|
|
void _openFilterDialog(
|
|
AsyncValue<List<dynamic>> requirementsAsync,
|
|
AsyncValue<List<dynamic>> academicYearsAsync,
|
|
AsyncValue<List<String>> statusesAsync,
|
|
) {
|
|
setState(() {
|
|
_pendingStatus = _selectedStatus;
|
|
_pendingDocumentType = _selectedDocumentType;
|
|
_pendingGradeLevel = _selectedGradeLevel;
|
|
_pendingSchoolYear = _selectedSchoolYear;
|
|
});
|
|
|
|
showDialog(
|
|
context: context,
|
|
barrierColor: Colors.black.withValues(alpha: 0.35),
|
|
builder: (ctx) => StatefulBuilder(
|
|
builder: (ctx, setDialogState) {
|
|
final isSmallScreen = MediaQuery.of(context).size.height < 600;
|
|
return Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
insetPadding: EdgeInsets.symmetric(
|
|
horizontal: 20,
|
|
vertical: isSmallScreen ? 16 : 36,
|
|
),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxWidth: 440,
|
|
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
|
),
|
|
child: _buildFilterPanelContent(
|
|
requirementsAsync,
|
|
academicYearsAsync,
|
|
statusesAsync,
|
|
setDialogState,
|
|
() {
|
|
setState(() {
|
|
_selectedStatus = _pendingStatus;
|
|
_selectedDocumentType = _pendingDocumentType;
|
|
_selectedGradeLevel = _pendingGradeLevel;
|
|
_selectedSchoolYear = _pendingSchoolYear;
|
|
});
|
|
_applyFilters();
|
|
Navigator.of(ctx).pop();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterPanelContent(
|
|
AsyncValue<List<dynamic>> requirementsAsync,
|
|
AsyncValue<List<dynamic>> academicYearsAsync,
|
|
AsyncValue<List<String>> statusesAsync,
|
|
StateSetter setDialogState,
|
|
VoidCallback onApply,
|
|
) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final jhsReqs = requirementsAsync.when(
|
|
data: (reqs) => [
|
|
'All JHS',
|
|
...reqs
|
|
.where((r) => r.category == 'JHS')
|
|
.map((r) => r.name as String)
|
|
.toSet()
|
|
.toList()
|
|
..sort(),
|
|
],
|
|
loading: () => ['All JHS'],
|
|
error: (err, stack) => ['All JHS'],
|
|
);
|
|
final shsReqs = requirementsAsync.when(
|
|
data: (reqs) => [
|
|
'All SHS',
|
|
...reqs
|
|
.where((r) => r.category == 'SHS')
|
|
.map((r) => r.name as String)
|
|
.toSet()
|
|
.toList()
|
|
..sort(),
|
|
],
|
|
loading: () => ['All SHS'],
|
|
error: (err, stack) => ['All SHS'],
|
|
);
|
|
final years = academicYearsAsync.when(
|
|
data: (y) => ['All Years', ...y.map((ay) => ay.yearRange as String)],
|
|
loading: () => ['All Years'],
|
|
error: (err, stack) => ['All Years'],
|
|
);
|
|
|
|
final jhsItems = jhsReqs.where((e) => e != 'All JHS').toList();
|
|
final shsItems = shsReqs.where((e) => e != 'All SHS').toList();
|
|
|
|
// Cache for use in _applyFilters
|
|
if (jhsItems.isNotEmpty) _jhsItems = jhsItems;
|
|
if (shsItems.isNotEmpty) _shsItems = shsItems;
|
|
|
|
final docTypeOptions = [
|
|
'All Types',
|
|
'All JHS',
|
|
'All SHS',
|
|
...jhsItems,
|
|
...shsItems,
|
|
];
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.12),
|
|
blurRadius: 24,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Header (Fixed)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 18, 16, 0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primaryGreen.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(
|
|
Icons.tune_rounded,
|
|
size: 20,
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
'Filter Documents',
|
|
style: TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w700,
|
|
color: isDark
|
|
? AppColors.darkTextPrimary
|
|
: AppColors.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.close_rounded,
|
|
size: 20,
|
|
color: isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary,
|
|
),
|
|
tooltip: 'Close',
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
Divider(
|
|
height: 20,
|
|
thickness: 1,
|
|
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
|
),
|
|
|
|
// Scrollable middle section for filter items
|
|
Flexible(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Document Type
|
|
_buildFilterSection(
|
|
label: 'Document Type',
|
|
hasActiveFilter: _pendingDocumentType != 'All Types',
|
|
onReset: () =>
|
|
setDialogState(() => _pendingDocumentType = 'All Types'),
|
|
child: _buildFilterDropdown(
|
|
value: docTypeOptions.contains(_pendingDocumentType)
|
|
? _pendingDocumentType
|
|
: 'All Types',
|
|
items: docTypeOptions,
|
|
onChanged: (v) =>
|
|
setDialogState(() => _pendingDocumentType = v!),
|
|
),
|
|
),
|
|
|
|
_buildDivider(isDark),
|
|
|
|
// Grade Level
|
|
_buildFilterSection(
|
|
label: 'Grade Level',
|
|
hasActiveFilter: _pendingGradeLevel != 'All Grades',
|
|
onReset: () =>
|
|
setDialogState(() => _pendingGradeLevel = 'All Grades'),
|
|
child: _buildFilterDropdown(
|
|
value: const [
|
|
'All Grades',
|
|
'7',
|
|
'8',
|
|
'9',
|
|
'10',
|
|
'11',
|
|
'12',
|
|
].contains(_pendingGradeLevel)
|
|
? _pendingGradeLevel
|
|
: 'All Grades',
|
|
items: const ['All Grades', '7', '8', '9', '10', '11', '12'],
|
|
labelBuilder: (g) =>
|
|
(g == 'All Grades' || g.toLowerCase().startsWith('grade'))
|
|
? g
|
|
: 'Grade $g',
|
|
onChanged: (v) =>
|
|
setDialogState(() => _pendingGradeLevel = v!),
|
|
),
|
|
),
|
|
|
|
_buildDivider(isDark),
|
|
|
|
// School Year
|
|
_buildFilterSection(
|
|
label: 'School Year',
|
|
hasActiveFilter: _pendingSchoolYear != 'All Years',
|
|
onReset: () =>
|
|
setDialogState(() => _pendingSchoolYear = 'All Years'),
|
|
child: _buildFilterDropdown(
|
|
value: years.contains(_pendingSchoolYear)
|
|
? _pendingSchoolYear
|
|
: 'All Years',
|
|
items: years,
|
|
onChanged: (v) =>
|
|
setDialogState(() => _pendingSchoolYear = v!),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
Divider(
|
|
height: 1,
|
|
thickness: 1,
|
|
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
|
),
|
|
|
|
// Footer buttons (Fixed at bottom)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 16),
|
|
child: Row(
|
|
children: [
|
|
// Reset all
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary,
|
|
side: BorderSide(
|
|
color: isDark
|
|
? AppColors.darkBorder
|
|
: Colors.grey.shade300,
|
|
),
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
),
|
|
onPressed: () {
|
|
setDialogState(() {
|
|
_pendingStatus = 'All Statuses';
|
|
_pendingDocumentType = 'All Types';
|
|
_pendingGradeLevel = 'All Grades';
|
|
_pendingSchoolYear = 'All Years';
|
|
});
|
|
},
|
|
child: const Text(
|
|
'Reset all',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 13.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
// Apply
|
|
Expanded(
|
|
flex: 2,
|
|
child: ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.primaryGreen,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
onPressed: onApply,
|
|
child: const Text(
|
|
'Apply Filters',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDivider(bool isDark) {
|
|
return Divider(
|
|
height: 1,
|
|
indent: 20,
|
|
endIndent: 20,
|
|
color: isDark
|
|
? AppColors.darkBorder.withValues(alpha: 0.5)
|
|
: Colors.grey.shade100,
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterSection({
|
|
required String label,
|
|
required VoidCallback onReset,
|
|
required Widget child,
|
|
bool hasActiveFilter = false,
|
|
}) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: isDark
|
|
? AppColors.darkTextPrimary
|
|
: AppColors.textPrimary,
|
|
),
|
|
),
|
|
if (hasActiveFilter)
|
|
GestureDetector(
|
|
onTap: onReset,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 4,
|
|
vertical: 2,
|
|
),
|
|
child: const Text(
|
|
'Reset',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.primaryGreen,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
child,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterDropdown({
|
|
required String value,
|
|
required List<String> items,
|
|
String Function(String)? labelBuilder,
|
|
ValueChanged<String?>? onChanged,
|
|
String? hint,
|
|
bool enabled = true,
|
|
}) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final selectedValue = items.contains(value) ? value : items.firstOrNull;
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: enabled
|
|
? (isDark ? AppColors.darkSurface2 : AppColors.surfaceWhite)
|
|
: (isDark ? AppColors.darkSurfaceCard : Colors.grey.shade100),
|
|
border: Border.all(
|
|
color: enabled
|
|
? (isDark ? AppColors.darkBorder : Colors.grey.shade300)
|
|
: (isDark
|
|
? AppColors.darkBorder.withValues(alpha: 0.5)
|
|
: Colors.grey.shade200),
|
|
),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: selectedValue,
|
|
isExpanded: true,
|
|
menuMaxHeight: 260,
|
|
borderRadius: BorderRadius.circular(12),
|
|
dropdownColor: isDark ? AppColors.darkSurfaceCard : Colors.white,
|
|
elevation: 4,
|
|
icon: Icon(
|
|
Icons.keyboard_arrow_down_rounded,
|
|
color: enabled
|
|
? (isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary)
|
|
: (isDark ? AppColors.darkTextMuted : AppColors.textMuted),
|
|
size: 22,
|
|
),
|
|
hint: hint != null
|
|
? Text(
|
|
hint,
|
|
style: TextStyle(
|
|
fontSize: 13.5,
|
|
color: isDark ? AppColors.darkTextMuted : AppColors.textMuted,
|
|
),
|
|
)
|
|
: null,
|
|
style: TextStyle(
|
|
fontSize: 13.5,
|
|
fontWeight: FontWeight.w500,
|
|
color: enabled
|
|
? (isDark ? AppColors.darkTextPrimary : AppColors.textPrimary)
|
|
: (isDark ? AppColors.darkTextMuted : AppColors.textMuted),
|
|
),
|
|
items: items.map((item) {
|
|
final displayLabel =
|
|
labelBuilder != null ? labelBuilder(item) : item;
|
|
final isItemActive = item == selectedValue;
|
|
return DropdownMenuItem<String>(
|
|
value: item,
|
|
child: Text(
|
|
displayLabel,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 13.5,
|
|
fontWeight:
|
|
isItemActive ? FontWeight.w600 : FontWeight.w400,
|
|
color: isItemActive
|
|
? AppColors.primaryGreen
|
|
: (isDark
|
|
? AppColors.darkTextPrimary
|
|
: AppColors.textPrimary),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
onChanged: enabled ? onChanged : null,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// FOLDERS TAB
|
|
// ══════════════════════════════════════════════════════════════
|
|
Widget _buildFoldersTab(
|
|
AsyncValue<List<dynamic>> foldersAsync,
|
|
AsyncValue<dynamic> docState,
|
|
DocumentQueryParams query,
|
|
bool isMobile,
|
|
double screenW,
|
|
) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
// If a folder is opened, show documents inline in this same tab
|
|
if (_openedFolderStudentId != null) {
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
child: docState.when(
|
|
loading: () => const Center(
|
|
child: CircularProgressIndicator(color: AppColors.primaryGreen),
|
|
),
|
|
error: (e, _) => _buildErrorState(e.toString()),
|
|
data: (pageData) {
|
|
final hasNoSections =
|
|
widget.userRole == 'teacher' &&
|
|
query.search.isEmpty &&
|
|
query.status.isEmpty &&
|
|
query.documentType.isEmpty &&
|
|
query.gradeLevel.isEmpty &&
|
|
query.schoolYear.isEmpty &&
|
|
pageData.total == 0;
|
|
final activeDocuments = pageData.documents
|
|
.where((d) => (d as DocumentModel).status != 'Archived')
|
|
.toList();
|
|
return activeDocuments.isEmpty
|
|
? _buildEmptyState(noSections: hasNoSections)
|
|
: _isGridView
|
|
? _buildGridView(
|
|
activeDocuments,
|
|
pageData.totalPages,
|
|
query.page,
|
|
)
|
|
: _buildListView(
|
|
activeDocuments,
|
|
pageData.totalPages,
|
|
query.page,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
return foldersAsync.when(
|
|
loading: () => const Center(
|
|
child: CircularProgressIndicator(color: AppColors.primaryGreen),
|
|
),
|
|
error: (e, _) => _buildErrorState(e.toString()),
|
|
data: (folders) {
|
|
if (folders.isEmpty) {
|
|
final hasNoSections =
|
|
widget.userRole == 'teacher' &&
|
|
query.search.isEmpty &&
|
|
query.status.isEmpty &&
|
|
query.documentType.isEmpty &&
|
|
query.gradeLevel.isEmpty &&
|
|
query.schoolYear.isEmpty;
|
|
|
|
if (hasNoSections) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: isDark ? AppColors.darkSurface2 : Colors.orange.shade50,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.class_outlined,
|
|
size: 56,
|
|
color: isDark ? Colors.orangeAccent : Colors.orange.shade400,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
'No sections assigned',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'You have no sections assigned to your account yet.\nContact your administrator to assign sections.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
|
fontSize: 13,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.folder_off_outlined,
|
|
size: 64,
|
|
color: isDark ? AppColors.darkTextMuted : Colors.grey.shade300,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No Student Folders',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
bool isFuzzyMatch(String text, String search) {
|
|
if (search.isEmpty) return true;
|
|
int j = 0;
|
|
for (int i = 0; i < text.length && j < search.length; i++) {
|
|
if (text[i].toLowerCase() == search[j].toLowerCase()) {
|
|
j++;
|
|
}
|
|
}
|
|
return j == search.length;
|
|
}
|
|
|
|
final filteredFolders = query.search.isEmpty
|
|
? folders
|
|
: folders.where((f) => isFuzzyMatch(f.name, query.search)).toList();
|
|
|
|
final int totalPages = (filteredFolders.length / _foldersPerPage)
|
|
.ceil();
|
|
final int startIndex = (_foldersPage - 1) * _foldersPerPage;
|
|
final int endIndex =
|
|
(startIndex + _foldersPerPage > filteredFolders.length)
|
|
? filteredFolders.length
|
|
: startIndex + _foldersPerPage;
|
|
final List<dynamic> paginatedFolders = filteredFolders.isEmpty
|
|
? []
|
|
: filteredFolders.sublist(startIndex, endIndex);
|
|
|
|
if (!_isGridView) {
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
margin: EdgeInsets.only(
|
|
left: isMobile ? 8 : 16,
|
|
right: isMobile ? 8 : 16,
|
|
top: isMobile ? 12 : 16,
|
|
bottom: isMobile ? 16 : 16,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: isMobile
|
|
? Border.all(
|
|
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
|
)
|
|
: null,
|
|
boxShadow: isMobile
|
|
? null
|
|
: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.04),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: ListView.separated(
|
|
padding: EdgeInsets.zero,
|
|
itemCount: paginatedFolders.length,
|
|
separatorBuilder: (context, index) =>
|
|
Divider(height: 1, color: isDark ? AppColors.darkBorder : Colors.grey.shade100),
|
|
itemBuilder: (ctx, i) {
|
|
final folder = paginatedFolders[i];
|
|
return RepaintBoundary(
|
|
child: GestureDetector(
|
|
onSecondaryTapDown: widget.userRole == 'teacher'
|
|
? null
|
|
: (details) => _showFolderContextMenu(
|
|
details.globalPosition,
|
|
folder,
|
|
),
|
|
onLongPressStart: widget.userRole == 'teacher'
|
|
? null
|
|
: (details) => _showFolderContextMenu(
|
|
details.globalPosition,
|
|
folder,
|
|
),
|
|
child: InkWell(
|
|
onTap: () {
|
|
if (folder.studentId != null) {
|
|
setState(() {
|
|
_openedFolderStudentId =
|
|
folder.studentId;
|
|
_openedFolderName = folder.name;
|
|
});
|
|
ref
|
|
.read(documentQueryProvider.notifier)
|
|
.setStudentId(folder.studentId);
|
|
_searchFocusNode.unfocus();
|
|
_searchController.clear();
|
|
ref
|
|
.read(documentQueryProvider.notifier)
|
|
.setSearch('');
|
|
}
|
|
},
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: isMobile ? 12 : 16,
|
|
vertical: isMobile ? 10 : 12,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.withValues(
|
|
alpha: isDark ? 0.16 : 0.10,
|
|
),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: const Center(
|
|
child: Icon(
|
|
Icons.folder_rounded,
|
|
size: 22,
|
|
color: Colors.orange,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
// Column: name + count files inside + JHS/SHS badges
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
folder.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 13,
|
|
color: isDark
|
|
? AppColors.darkTextPrimary
|
|
: AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'${folder.documentCount ?? 0} ${folder.documentCount == 1 ? "item" : "items"}',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: isDark
|
|
? AppColors.darkTextSecondary
|
|
: AppColors.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
_buildFolderCompletionBadge(
|
|
folder,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.chevron_right,
|
|
size: 18,
|
|
color: isDark
|
|
? AppColors.darkTextMuted
|
|
: AppColors.textMuted,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (totalPages > 1 && !_searchFocusNode.hasFocus)
|
|
_buildFoldersPagination(totalPages, _foldersPage),
|
|
],
|
|
);
|
|
}
|
|
|
|
return LayoutBuilder(
|
|
builder: (ctx, c) {
|
|
// On mobile use 2 columns; on desktop derive from comfortable tile width
|
|
int cols = isMobile
|
|
? 2
|
|
: (c.maxWidth / 160.0).floor().clamp(2, 6);
|
|
final childAspect = isMobile ? 0.85 : 0.95;
|
|
final grid = GridView.builder(
|
|
padding: EdgeInsets.only(
|
|
left: isMobile ? 10 : 16,
|
|
right: isMobile ? 10 : 16,
|
|
top: isMobile ? 14 : 16,
|
|
bottom: isMobile ? 24 : 16,
|
|
),
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: cols,
|
|
crossAxisSpacing: isMobile ? 10 : 14,
|
|
mainAxisSpacing: isMobile ? 10 : 14,
|
|
childAspectRatio: childAspect,
|
|
),
|
|
itemCount: paginatedFolders.length,
|
|
itemBuilder: (ctx, i) {
|
|
final folder = paginatedFolders[i];
|
|
return RepaintBoundary(
|
|
child: GestureDetector(
|
|
onSecondaryTapDown: widget.userRole == 'teacher'
|
|
? null
|
|
: (details) => _showFolderContextMenu(
|
|
details.globalPosition,
|
|
folder,
|
|
),
|
|
onLongPressStart: widget.userRole == 'teacher'
|
|
? null
|
|
: (details) => _showFolderContextMenu(
|
|
details.globalPosition,
|
|
folder,
|
|
),
|
|
child: InkWell(
|
|
onTap: () {
|
|
if (folder.studentId != null) {
|
|
setState(() {
|
|
_openedFolderStudentId = folder.studentId;
|
|
_openedFolderName = folder.name;
|
|
});
|
|
ref
|
|
.read(documentQueryProvider.notifier)
|
|
.setStudentId(folder.studentId);
|
|
_searchFocusNode.unfocus();
|
|
_searchController.clear();
|
|
ref.read(documentQueryProvider.notifier).setSearch('');
|
|
}
|
|
},
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: isDark ? AppColors.darkBorder : Colors.grey.shade200),
|
|
boxShadow: isMobile
|
|
? null
|
|
: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.03),
|
|
blurRadius: 6,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
|
child: Center(
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.folder_rounded,
|
|
size: isMobile ? 38 : 46,
|
|
color: Colors.orange,
|
|
),
|
|
SizedBox(height: isMobile ? 6 : 8),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
child: Text(
|
|
folder.name,
|
|
textAlign: TextAlign.center,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: isMobile ? 12 : 13,
|
|
height: 1.2,
|
|
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: _buildFolderCompletionBadge(folder),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
return Column(
|
|
children: [
|
|
Expanded(child: grid),
|
|
if (totalPages > 1 && !_searchFocusNode.hasFocus)
|
|
_buildFoldersPagination(totalPages, _foldersPage),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildFolderCompletionBadge(dynamic folder) {
|
|
final FolderModel f = folder as FolderModel;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final pills = <Widget>[];
|
|
|
|
// JHS Pill
|
|
final jhsTotal = f.jhsTotal;
|
|
final jhsDone = f.jhsCompleted;
|
|
final jhsComplete = jhsTotal > 0 && jhsDone >= jhsTotal;
|
|
final jhsColor = jhsComplete
|
|
? (isDark ? Colors.green.shade300 : Colors.green)
|
|
: (isDark ? Colors.orange.shade300 : Colors.orange);
|
|
pills.add(
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: jhsColor.withValues(alpha: isDark ? 0.20 : 0.12),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: jhsColor.withValues(alpha: isDark ? 0.45 : 0.35)),
|
|
),
|
|
child: Text(
|
|
'JHS $jhsDone/$jhsTotal',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: jhsColor,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// SHS Pill
|
|
final shsTotal = f.shsTotal;
|
|
final shsDone = f.shsCompleted;
|
|
final shsComplete = shsTotal > 0 && shsDone >= shsTotal;
|
|
final shsColor = shsComplete
|
|
? (isDark ? Colors.green.shade300 : Colors.green)
|
|
: (isDark ? Colors.blue.shade300 : Colors.blue.shade700);
|
|
pills.add(
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: shsColor.withValues(alpha: isDark ? 0.20 : 0.12),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: shsColor.withValues(alpha: isDark ? 0.45 : 0.35)),
|
|
),
|
|
child: Text(
|
|
'SHS $shsDone/$shsTotal',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: shsColor,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (pills.isEmpty) return const SizedBox.shrink();
|
|
return Wrap(
|
|
spacing: 4,
|
|
runSpacing: 3,
|
|
alignment: WrapAlignment.center,
|
|
children: pills,
|
|
);
|
|
}
|
|
|
|
Widget _buildGridView(List documents, int totalPages, int currentPage) {
|
|
final screenW = MediaQuery.of(context).size.width;
|
|
final isMobileGrid = screenW < 700;
|
|
return LayoutBuilder(
|
|
builder: (ctx, c) {
|
|
int cols = isMobileGrid
|
|
? 2
|
|
: (c.maxWidth / 160.0).floor().clamp(2, 6);
|
|
final aspect = isMobileGrid ? 0.82 : 0.95;
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
child: GridView.builder(
|
|
padding: EdgeInsets.only(
|
|
left: isMobileGrid ? 10 : 16,
|
|
right: isMobileGrid ? 10 : 16,
|
|
top: isMobileGrid ? 12 : 16,
|
|
bottom: isMobileGrid ? 24 : 16,
|
|
),
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: cols,
|
|
crossAxisSpacing: isMobileGrid ? 10 : 12,
|
|
mainAxisSpacing: isMobileGrid ? 10 : 12,
|
|
childAspectRatio: aspect,
|
|
),
|
|
itemCount: documents.length,
|
|
itemBuilder: (ctx, i) => FileFolderCard(
|
|
document: documents[i],
|
|
isGrid: true,
|
|
userRole: widget.userRole,
|
|
isMultiSelectMode: _isMultiSelectMode,
|
|
isSelected: _selectedDocumentIds.contains(documents[i].id),
|
|
onIconTap: () {
|
|
setState(() {
|
|
if (!_isMultiSelectMode) {
|
|
_isMultiSelectMode = true;
|
|
_selectedDocumentIds.add(documents[i].id);
|
|
} else {
|
|
if (_selectedDocumentIds.contains(documents[i].id)) {
|
|
_selectedDocumentIds.remove(documents[i].id);
|
|
} else {
|
|
_selectedDocumentIds.add(documents[i].id);
|
|
}
|
|
}
|
|
});
|
|
},
|
|
onSelectedChanged: (val) {
|
|
setState(() {
|
|
if (val == true) {
|
|
_selectedDocumentIds.add(documents[i].id);
|
|
_isMultiSelectMode = true;
|
|
} else {
|
|
_selectedDocumentIds.remove(documents[i].id);
|
|
}
|
|
});
|
|
},
|
|
onTap: () {
|
|
if (_isMultiSelectMode) {
|
|
setState(() {
|
|
if (_selectedDocumentIds.contains(documents[i].id)) {
|
|
_selectedDocumentIds.remove(documents[i].id);
|
|
} else {
|
|
_selectedDocumentIds.add(documents[i].id);
|
|
}
|
|
});
|
|
} else {
|
|
showDocumentPreview(
|
|
context: context,
|
|
document: documents[i],
|
|
);
|
|
}
|
|
},
|
|
onActionSelected: (a) => _handleAction(a, documents[i]),
|
|
onViewProfile: (sid) => showStudentProfileModal(
|
|
context,
|
|
studentId: sid,
|
|
userRole: widget.userRole,
|
|
hideEnrollmentActions: true,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (totalPages > 1 && !_searchFocusNode.hasFocus)
|
|
_buildPagination(totalPages, currentPage)
|
|
else if (_openedFolderStudentId != null)
|
|
SizedBox(height: isMobileGrid ? 76 : 16),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// LIST VIEW
|
|
// ══════════════════════════════════════════════════════════════
|
|
Widget _buildListView(List documents, int totalPages, int currentPage) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
final screenW = MediaQuery.of(context).size.width;
|
|
final isMobileList = screenW < 700;
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
margin: EdgeInsets.only(
|
|
left: isMobileList ? 8 : 16,
|
|
right: isMobileList ? 8 : 16,
|
|
top: isMobileList ? 12 : 16,
|
|
bottom: isMobileList ? 16 : 16,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: isMobileList
|
|
? Border.all(
|
|
color: isDark ? AppColors.darkBorder : Colors.grey.shade200,
|
|
)
|
|
: null,
|
|
boxShadow: isMobileList
|
|
? null
|
|
: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.04),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: RefreshIndicator(
|
|
onRefresh: () async {
|
|
ref.invalidate(documentPageProvider);
|
|
},
|
|
child: ListView.separated(
|
|
padding: EdgeInsets.zero,
|
|
itemCount: documents.length,
|
|
separatorBuilder: (ctx, i) =>
|
|
Divider(height: 1, color: isDark ? AppColors.darkBorder : Colors.grey.shade100),
|
|
itemBuilder: (ctx, i) {
|
|
final doc = documents[i] as DocumentModel;
|
|
return FileFolderCard(
|
|
document: doc,
|
|
isGrid: false,
|
|
userRole: widget.userRole,
|
|
isMultiSelectMode: _isMultiSelectMode,
|
|
isSelected: _selectedDocumentIds.contains(doc.id),
|
|
onIconTap: () {
|
|
setState(() {
|
|
_isMultiSelectMode = true;
|
|
_selectedDocumentIds.add(doc.id);
|
|
});
|
|
},
|
|
onSelectedChanged: (val) {
|
|
setState(() {
|
|
if (val == true) {
|
|
_selectedDocumentIds.add(doc.id);
|
|
} else {
|
|
_selectedDocumentIds.remove(doc.id);
|
|
}
|
|
});
|
|
},
|
|
onTap: () {
|
|
if (_isMultiSelectMode) {
|
|
setState(() {
|
|
if (_selectedDocumentIds.contains(doc.id)) {
|
|
_selectedDocumentIds.remove(doc.id);
|
|
} else {
|
|
_selectedDocumentIds.add(doc.id);
|
|
}
|
|
});
|
|
} else {
|
|
showDocumentPreview(
|
|
context: context,
|
|
document: doc,
|
|
);
|
|
}
|
|
},
|
|
onActionSelected: (a) => _handleAction(a, doc),
|
|
onViewProfile: (sid) => showStudentProfileModal(
|
|
context,
|
|
studentId: sid,
|
|
userRole: widget.userRole,
|
|
hideEnrollmentActions: true,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (totalPages > 1 && !_searchFocusNode.hasFocus)
|
|
_buildPagination(totalPages, currentPage)
|
|
else if (_openedFolderStudentId != null)
|
|
SizedBox(height: isMobileList ? 76 : 16),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// EMPTY / ERROR STATES
|
|
// ══════════════════════════════════════════════════════════════
|
|
Widget _buildEmptyState({bool noSections = false}) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
if (noSections) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade50,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.class_outlined,
|
|
size: 56,
|
|
color: isDark ? Colors.orangeAccent : Colors.orange.shade400,
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
'No sections assigned',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'You have no sections assigned to your account yet.\nContact your administrator to assign sections.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
|
fontSize: 13,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.folder_off_outlined,
|
|
size: 72,
|
|
color: isDark ? AppColors.darkTextMuted : Colors.grey.shade300,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No documents found',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Upload a document or adjust your filters',
|
|
style: TextStyle(color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary),
|
|
),
|
|
if (_selectedDocumentType != 'All Types' ||
|
|
_selectedGradeLevel != 'All Grades' ||
|
|
_selectedSchoolYear != 'All Years' ||
|
|
_searchController.text.isNotEmpty) ...[
|
|
const SizedBox(height: 16),
|
|
TextButton.icon(
|
|
onPressed: _clearFilters,
|
|
icon: const Icon(Icons.clear_all),
|
|
label: const Text('Clear Filters'),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildErrorState(String message) {
|
|
return AppErrorState.fromError(
|
|
error: message,
|
|
onRetry: () {
|
|
ref.invalidate(documentPageProvider);
|
|
ref.invalidate(foldersProvider);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// PAGINATION
|
|
// ══════════════════════════════════════════════════════════════
|
|
Widget _buildPagination(int totalPages, int currentPage) {
|
|
if (_searchFocusNode.hasFocus) return const SizedBox.shrink();
|
|
return AppPagination(
|
|
currentPage: currentPage,
|
|
totalPages: totalPages,
|
|
onPageChanged: (p) =>
|
|
ref.read(documentQueryProvider.notifier).setPage(p),
|
|
);
|
|
}
|
|
|
|
Widget _buildFoldersPagination(int totalPages, int currentPage) {
|
|
if (_searchFocusNode.hasFocus) return const SizedBox.shrink();
|
|
return AppPagination(
|
|
currentPage: currentPage,
|
|
totalPages: totalPages,
|
|
onPageChanged: (p) => setState(() => _foldersPage = p),
|
|
);
|
|
}
|
|
|
|
Future<void> _showFolderContextMenu(
|
|
Offset position,
|
|
dynamic folder,
|
|
) async {
|
|
if (folder.studentId == null) return;
|
|
|
|
final RenderBox overlay =
|
|
Overlay.of(context).context.findRenderObject() as RenderBox;
|
|
final value = await showMenu<String>(
|
|
context: context,
|
|
position: RelativeRect.fromRect(
|
|
position & const Size(40, 40),
|
|
Offset.zero & overlay.size,
|
|
),
|
|
items: [
|
|
const PopupMenuItem(
|
|
value: 'open',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.folder_open, size: 18, color: Colors.orange),
|
|
SizedBox(width: 12),
|
|
Text('Open Folder', style: TextStyle(fontSize: 14)),
|
|
],
|
|
),
|
|
),
|
|
const PopupMenuDivider(),
|
|
const PopupMenuItem(
|
|
value: 'view_profile',
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.person, size: 18, color: AppColors.primaryGreen),
|
|
SizedBox(width: 12),
|
|
Text('View Student Profile', style: TextStyle(fontSize: 14)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
if (!mounted || value == null) return;
|
|
if (value == 'open') {
|
|
setState(() {
|
|
_openedFolderStudentId = folder.studentId;
|
|
_openedFolderName = folder.name;
|
|
});
|
|
ref.read(documentQueryProvider.notifier).setStudentId(folder.studentId);
|
|
} else if (value == 'view_profile') {
|
|
showStudentProfileModal(
|
|
context,
|
|
studentId: folder.studentId!,
|
|
userRole: widget.userRole,
|
|
hideEnrollmentActions: true,
|
|
);
|
|
}
|
|
}
|
|
}
|