3186 lines
116 KiB
Dart
3186 lines
116 KiB
Dart
import 'dart:async';
|
||
import 'dart:io';
|
||
import 'package:flutter/foundation.dart'
|
||
show defaultTargetPlatform, TargetPlatform;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:desktop_drop/desktop_drop.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/widgets/app_pagination.dart';
|
||
import '../../providers/navigation_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 '../../../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 GlobalKey _searchBarKey = GlobalKey();
|
||
Timer? _debounce;
|
||
bool _isGridView = false;
|
||
late final TabController _tabController;
|
||
bool _isDragOver = false;
|
||
|
||
// --- 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);
|
||
}
|
||
}
|
||
});
|
||
|
||
// If a specific student was passed, jump to Folders tab and open that folder
|
||
final initialFolder = ref.read(openedFolderProvider);
|
||
if (initialFolder != null) {
|
||
_openedFolderStudentId = initialFolder.id;
|
||
_openedFolderName = initialFolder.name;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
ref.read(documentQueryProvider.notifier).setStudentId(initialFolder.id);
|
||
});
|
||
} else if (widget.initialStudentId != null) {
|
||
_openedFolderStudentId = widget.initialStudentId;
|
||
_openedFolderName = 'Student Documents';
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
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;
|
||
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;
|
||
}
|
||
}
|
||
});
|
||
|
||
_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();
|
||
_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 == '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';
|
||
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('Download started...'),
|
||
duration: Duration(seconds: 2),
|
||
),
|
||
);
|
||
await DownloadService.downloadFile(
|
||
url: url,
|
||
fileName: document.fileName,
|
||
);
|
||
if (!context.mounted) return;
|
||
showSuccessDialog(
|
||
context,
|
||
message: 'Document downloaded successfully.',
|
||
);
|
||
} catch (e) {
|
||
if (!context.mounted) return;
|
||
showErrorDialog(context, 'Download Failed', e.toString());
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
'Downloading ${_selectedDocumentIds.length} documents...',
|
||
),
|
||
duration: const Duration(seconds: 2),
|
||
),
|
||
);
|
||
|
||
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 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 Selected Documents',
|
||
style: TextStyle(color: AppColors.error, fontSize: 17),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
content: Text(
|
||
'Are you sure you want to permanently delete these ${_selectedDocumentIds.length} documents?',
|
||
),
|
||
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: 'Selected documents deleted successfully.',
|
||
);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
showErrorDialog(
|
||
context,
|
||
'Delete Failed',
|
||
e.toString().replaceFirst('Exception: ', ''),
|
||
);
|
||
}
|
||
}
|
||
|
||
Widget _buildInlineMultiSelectHeader() {
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
final count = _selectedDocumentIds.length;
|
||
final isAdmin = widget.userRole != 'teacher';
|
||
|
||
final pageDocs = ref.watch(documentPageProvider).value?.documents ?? [];
|
||
final pageIds = pageDocs.map((d) => d.id).toSet();
|
||
final bool allSelected = pageIds.isNotEmpty && pageIds.every((id) => _selectedDocumentIds.contains(id));
|
||
final buttonColor = isDark ? Colors.white : Colors.black;
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? AppColors.darkSurfaceCard : AppColors.primaryGreen.withValues(alpha: 0.1),
|
||
border: Border(
|
||
bottom: BorderSide(color: isDark ? AppColors.darkBorder : AppColors.primaryGreen.withValues(alpha: 0.2)),
|
||
),
|
||
),
|
||
child: SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: [
|
||
Tooltip(
|
||
message: 'Cancel selection',
|
||
child: IconButton(
|
||
icon: Icon(Icons.close, color: buttonColor),
|
||
onPressed: () => setState(() {
|
||
_selectedDocumentIds.clear();
|
||
_isMultiSelectMode = false;
|
||
}),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
count == 0 ? 'Select items' : '$count selected',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 16,
|
||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Tooltip(
|
||
message: allSelected ? 'Unselect All' : 'Select All',
|
||
child: IconButton(
|
||
icon: Icon(
|
||
allSelected ? Icons.deselect : Icons.select_all,
|
||
color: buttonColor,
|
||
),
|
||
onPressed: pageDocs.isEmpty
|
||
? null
|
||
: () {
|
||
setState(() {
|
||
if (allSelected) {
|
||
_selectedDocumentIds.clear();
|
||
} else {
|
||
_selectedDocumentIds.addAll(pageIds);
|
||
}
|
||
});
|
||
},
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Container(width: 1, height: 24, color: isDark ? AppColors.darkBorder : Colors.grey.shade400),
|
||
const SizedBox(width: 12),
|
||
Tooltip(
|
||
message: 'Print',
|
||
child: IconButton(
|
||
icon: Icon(Icons.print_rounded, color: buttonColor),
|
||
onPressed: count == 0 ? null : _handleBatchPrint,
|
||
),
|
||
),
|
||
Tooltip(
|
||
message: 'Copy',
|
||
child: IconButton(
|
||
icon: Icon(Icons.copy_rounded, color: buttonColor),
|
||
onPressed: count == 0 ? null : _handleBatchCopy,
|
||
),
|
||
),
|
||
Tooltip(
|
||
message: 'Download',
|
||
child: IconButton(
|
||
icon: Icon(Icons.download_rounded, color: buttonColor),
|
||
onPressed: count == 0 ? null : _handleBatchDownload,
|
||
),
|
||
),
|
||
Tooltip(
|
||
message: 'Complete',
|
||
child: IconButton(
|
||
icon: Icon(Icons.check_circle_outline_rounded, color: buttonColor),
|
||
onPressed: count == 0 ? null : () => _handleBatchStatus('Completed'),
|
||
),
|
||
),
|
||
Tooltip(
|
||
message: 'Archive',
|
||
child: IconButton(
|
||
icon: Icon(Icons.archive_outlined, color: buttonColor),
|
||
onPressed: count == 0 ? null : () => _handleBatchStatus('Archived'),
|
||
),
|
||
),
|
||
if (isAdmin)
|
||
Tooltip(
|
||
message: 'Delete',
|
||
child: IconButton(
|
||
icon: Icon(Icons.delete_outline_rounded, color: buttonColor),
|
||
onPressed: count == 0 ? null : _handleBatchDelete,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
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 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: _buildDragDropWrapper(
|
||
context,
|
||
showHint: _tabController.index == 1 && !isFolderOpened,
|
||
child: Scaffold(
|
||
backgroundColor: Colors.transparent,
|
||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||
floatingActionButton:
|
||
(_tabController.index != 2 &&
|
||
!_isMultiSelectMode &&
|
||
!_searchFocusNode.hasFocus)
|
||
? Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
// Left FAB: Print List (Only for Android/mobile app)
|
||
if (isMobile)
|
||
Badge(
|
||
label: Text(
|
||
'${ref.watch(printQueueProvider).value?.length ?? 0}',
|
||
),
|
||
isLabelVisible:
|
||
(ref.watch(printQueueProvider).value?.length ?? 0) >
|
||
0,
|
||
backgroundColor: AppColors.error,
|
||
offset: const Offset(4, -4),
|
||
child: FloatingActionButton(
|
||
heroTag: 'fab-print-list',
|
||
backgroundColor: AppColors.primaryGreen,
|
||
shape: const CircleBorder(),
|
||
onPressed: () {
|
||
PrintQueueModal.show(context);
|
||
},
|
||
child: const Icon(Icons.print, color: Colors.white),
|
||
),
|
||
)
|
||
else
|
||
const SizedBox.shrink(),
|
||
// Right FAB: Upload Document
|
||
if (isMobile)
|
||
if (_tabController.index == 1 || isFolderOpened)
|
||
FloatingActionButton(
|
||
heroTag: 'fab-upload-doc',
|
||
backgroundColor: AppColors.primaryGreen,
|
||
shape: const CircleBorder(),
|
||
onPressed: () {
|
||
UploadOcrModal.show(
|
||
context,
|
||
prefilledStudentId:
|
||
_openedFolderStudentId ??
|
||
widget.initialStudentId,
|
||
);
|
||
},
|
||
child: const Icon(
|
||
Icons.cloud_upload,
|
||
color: Colors.white,
|
||
),
|
||
)
|
||
else
|
||
const SizedBox.shrink()
|
||
else
|
||
const SizedBox.shrink(),
|
||
],
|
||
),
|
||
)
|
||
: 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,
|
||
),
|
||
|
||
// ── TabBar ──
|
||
Container(
|
||
color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
|
||
child: TabBar(
|
||
controller: _tabController,
|
||
onTap: (index) {
|
||
// Only run reset behavior if tapping the already active tab
|
||
if (index == _tabController.index) {
|
||
if (index == 0 && _openedFolderStudentId != null) {
|
||
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);
|
||
}
|
||
}
|
||
},
|
||
labelColor: AppColors.primaryGreen,
|
||
unselectedLabelColor: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||
indicatorColor: AppColors.primaryGreen,
|
||
indicatorWeight: 2.5,
|
||
labelStyle: const TextStyle(
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 13,
|
||
),
|
||
tabs: const [
|
||
Tab(
|
||
text: 'Student Folders',
|
||
),
|
||
Tab(
|
||
text: 'All Documents',
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
const Divider(height: 1),
|
||
|
||
// ── 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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── DRAG AND DROP WRAPPER (Windows only) ──────────────────────────────────
|
||
Widget _buildDragDropWrapper(BuildContext context, {
|
||
required Widget child,
|
||
bool showHint = false,
|
||
}) {
|
||
final isWindows = defaultTargetPlatform == TargetPlatform.windows;
|
||
if (!isWindows) return child;
|
||
|
||
return DropTarget(
|
||
onDragEntered: (details) => setState(() => _isDragOver = true),
|
||
onDragExited: (details) => setState(() => _isDragOver = false),
|
||
onDragDone: (details) {
|
||
setState(() => _isDragOver = false);
|
||
// Only process drop when on a valid tab (All Files or inside a student folder)
|
||
if (!showHint) return;
|
||
final validFiles = details.files.where((xfile) {
|
||
final ext = xfile.path.toLowerCase();
|
||
return ext.endsWith('.pdf') ||
|
||
ext.endsWith('.jpg') ||
|
||
ext.endsWith('.jpeg') ||
|
||
ext.endsWith('.png') ||
|
||
ext.endsWith('.xlsx') ||
|
||
ext.endsWith('.xls') ||
|
||
ext.endsWith('.csv');
|
||
}).map((x) => File(x.path)).toList();
|
||
|
||
if (validFiles.isNotEmpty) {
|
||
UploadOcrModal.show(
|
||
context,
|
||
prefilledStudentId:
|
||
_openedFolderStudentId ?? widget.initialStudentId,
|
||
preloadedFiles: validFiles,
|
||
);
|
||
}
|
||
},
|
||
child: Stack(
|
||
children: [
|
||
child,
|
||
// ── Idle hint removed from document tabs (only shown in Upload Modal) ──
|
||
// ── Active drop overlay (only when on a valid tab) ──
|
||
if (_isDragOver && showHint)
|
||
Positioned.fill(
|
||
child: IgnorePointer(
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.primaryGreen.withValues(alpha: 0.08),
|
||
border: Border.all(
|
||
color: AppColors.primaryGreen,
|
||
width: 2.5,
|
||
strokeAlign: BorderSide.strokeAlignInside,
|
||
),
|
||
),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.cloud_upload_outlined,
|
||
size: 64,
|
||
color: AppColors.primaryGreen.withValues(alpha: 0.8),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
'Drop files to upload',
|
||
style: TextStyle(
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.bold,
|
||
color:
|
||
AppColors.primaryGreen.withValues(alpha: 0.9),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'PDF, JPG, PNG supported',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color:
|
||
AppColors.primaryGreen.withValues(alpha: 0.6),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showSearchDialog(BuildContext context) {
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
showDialog(
|
||
context: context,
|
||
barrierColor: Colors.black54,
|
||
builder: (context) {
|
||
return Align(
|
||
alignment: Alignment.topCenter,
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(top: kToolbarHeight + 24),
|
||
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);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
_searchFocusNode.requestFocus();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// TOP HEADER
|
||
// ══════════════════════════════════════════════════════════════
|
||
Widget _buildTopHeader(
|
||
bool isMobile,
|
||
bool isStudentFiltered,
|
||
DocumentQueryParams query,
|
||
bool isFolderOpened,
|
||
AsyncValue<List<dynamic>> requirementsAsync,
|
||
AsyncValue<List<dynamic>> academicYearsAsync,
|
||
AsyncValue<List<String>> statusesAsync,
|
||
) {
|
||
final hPad = isMobile ? 12.0 : 20.0;
|
||
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
|
||
return Container(
|
||
color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
|
||
padding: EdgeInsets.symmetric(horizontal: hPad, vertical: 12),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
// Back button if folder opened
|
||
if (isFolderOpened) ...[
|
||
IconButton(
|
||
icon: const Icon(
|
||
Icons.arrow_back_ios_new_rounded,
|
||
size: 20,
|
||
),
|
||
color: AppColors.primaryGreen,
|
||
tooltip: 'Back to Folders',
|
||
onPressed: () {
|
||
setState(() {
|
||
_openedFolderStudentId = null;
|
||
_openedFolderName = null;
|
||
});
|
||
ref.read(openedFolderProvider.notifier).setFolder(null);
|
||
ref.read(documentQueryProvider.notifier).setStudentId(null);
|
||
},
|
||
),
|
||
const SizedBox(width: 6),
|
||
],
|
||
|
||
// Screen title
|
||
Expanded(
|
||
child: _openedFolderName != null
|
||
? Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (!isMobile) ...[
|
||
InkWell(
|
||
onTap: () {
|
||
setState(() {
|
||
_openedFolderStudentId = null;
|
||
_openedFolderName = null;
|
||
});
|
||
ref
|
||
.read(openedFolderProvider.notifier)
|
||
.setFolder(null);
|
||
ref
|
||
.read(documentQueryProvider.notifier)
|
||
.setStudentId(null);
|
||
},
|
||
child: Text(
|
||
'Student Folders',
|
||
style: TextStyle(
|
||
fontSize: 21,
|
||
fontWeight: FontWeight.w500,
|
||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
Text(
|
||
' / ',
|
||
style: TextStyle(
|
||
fontSize: 21,
|
||
fontWeight: FontWeight.w500,
|
||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
Expanded(
|
||
child: Text(
|
||
_openedFolderName!,
|
||
style: TextStyle(
|
||
fontSize: isMobile ? 17 : 21,
|
||
fontWeight: FontWeight.bold,
|
||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
)
|
||
: Text(
|
||
_tabController.index == 0
|
||
? 'Student Folders'
|
||
: _tabController.index == 1
|
||
? (isStudentFiltered
|
||
? 'Student Documents'
|
||
: 'All Documents')
|
||
: 'Recycle Bin',
|
||
style: TextStyle(
|
||
fontSize: isMobile ? 17 : 21,
|
||
fontWeight: FontWeight.bold,
|
||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
|
||
const SizedBox(width: 16),
|
||
|
||
// Clear student filter chip (compact)
|
||
if (isStudentFiltered && !isFolderOpened) ...[
|
||
const SizedBox(width: 12),
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
|
||
if (!isFolderOpened) ...[
|
||
IconButton(
|
||
icon: Icon(
|
||
(_searchController.text.isNotEmpty || query.search.isNotEmpty)
|
||
? Icons.close
|
||
: Icons.search,
|
||
size: 28,
|
||
color: isDark ? AppColors.darkTextPrimary : Colors.black87,
|
||
),
|
||
tooltip:
|
||
(_searchController.text.isNotEmpty || query.search.isNotEmpty)
|
||
? 'Clear Search'
|
||
: 'Search Documents',
|
||
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: 8),
|
||
|
||
// Filter button (Icon only, no background, no border)
|
||
if (_tabController.index == 1 || isFolderOpened) ...[
|
||
Tooltip(
|
||
message: 'Filter Documents',
|
||
child: IconButton(
|
||
onPressed: () => _openFilterDialog(
|
||
requirementsAsync,
|
||
academicYearsAsync,
|
||
statusesAsync,
|
||
),
|
||
icon: Badge(
|
||
isLabelVisible: _getActiveFilterCount() > 0,
|
||
label: Text(_getActiveFilterCount().toString()),
|
||
child: const Icon(
|
||
Icons.tune_rounded,
|
||
size: 20,
|
||
color: AppColors.primaryGreen,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
],
|
||
|
||
// Desktop action buttons (Upload available to all roles)
|
||
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: 8),
|
||
],
|
||
|
||
if (defaultTargetPlatform != TargetPlatform.android && !isMobile) ...[
|
||
SizedBox(
|
||
height: 36,
|
||
child: _buildPrintQueueButton(compact: false),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
|
||
// Dropdown Menu
|
||
SizedBox(height: 38, child: _buildMoreOptionsDropdown(isMobile)),
|
||
|
||
const SizedBox(width: 4),
|
||
|
||
// Info Button for Download Guide (Moved to right end)
|
||
IconButton(
|
||
icon: const Icon(
|
||
Icons.info_outline,
|
||
color: AppColors.primaryGreen,
|
||
size: 20,
|
||
),
|
||
tooltip: 'Download Guide',
|
||
onPressed: () {
|
||
showDialog(
|
||
context: context,
|
||
builder: (_) => const DownloadGuideDialog(),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
int _getActiveFilterCount() {
|
||
int count = 0;
|
||
if (_selectedStatus != 'All Statuses') count++;
|
||
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;
|
||
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 == 'recycle_bin') {
|
||
showDialog(context: context, builder: (_) => const RecycleBinModal());
|
||
}
|
||
},
|
||
itemBuilder: (context) => [
|
||
if (_tabController.index == 1 || _openedFolderStudentId != null) ...[
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
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,
|
||
);
|
||
|
||
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.25),
|
||
builder: (ctx) => StatefulBuilder(
|
||
builder: (ctx, setDialogState) {
|
||
return Dialog(
|
||
backgroundColor: Colors.transparent,
|
||
insetPadding: const EdgeInsets.symmetric(
|
||
horizontal: 20,
|
||
vertical: 40,
|
||
),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 440),
|
||
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;
|
||
const defaultStatuses = [
|
||
'All Statuses',
|
||
'Pending',
|
||
'Verified',
|
||
'Draft',
|
||
'Archived',
|
||
];
|
||
final statusItems = statusesAsync.when(
|
||
data: (s) => (defaultStatuses.toSet()..addAll(s)).toList(),
|
||
loading: () => defaultStatuses,
|
||
error: (_, st) => defaultStatuses,
|
||
);
|
||
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(
|
||
margin: const EdgeInsets.only(top: 10),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: isDark ? AppColors.darkBorder : Colors.grey.shade200),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.07),
|
||
blurRadius: 16,
|
||
offset: const Offset(0, 4),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// Header
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||
child: Row(
|
||
children: [
|
||
const Icon(
|
||
Icons.tune_rounded,
|
||
size: 18,
|
||
color: AppColors.primaryGreen,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
'Filter Documents',
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.bold,
|
||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
const Divider(height: 20),
|
||
|
||
// Status
|
||
_buildFilterSection(
|
||
label: 'Status',
|
||
onReset: () =>
|
||
setDialogState(() => _pendingStatus = 'All Statuses'),
|
||
child: _buildFilterChipGroup(
|
||
items: statusItems,
|
||
selectedValue: statusItems.contains(_pendingStatus)
|
||
? _pendingStatus
|
||
: 'All Statuses',
|
||
onSelected: (v) => setDialogState(() => _pendingStatus = v),
|
||
),
|
||
),
|
||
|
||
const Divider(height: 1, indent: 20, endIndent: 20),
|
||
|
||
// Document Type
|
||
_buildFilterSection(
|
||
label: 'Document Type',
|
||
onReset: () =>
|
||
setDialogState(() => _pendingDocumentType = 'All Types'),
|
||
child: _buildFilterDropdown(
|
||
value: docTypeOptions.contains(_pendingDocumentType)
|
||
? _pendingDocumentType
|
||
: 'All Types',
|
||
items: docTypeOptions,
|
||
onChanged: (v) => setDialogState(() => _pendingDocumentType = v!),
|
||
),
|
||
),
|
||
|
||
const Divider(height: 1, indent: 20, endIndent: 20),
|
||
|
||
// Grade Level
|
||
_buildFilterSection(
|
||
label: 'Grade Level',
|
||
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'],
|
||
onChanged: (v) => setDialogState(() => _pendingGradeLevel = v!),
|
||
),
|
||
),
|
||
|
||
const Divider(height: 1, indent: 20, endIndent: 20),
|
||
|
||
// School Year
|
||
_buildFilterSection(
|
||
label: 'School Year',
|
||
onReset: () =>
|
||
setDialogState(() => _pendingSchoolYear = 'All Years'),
|
||
child: _buildFilterDropdown(
|
||
value: years.contains(_pendingSchoolYear)
|
||
? _pendingSchoolYear
|
||
: 'All Years',
|
||
items: years,
|
||
onChanged: (v) => setDialogState(() => _pendingSchoolYear = v!),
|
||
),
|
||
),
|
||
|
||
// Footer buttons
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||
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(8),
|
||
),
|
||
),
|
||
onPressed: () {
|
||
setDialogState(() {
|
||
_pendingStatus = 'All Statuses';
|
||
_pendingDocumentType = 'All Types';
|
||
_pendingGradeLevel = 'All Grades';
|
||
_pendingSchoolYear = 'All Years';
|
||
});
|
||
_clearFilters();
|
||
Navigator.of(context).pop();
|
||
},
|
||
child: const Text(
|
||
'Reset all',
|
||
style: TextStyle(fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
),
|
||
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(8),
|
||
),
|
||
elevation: 0,
|
||
),
|
||
onPressed: onApply,
|
||
child: const Text(
|
||
'Apply now',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.w700,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildFilterChipGroup({
|
||
required List<String> items,
|
||
required String selectedValue,
|
||
required ValueChanged<String> onSelected,
|
||
}) {
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
return Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: items.map((item) {
|
||
final isSelected = item == selectedValue;
|
||
return ChoiceChip(
|
||
label: Text(
|
||
item,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||
color:
|
||
isSelected ? AppColors.primaryGreen : (isDark ? AppColors.darkTextPrimary : AppColors.textPrimary),
|
||
),
|
||
),
|
||
selected: isSelected,
|
||
onSelected: (selected) {
|
||
if (selected) {
|
||
onSelected(item);
|
||
}
|
||
},
|
||
selectedColor: AppColors.primaryGreen.withValues(alpha: 0.12),
|
||
backgroundColor: isDark ? AppColors.darkSurface2 : Colors.grey.shade100,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(20),
|
||
side: BorderSide(
|
||
color: isSelected
|
||
? AppColors.primaryGreen
|
||
: (isDark ? AppColors.darkBorder : Colors.grey.shade300),
|
||
width: 1,
|
||
),
|
||
),
|
||
showCheckmark: false,
|
||
);
|
||
}).toList(),
|
||
);
|
||
}
|
||
|
||
Widget _buildFilterSection({
|
||
required String label,
|
||
required VoidCallback onReset,
|
||
required Widget child,
|
||
}) {
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||
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,
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: onReset,
|
||
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,
|
||
ValueChanged<String?>? onChanged,
|
||
String? hint,
|
||
bool enabled = true,
|
||
}) {
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: enabled ? (isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite) : (isDark ? AppColors.darkSurface2 : Colors.grey.shade50),
|
||
border: Border.all(color: isDark ? AppColors.darkBorder : Colors.grey.shade300),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: DropdownButtonHideUnderline(
|
||
child: DropdownButton<String>(
|
||
value: items.contains(value) ? value : items.first,
|
||
isExpanded: true,
|
||
isDense: true,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: enabled ? (isDark ? AppColors.darkTextPrimary : AppColors.textPrimary) : (isDark ? AppColors.darkTextMuted : AppColors.textMuted),
|
||
),
|
||
hint: hint != null
|
||
? Text(
|
||
hint,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: isDark ? AppColors.darkTextMuted : AppColors.textMuted,
|
||
),
|
||
)
|
||
: null,
|
||
icon: Icon(
|
||
Icons.keyboard_arrow_down_rounded,
|
||
color: enabled ? (isDark ? AppColors.darkTextSecondary : AppColors.textSecondary) : (isDark ? AppColors.darkTextMuted : AppColors.textMuted),
|
||
size: 20,
|
||
),
|
||
items: items
|
||
.map(
|
||
(i) => DropdownMenuItem(
|
||
value: i,
|
||
child: Text(i, style: const TextStyle(fontSize: 14)),
|
||
),
|
||
)
|
||
.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;
|
||
return pageData.documents.isEmpty
|
||
? _buildEmptyState(noSections: hasNoSections)
|
||
: _isGridView
|
||
? _buildGridView(
|
||
pageData.documents,
|
||
pageData.totalPages,
|
||
query.page,
|
||
)
|
||
: _buildListView(
|
||
pageData.documents,
|
||
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: 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.all(isMobile ? 8 : 16),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
|
||
borderRadius: BorderRadius.circular(12),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.04),
|
||
blurRadius: 10,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: Column(
|
||
children: [
|
||
// Table header — hide progress column on mobile
|
||
Container(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: isMobile ? 12 : 16,
|
||
vertical: 10,
|
||
),
|
||
color: AppColors.primaryGreen.withValues(alpha: 0.06),
|
||
child: Row(
|
||
children: [
|
||
const SizedBox(width: 40),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Text(
|
||
'Folder Name',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 12,
|
||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
if (!isMobile)
|
||
Expanded(
|
||
flex: 2,
|
||
child: Text(
|
||
'Requirement Progress',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 12,
|
||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 24),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: ListView.separated(
|
||
itemCount: paginatedFolders.length,
|
||
separatorBuilder: (context, index) =>
|
||
Divider(height: 1, color: isDark ? AppColors.darkBorder : Colors.grey.shade100),
|
||
itemBuilder: (ctx, i) {
|
||
final folder = paginatedFolders[i];
|
||
return GestureDetector(
|
||
onSecondaryTapDown: (details) =>
|
||
_showFolderContextMenu(
|
||
context,
|
||
details.globalPosition,
|
||
folder,
|
||
),
|
||
onLongPressStart: (details) =>
|
||
_showFolderContextMenu(
|
||
context,
|
||
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: [
|
||
const Icon(
|
||
Icons.folder_rounded,
|
||
size: 28,
|
||
color: Colors.orange,
|
||
),
|
||
const SizedBox(width: 12),
|
||
// On mobile: stack name + badges vertically
|
||
Expanded(
|
||
child: isMobile
|
||
? Column(
|
||
crossAxisAlignment:
|
||
CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
folder.name,
|
||
maxLines: 2,
|
||
overflow:
|
||
TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontWeight:
|
||
FontWeight.bold,
|
||
fontSize: 13,
|
||
color: isDark
|
||
? AppColors.darkTextPrimary
|
||
: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
_buildFolderCompletionBadge(
|
||
folder,
|
||
),
|
||
],
|
||
)
|
||
: Row(
|
||
children: [
|
||
Expanded(
|
||
flex: 3,
|
||
child: Text(
|
||
folder.name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow
|
||
.ellipsis,
|
||
style: TextStyle(
|
||
fontWeight:
|
||
FontWeight.bold,
|
||
fontSize: 14,
|
||
color: isDark
|
||
? AppColors.darkTextPrimary
|
||
: AppColors.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
flex: 2,
|
||
child: Align(
|
||
alignment: Alignment
|
||
.centerLeft,
|
||
child:
|
||
_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 with a narrower tile width base
|
||
final tileBase = isMobile ? 140.0 : 200.0;
|
||
int cols = isMobile
|
||
? 2
|
||
: (c.maxWidth / tileBase).floor().clamp(2, 6);
|
||
final childAspect = isMobile ? 0.85 : 0.95;
|
||
final grid = GridView.builder(
|
||
padding: EdgeInsets.all(isMobile ? 10 : 16),
|
||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: cols,
|
||
crossAxisSpacing: isMobile ? 10 : 16,
|
||
mainAxisSpacing: isMobile ? 10 : 16,
|
||
childAspectRatio: childAspect,
|
||
),
|
||
itemCount: paginatedFolders.length,
|
||
itemBuilder: (ctx, i) {
|
||
final folder = paginatedFolders[i];
|
||
return GestureDetector(
|
||
onSecondaryTapDown: (details) => _showFolderContextMenu(
|
||
context,
|
||
details.globalPosition,
|
||
folder,
|
||
),
|
||
onLongPressStart: (details) => _showFolderContextMenu(
|
||
context,
|
||
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: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.03),
|
||
blurRadius: 6,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.folder_rounded,
|
||
size: isMobile ? 36 : 48,
|
||
color: Colors.orange,
|
||
),
|
||
SizedBox(height: isMobile ? 8 : 12),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
child: Text(
|
||
folder.name,
|
||
textAlign: TextAlign.center,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: isMobile ? 11 : 13,
|
||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
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 pills = <Widget>[];
|
||
|
||
// JHS Pill
|
||
final jhsTotal = f.jhsTotal;
|
||
final jhsDone = f.jhsCompleted;
|
||
final jhsComplete = jhsTotal > 0 && jhsDone >= jhsTotal;
|
||
final jhsColor = jhsComplete ? Colors.green : Colors.orange;
|
||
pills.add(
|
||
Container(
|
||
margin: const EdgeInsets.only(right: 4),
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: jhsColor.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: jhsColor.withValues(alpha: 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 ? Colors.green : Colors.blue.shade700;
|
||
pills.add(
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: shsColor.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: shsColor.withValues(alpha: 0.35)),
|
||
),
|
||
child: Text(
|
||
'SHS $shsDone/$shsTotal',
|
||
style: TextStyle(
|
||
fontSize: 10,
|
||
color: shsColor,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
if (pills.isEmpty) return const SizedBox.shrink();
|
||
return Row(mainAxisSize: MainAxisSize.min, 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) {
|
||
// Mobile: always 2 columns; desktop: derive from tile width
|
||
int cols = isMobileGrid ? 2 : (c.maxWidth / 180).floor().clamp(2, 6);
|
||
final aspect = isMobileGrid ? 0.80 : 1.0;
|
||
return Column(
|
||
children: [
|
||
Expanded(
|
||
child: GridView.builder(
|
||
padding: EdgeInsets.all(isMobileGrid ? 10 : 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),
|
||
onSelectedChanged: (val) {
|
||
setState(() {
|
||
if (val == true) {
|
||
_selectedDocumentIds.add(documents[i].id);
|
||
} 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)
|
||
Container(child: _buildPagination(totalPages, currentPage)),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// 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.all(isMobileList ? 8 : 16),
|
||
decoration: BoxDecoration(
|
||
color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
|
||
borderRadius: BorderRadius.circular(12),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.04),
|
||
blurRadius: 10,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: Column(
|
||
children: [
|
||
// Table header — simplified for mobile
|
||
Container(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: isMobileList ? 12 : 16,
|
||
vertical: 10,
|
||
),
|
||
color: AppColors.primaryGreen.withValues(alpha: 0.06),
|
||
child: Row(
|
||
children: [
|
||
// Icon placeholder
|
||
const SizedBox(width: 40),
|
||
const SizedBox(width: 8),
|
||
// File name always visible
|
||
Expanded(
|
||
flex: 3,
|
||
child: Text(
|
||
'File Name',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 12,
|
||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
// Student column — hidden on mobile
|
||
if (!isMobileList)
|
||
Expanded(
|
||
flex: 2,
|
||
child: Text(
|
||
'Folder Path',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 12,
|
||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
// Doc type — hidden on mobile
|
||
if (!isMobileList)
|
||
Expanded(
|
||
flex: 2,
|
||
child: Text(
|
||
'Doc Type',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 12,
|
||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
// Status always visible
|
||
Expanded(
|
||
child: Text(
|
||
'Status',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 12,
|
||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 40),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: ListView.separated(
|
||
itemCount: documents.length,
|
||
separatorBuilder: (context, index) =>
|
||
Divider(height: 1, color: isDark ? AppColors.darkBorder : Colors.grey.shade100),
|
||
itemBuilder: (ctx, i) {
|
||
return isMobileList
|
||
? _buildMobileListRow(documents[i], i)
|
||
: FileFolderCard(
|
||
document: documents[i],
|
||
isGrid: false,
|
||
userRole: widget.userRole,
|
||
isMultiSelectMode: _isMultiSelectMode,
|
||
isSelected: _selectedDocumentIds.contains(
|
||
documents[i].id,
|
||
),
|
||
onSelectedChanged: (val) {
|
||
setState(() {
|
||
if (val == true) {
|
||
_selectedDocumentIds.add(documents[i].id);
|
||
} 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)
|
||
Container(child: _buildPagination(totalPages, currentPage)),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Compact card row for mobile list view – stacks info vertically.
|
||
Widget _buildMobileListRow(dynamic doc, int i) {
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
Color fileColor;
|
||
IconData fileIcon;
|
||
final name = doc.fileName.toLowerCase() as String;
|
||
if (name.endsWith('.pdf')) {
|
||
fileColor = Colors.redAccent;
|
||
fileIcon = Icons.picture_as_pdf;
|
||
} else if (name.endsWith('.png') ||
|
||
name.endsWith('.jpg') ||
|
||
name.endsWith('.jpeg')) {
|
||
fileColor = Colors.blueAccent;
|
||
fileIcon = Icons.image;
|
||
} else {
|
||
fileColor = AppColors.primaryGreen;
|
||
fileIcon = Icons.insert_drive_file;
|
||
}
|
||
|
||
Color statusColor;
|
||
switch (doc.status as String) {
|
||
case 'Completed':
|
||
statusColor = AppColors.success;
|
||
break;
|
||
case 'Archived':
|
||
statusColor = Colors.blue;
|
||
break;
|
||
default:
|
||
statusColor = Colors.grey;
|
||
}
|
||
|
||
final isSelected = _selectedDocumentIds.contains(doc.id);
|
||
|
||
return GestureDetector(
|
||
onSecondaryTapDown: (details) => _showDocumentContextMenu(
|
||
context,
|
||
details.globalPosition,
|
||
doc as DocumentModel,
|
||
),
|
||
onLongPressStart: (details) => _showDocumentContextMenu(
|
||
context,
|
||
details.globalPosition,
|
||
doc as DocumentModel,
|
||
),
|
||
child: InkWell(
|
||
onTap: () {
|
||
if (_isMultiSelectMode) {
|
||
setState(() {
|
||
if (isSelected) {
|
||
_selectedDocumentIds.remove(doc.id);
|
||
} else {
|
||
_selectedDocumentIds.add(doc.id);
|
||
}
|
||
});
|
||
} else {
|
||
showDocumentPreview(
|
||
context: context,
|
||
document: doc as DocumentModel,
|
||
);
|
||
}
|
||
},
|
||
child: Container(
|
||
color: isSelected
|
||
? AppColors.primaryGreen.withValues(alpha: 0.05)
|
||
: null,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
// Multi-select checkbox
|
||
if (_isMultiSelectMode) ...[
|
||
Checkbox(
|
||
value: isSelected,
|
||
activeColor: AppColors.primaryGreen,
|
||
onChanged: (val) {
|
||
setState(() {
|
||
if (val == true) {
|
||
_selectedDocumentIds.add(doc.id);
|
||
} else {
|
||
_selectedDocumentIds.remove(doc.id);
|
||
}
|
||
});
|
||
},
|
||
visualDensity: VisualDensity.compact,
|
||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
),
|
||
const SizedBox(width: 4),
|
||
],
|
||
|
||
// File icon
|
||
SizedBox(
|
||
width: 32,
|
||
height: 32,
|
||
child: Center(
|
||
child: Icon(fileIcon, size: 24, color: fileColor),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
|
||
// Stacked info: file name + student + doc type
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
doc.fileName as String,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 13,
|
||
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
doc.studentName as String? ??
|
||
(doc.studentLrn != null
|
||
? 'LRN: ${doc.studentLrn}'
|
||
: '—'),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||
),
|
||
),
|
||
if ((doc.documentType as String?) != null) ...[
|
||
const SizedBox(height: 1),
|
||
Text(
|
||
doc.documentType as String,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 10,
|
||
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade500,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
|
||
// Status badge
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: statusColor.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: statusColor.withValues(alpha: 0.3)),
|
||
),
|
||
child: Text(
|
||
doc.status as String,
|
||
style: TextStyle(
|
||
fontSize: 10,
|
||
color: statusColor,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
|
||
// Actions menu
|
||
if (!_isMultiSelectMode)
|
||
SizedBox(
|
||
width: 30,
|
||
child: PopupMenuButton<String>(
|
||
icon: Icon(
|
||
Icons.more_vert,
|
||
size: 16,
|
||
color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary,
|
||
),
|
||
padding: EdgeInsets.zero,
|
||
onSelected: (a) => _handleAction(a, doc as DocumentModel),
|
||
itemBuilder: (_) => [
|
||
const PopupMenuItem(
|
||
value: 'select',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.check_box_outlined, size: 16),
|
||
SizedBox(width: 10),
|
||
Text('Select', style: TextStyle(fontSize: 13)),
|
||
],
|
||
),
|
||
),
|
||
const PopupMenuItem(
|
||
value: 'queue',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.print, size: 16),
|
||
SizedBox(width: 10),
|
||
Text('Add to Print List', style: TextStyle(fontSize: 13)),
|
||
],
|
||
),
|
||
),
|
||
const PopupMenuItem(
|
||
value: 'copy',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.copy, size: 16),
|
||
SizedBox(width: 10),
|
||
Text('Copy', style: TextStyle(fontSize: 13)),
|
||
],
|
||
),
|
||
),
|
||
const PopupMenuItem(
|
||
value: 'download',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.download, size: 16),
|
||
SizedBox(width: 10),
|
||
Text('Download', style: TextStyle(fontSize: 13)),
|
||
],
|
||
),
|
||
),
|
||
const PopupMenuDivider(),
|
||
const PopupMenuItem(
|
||
value: 'view_profile',
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.person,
|
||
size: 16,
|
||
color: AppColors.primaryGreen,
|
||
),
|
||
SizedBox(width: 10),
|
||
Text(
|
||
'Student Profile',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.primaryGreen,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (widget.userRole != 'teacher') ...[
|
||
const PopupMenuDivider(),
|
||
const PopupMenuItem(
|
||
value: 'delete',
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.delete,
|
||
size: 16,
|
||
color: AppColors.error,
|
||
),
|
||
SizedBox(width: 10),
|
||
Text(
|
||
'Delete',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.error,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// 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: 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 (_selectedStatus != 'All Statuses' ||
|
||
_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) {
|
||
final clean = message.startsWith('Exception: ')
|
||
? message.substring(11)
|
||
: message;
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.wifi_off_rounded, size: 56, color: AppColors.error),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
clean,
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(color: AppColors.error),
|
||
),
|
||
const SizedBox(height: 16),
|
||
ElevatedButton.icon(
|
||
icon: const Icon(Icons.refresh),
|
||
label: const Text('Retry'),
|
||
onPressed: () => ref.invalidate(documentPageProvider),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: AppColors.primaryGreen,
|
||
foregroundColor: Colors.white,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// PAGINATION
|
||
// ══════════════════════════════════════════════════════════════
|
||
Widget _buildPagination(int totalPages, int currentPage) {
|
||
return SafeArea(
|
||
top: false,
|
||
child: AppPagination(
|
||
currentPage: currentPage,
|
||
totalPages: totalPages,
|
||
onPageChanged: (p) =>
|
||
ref.read(documentQueryProvider.notifier).setPage(p),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildFoldersPagination(int totalPages, int currentPage) {
|
||
return SafeArea(
|
||
top: false,
|
||
child: AppPagination(
|
||
currentPage: currentPage,
|
||
totalPages: totalPages,
|
||
onPageChanged: (p) => setState(() => _foldersPage = p),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showFolderContextMenu(
|
||
BuildContext context,
|
||
Offset position,
|
||
dynamic folder,
|
||
) {
|
||
if (folder.studentId == null) return;
|
||
|
||
final RenderBox overlay =
|
||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||
showMenu<String>(
|
||
context: context,
|
||
position: RelativeRect.fromRect(
|
||
position & const Size(40, 40),
|
||
Offset.zero & overlay.size,
|
||
),
|
||
items: [
|
||
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)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
).then((value) {
|
||
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,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
void _showDocumentContextMenu(
|
||
BuildContext context,
|
||
Offset position,
|
||
DocumentModel doc,
|
||
) {
|
||
if (_isMultiSelectMode) return;
|
||
final RenderBox overlay =
|
||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||
|
||
final items = <PopupMenuEntry<String>>[
|
||
const PopupMenuItem(
|
||
value: 'select',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.check_box_outlined, size: 18),
|
||
SizedBox(width: 12),
|
||
Text('Select', style: TextStyle(fontSize: 14)),
|
||
],
|
||
),
|
||
),
|
||
const PopupMenuItem(
|
||
value: 'queue',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.print, size: 18),
|
||
SizedBox(width: 12),
|
||
Text('Add to Print List', style: TextStyle(fontSize: 14)),
|
||
],
|
||
),
|
||
),
|
||
const PopupMenuItem(
|
||
value: 'copy',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.copy, size: 18),
|
||
SizedBox(width: 12),
|
||
Text('Copy', style: TextStyle(fontSize: 14)),
|
||
],
|
||
),
|
||
),
|
||
const PopupMenuItem(
|
||
value: 'download',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.download, size: 18),
|
||
SizedBox(width: 12),
|
||
Text('Download', 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 (widget.userRole != 'teacher') {
|
||
items.add(const PopupMenuDivider());
|
||
items.add(
|
||
const PopupMenuItem(
|
||
value: 'delete',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.delete, size: 18, color: AppColors.error),
|
||
SizedBox(width: 12),
|
||
Text(
|
||
'Delete',
|
||
style: TextStyle(fontSize: 14, color: AppColors.error),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
showMenu<String>(
|
||
context: context,
|
||
position: RelativeRect.fromRect(
|
||
position & const Size(40, 40),
|
||
Offset.zero & overlay.size,
|
||
),
|
||
items: items,
|
||
).then((value) {
|
||
if (value != null) {
|
||
_handleAction(value, doc);
|
||
}
|
||
});
|
||
}
|
||
}
|