Files
TIS_RMS/frontend/lib/ui/screens/students/students_screen.dart
T

2321 lines
86 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/constants/app_sizes.dart';
import '../../../domain/entities/student_model.dart';
import '../../shared/buttons/primary_button.dart';
import '../../shared/widgets/app_button_loader.dart';
import '../../providers/student_provider.dart';
import '../documents/widgets/student_profile_modal.dart';
import 'widgets/add_student_modal.dart';
import 'widgets/edit_student_modal.dart';
import 'widgets/bulk_ocr_import_dialog.dart';
import 'widgets/student_filter_dialog.dart';
import '../../providers/setup_provider.dart';
import '../../shared/inputs/app_search_bar.dart';
import '../../providers/navigation_provider.dart';
import '../../providers/document_provider.dart';
import '../../providers/archives_provider.dart';
import '../../shared/dialogs/error_dialog.dart';
import '../../shared/dialogs/success_dialog.dart';
import '../../shared/widgets/app_pagination.dart';
import '../../shared/widgets/app_error_state.dart';
import 'package:data_table_2/data_table_2.dart';
class StudentsScreen extends ConsumerStatefulWidget {
final String userRole;
const StudentsScreen({super.key, this.userRole = 'teacher'});
@override
ConsumerState<StudentsScreen> createState() => _StudentsScreenState();
}
class _StudentsScreenState extends ConsumerState<StudentsScreen> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _horizontalScrollController = ScrollController();
final FocusNode _searchFocusNode = FocusNode();
final FocusNode _shortcutFocusNode = FocusNode();
Timer? _debounce;
final List<int> _selectedStudentIds = [];
bool _showMultiSelect = false;
bool _isDragOver = false;
Timer? _dragResetTimer;
ProviderSubscription<String>? _tabListener;
@override
void initState() {
super.initState();
_searchController.addListener(_onSearchChanged);
_searchFocusNode.addListener(_onSearchFocusChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// Sync initial search text if it was set externally (e.g. from Dashboard)
final initialQuery = ref.read(studentQueryProvider).search;
if (initialQuery.isNotEmpty) {
_searchController.text = initialQuery;
}
if (ref.read(activeTabProvider) == 'Students') {
_shortcutFocusNode.requestFocus();
}
_tabListener = ref.listenManual<String>(activeTabProvider, (
previous,
next,
) {
if (!mounted) return;
if (next != 'Students') {
if (_searchController.text.isNotEmpty) _searchController.clear();
ref.read(studentQueryProvider.notifier).reset();
if (_showMultiSelect || _selectedStudentIds.isNotEmpty) {
setState(() {
_showMultiSelect = false;
_selectedStudentIds.clear();
});
}
} else {
Future.delayed(const Duration(milliseconds: 120), () {
if (mounted) {
_shortcutFocusNode.requestFocus();
}
});
// Invalidate setup providers so dropdowns and filters always have fresh data
ref.invalidate(academicYearsListProvider);
ref.invalidate(gradeLevelsListProvider);
ref.invalidate(sectionsListProvider);
// Sync search text if returning to Students tab with a pre-filled query
final currentQuery = ref.read(studentQueryProvider).search;
if (_searchController.text != currentQuery) {
_searchController.text = currentQuery;
if (mounted) setState(() {});
}
}
});
});
}
@override
void dispose() {
_dragResetTimer?.cancel();
_tabListener?.close();
_debounce?.cancel();
_searchController.removeListener(_onSearchChanged);
_searchFocusNode.removeListener(_onSearchFocusChanged);
_searchController.dispose();
_searchFocusNode.dispose();
_shortcutFocusNode.dispose();
_horizontalScrollController.dispose();
ref.read(studentQueryProvider.notifier).reset();
super.dispose();
}
void _onSearchChanged() {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
ref.read(studentQueryProvider.notifier).setSearch(_searchController.text);
});
}
void _onSearchSubmitted(String query) {
_debounce?.cancel();
ref.read(studentQueryProvider.notifier).setSearch(query);
}
void _onSearchFocusChanged() {
if (mounted) setState(() {});
}
// ----------------------------------------------------------------
// SHOW ADD / EDIT MODAL
// ----------------------------------------------------------------
Future<bool?> _openModal({StudentModel? student}) async {
final result = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => student == null
? const AddStudentModal()
: EditStudentModal(student: student),
),
);
// For add: show success dialog here on students screen.
// For edit: success dialog is shown inside EditStudentModal before popping,
// so we just return the result and let _viewProfile re-open the profile.
if (result == true && mounted && student == null) {
await showSuccessDialog(
context,
message: 'Student added successfully!',
);
}
return result;
}
// ----------------------------------------------------------------
// SHOW BULK OCR IMPORT DIALOG
// ----------------------------------------------------------------
Future<void> _openBulkOcrImport({List<File>? preloadedFiles}) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => BulkOcrImportDialog(preloadedFiles: preloadedFiles),
),
);
}
// ── DRAG AND DROP WRAPPER (Windows only) ──────────────────────────────────
Widget _buildDragDropWrapper(BuildContext context, {required Widget child}) {
final isWindows = defaultTargetPlatform == TargetPlatform.windows;
if (!isWindows) return child;
// Reset drag overlay if screen is no longer current active route or active tab
final isActiveTab = ref.read(activeTabProvider) == 'Students';
if (_isDragOver && (!isActiveTab || ModalRoute.of(context)?.isCurrent != true)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _isDragOver) {
setState(() => _isDragOver = false);
}
});
}
return DropTarget(
onDragEntered: (details) {
_dragResetTimer?.cancel();
if (ref.read(activeTabProvider) != 'Students') return;
if (ModalRoute.of(context)?.isCurrent == true) {
setState(() => _isDragOver = true);
}
// Safety auto-reset timer in case Windows OS swallows dragExit event
_dragResetTimer = Timer(const Duration(seconds: 3), () {
if (mounted && _isDragOver) {
setState(() => _isDragOver = false);
}
});
},
onDragExited: (details) {
_dragResetTimer?.cancel();
if (mounted) {
setState(() => _isDragOver = false);
}
},
onDragDone: (details) {
_dragResetTimer?.cancel();
if (mounted) {
setState(() => _isDragOver = false);
}
if (ref.read(activeTabProvider) != 'Students') return;
if (ModalRoute.of(context)?.isCurrent != true) 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) {
_openBulkOcrImport(preloadedFiles: validFiles);
}
},
child: Stack(
children: [
child,
if (_isDragOver)
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 import students',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.primaryGreen.withValues(alpha: 0.9),
),
),
const SizedBox(height: 4),
Text(
'PDF, JPG, PNG, XLSX supported',
style: TextStyle(
fontSize: 13,
color: AppColors.primaryGreen.withValues(alpha: 0.6),
),
),
],
),
),
),
),
),
],
),
);
}
// ----------------------------------------------------------------
// RIGHT-CLICK CONTEXT MENU (Windows)
// ----------------------------------------------------------------
Future<void> _showStudentContextMenu(
BuildContext ctx,
Offset globalPosition,
StudentModel student,
) async {
if (widget.userRole == 'teacher') return;
final RenderBox overlay =
Overlay.of(ctx).context.findRenderObject()! as RenderBox;
final RelativeRect position = RelativeRect.fromRect(
Rect.fromLTWH(globalPosition.dx, globalPosition.dy, 0, 0),
Offset.zero & overlay.size,
);
final choice = await showMenu<String>(
context: ctx,
position: position,
items: [
PopupMenuItem<String>(
value: 'edit',
child: Row(
children: const [
Icon(Icons.edit_outlined, size: 18, color: AppColors.primaryGreen),
SizedBox(width: 10),
Text('Edit'),
],
),
),
PopupMenuItem<String>(
value: 'select',
child: Row(
children: const [
Icon(Icons.check_box_outlined, size: 18, color: AppColors.primaryGreen),
SizedBox(width: 10),
Text('Select'),
],
),
),
],
);
if (!mounted) return;
if (choice == 'edit') {
await _openModal(student: student);
} else if (choice == 'select') {
setState(() {
_showMultiSelect = true;
if (!_selectedStudentIds.contains(student.id)) {
_selectedStudentIds.add(student.id);
}
});
}
}
void _toggleSelectAll(List<StudentModel> students) {
setState(() {
if (_selectedStudentIds.length == students.length) {
_selectedStudentIds.clear();
} else {
_selectedStudentIds.clear();
_selectedStudentIds.addAll(students.map((e) => e.id));
}
});
}
Widget _buildInlineMultiSelectHeader(List<StudentModel> allStudents) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final count = _selectedStudentIds.length;
final allSelected = allStudents.isNotEmpty && count == allStudents.length;
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(() {
_selectedStudentIds.clear();
_showMultiSelect = 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: allStudents.isEmpty
? null
: () => _toggleSelectAll(allStudents),
),
),
const SizedBox(width: 16),
Container(
width: 1,
height: 24,
color: isDark ? AppColors.darkBorder : Colors.grey.shade400,
),
const SizedBox(width: 12),
Tooltip(
message: 'Enroll',
child: IconButton(
icon: Icon(Icons.group_add_rounded, color: buttonColor),
onPressed: count == 0 ? null : _showBulkEnrollModal,
),
),
Tooltip(
message: 'Graduate',
child: IconButton(
icon: Icon(Icons.school_rounded, color: buttonColor),
onPressed: count == 0 ? null : _showBulkGraduateConfirm,
),
),
Tooltip(
message: 'Transfer',
child: IconButton(
icon: Icon(
Icons.transfer_within_a_station_rounded,
color: buttonColor,
),
onPressed: count == 0
? null
: () => _showBulkChangeStatusConfirm(
'Transferred',
allStudents,
),
),
),
Tooltip(
message: 'Drop',
child: IconButton(
icon: Icon(Icons.person_off_rounded, color: buttonColor),
onPressed: count == 0
? null
: () =>
_showBulkChangeStatusConfirm('Dropped', allStudents),
),
),
Tooltip(
message: 'Inactive',
child: IconButton(
icon: Icon(
Icons.do_not_disturb_on_total_silence_rounded,
color: buttonColor,
),
onPressed: count == 0
? null
: () =>
_showBulkChangeStatusConfirm('Inactive', allStudents),
),
),
],
),
),
);
}
void _showBulkEnrollModal() {
showDialog(
context: context,
builder: (context) => BulkEnrollDialog(
studentIds: _selectedStudentIds,
onSuccess: () {
setState(() {
_selectedStudentIds.clear();
_showMultiSelect = false;
});
showSuccessDialog(
context,
message: 'Students successfully enrolled.',
);
},
),
);
}
void _showBulkGraduateConfirm() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Bulk Graduate'),
content: Text(
'Are you sure you want to change the status of ${_selectedStudentIds.length} selected student(s) to "Graduated"?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('CANCEL'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('GRADUATE'),
),
],
),
);
if (confirmed == true && mounted) {
try {
await ref
.read(studentMutationProvider.notifier)
.bulkGraduate(_selectedStudentIds);
if (mounted) {
setState(() {
_selectedStudentIds.clear();
_showMultiSelect = false;
});
showSuccessDialog(
context,
message: 'Students successfully graduated.',
);
}
} catch (e) {
if (mounted) {
final errMsg = e.toString().replaceAll('Exception: ', '');
showErrorDialog(context, 'Error', errMsg);
}
}
}
}
void _showBulkChangeStatusConfirm(
String newStatus,
List<StudentModel> allStudents,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Bulk $newStatus'),
content: Text(
'Are you sure you want to change the status of ${_selectedStudentIds.length} selected student(s) to "$newStatus"?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('CANCEL'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: newStatus == 'Dropped'
? Colors.red
: Colors.orange,
foregroundColor: Colors.white,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(newStatus.toUpperCase()),
),
],
),
);
if (confirmed == true && mounted) {
try {
final selectedStudents = allStudents
.where((s) => _selectedStudentIds.contains(s.id))
.toList();
await ref
.read(studentMutationProvider.notifier)
.bulkChangeStatus(selectedStudents, newStatus);
if (mounted) {
setState(() {
_selectedStudentIds.clear();
_showMultiSelect = false;
});
showSuccessDialog(
context,
message: 'Students successfully updated to $newStatus.',
);
}
} catch (e) {
if (mounted) {
final errMsg = e.toString().replaceAll('Exception: ', '');
showErrorDialog(context, 'Error', errMsg);
}
}
}
}
// ----------------------------------------------------------------
// DELETE CONFIRM
// ----------------------------------------------------------------
Future<void> _confirmDelete(StudentModel student) async {
final confirmController = TextEditingController();
bool isLoading = false;
String? errorMessage;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: const Text('Delete Student?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Are you sure you want to delete '
'"${student.listDisplayName}"? The student will be marked as inactive and securely stored.',
),
const SizedBox(height: 16),
const Text('Please type "confirm" to delete:'),
const SizedBox(height: 8),
TextField(
controller: confirmController,
decoration: InputDecoration(
labelText: 'Type confirm',
errorText: errorMessage,
border: const OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: isLoading
? null
: () => Navigator.of(ctx).pop(false),
child: const Text('CANCEL'),
),
TextButton(
onPressed: isLoading
? null
: () async {
final text = confirmController.text.trim();
if (text.isEmpty) {
setState(() => errorMessage = 'Input is required');
return;
}
if (text.toLowerCase() != 'confirm') {
setState(
() => errorMessage = 'Please type "confirm"',
);
return;
}
Navigator.of(ctx).pop(true);
},
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: isLoading
? const AppButtonLoader(
size: 16,
color: AppColors.error,
)
: const Text('DELETE'),
),
],
);
},
);
},
);
if (confirmed == true && mounted) {
try {
await ref
.read(studentMutationProvider.notifier)
.setStudentInactive(student.id, student);
if (mounted) {
showSuccessDialog(
context,
message: 'Student deleted but still stored and marked as inactive.',
);
}
} catch (e) {
if (mounted) {
final raw = e.toString();
final msg = raw.startsWith('Exception: ') ? raw.substring(11) : raw;
showErrorDialog(context, 'Error', msg);
}
}
}
}
// ----------------------------------------------------------------
// NAVIGATION HELPERS
// ----------------------------------------------------------------
void _viewProfile(StudentModel student) {
showStudentProfileModal(
context,
studentId: student.id,
userRole: widget.userRole,
onEditById: (currentId) async {
Navigator.pop(context);
final pageState = ref.read(studentPageProvider);
final students = pageState.value?.students ?? [];
final targetStudent = students.firstWhere(
(s) => s.id == currentId,
orElse: () => student,
);
await _openModal(student: targetStudent);
if (mounted) {
_viewProfile(targetStudent);
}
},
onDeleteById: (currentId) {
Navigator.pop(context);
final pageState = ref.read(studentPageProvider);
final students = pageState.value?.students ?? [];
final targetStudent = students.firstWhere(
(s) => s.id == currentId,
orElse: () => student,
);
_confirmDelete(targetStudent);
},
);
}
void _openDocumentsFolder(StudentModel student) {
if (student.status != 'Enrolled') {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Student Not Enrolled'),
content: Text(
'${student.listDisplayName} is currently marked as ${student.status}. Would you like to navigate to the Archives screen to view their documents?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('CANCEL'),
),
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
ref.read(activeTabProvider.notifier).setTab('Archives');
Future.microtask(() {
if (mounted) {
ref
.read(openedArchiveFolderProvider.notifier)
.setFolder(
OpenedArchiveFolderData(
id: student.id,
name: student.listDisplayName,
),
);
}
});
},
child: const Text('PROCEED'),
),
],
),
);
} else {
ref.read(activeTabProvider.notifier).setTab('Documents');
Future.microtask(() {
if (mounted) {
ref
.read(openedFolderProvider.notifier)
.setFolder(
OpenedFolderData(id: student.id, name: student.listDisplayName),
);
}
});
}
}
// ================================================================
// BUILD
// ================================================================
@override
Widget build(BuildContext context) {
final query = ref.watch(studentQueryProvider);
final pageAsync = ref.watch(studentPageProvider);
final activeCount = [
query.schoolYear.isNotEmpty,
query.gradeLevel.isNotEmpty,
query.section.isNotEmpty,
query.status.isNotEmpty,
query.is4Ps.isNotEmpty,
query.sortBy.isNotEmpty,
query.limit != 20,
].where((v) => v).length;
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) != 'Students') return;
if (_showMultiSelect || _selectedStudentIds.isNotEmpty) {
setState(() {
_showMultiSelect = false;
_selectedStudentIds.clear();
});
return;
}
ref.read(activeTabProvider.notifier).setTab('Dashboard');
},
child: _buildDragDropWrapper(
context,
child: Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.transparent,
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
floatingActionButton:
(widget.userRole == 'teacher' ||
_showMultiSelect ||
_searchFocusNode.hasFocus ||
defaultTargetPlatform == TargetPlatform.windows)
? null
: FloatingActionButton(
heroTag: 'add_student_fab',
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
shape: const CircleBorder(),
onPressed: () => _openModal(),
child: const Icon(Icons.person_add),
),
body: Stack(
children: [
SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Header + Controls or Inline Multi-Select Header ──
if (_showMultiSelect)
_buildInlineMultiSelectHeader(
pageAsync.value?.students ?? [],
)
else
Padding(
padding: const EdgeInsets.only(
left: AppSizes.p24,
right: AppSizes.p24,
top: AppSizes.p24,
),
child: _buildHeaderControls(
context,
query,
ref,
activeCount,
),
),
const SizedBox(height: AppSizes.p24),
// ── Data Table / Cards ──
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSizes.p24,
),
child: pageAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(
color: AppColors.primaryGreen,
),
),
error: (err, _) =>
_buildError(err.toString()),
data: (page) {
// Teacher with no active filters and 0 total means no sections assigned
final hasNoSections =
widget.userRole == 'teacher' &&
query.search.isEmpty &&
query.gradeLevel.isEmpty &&
query.section.isEmpty &&
query.status.isEmpty &&
query.schoolYear.isEmpty &&
page.total == 0;
final sortedStudents = _sortStudents(
page.students,
query,
);
return LayoutBuilder(
builder: (ctx, c) => c.maxWidth > 800
? _buildDesktopTable(
sortedStudents,
query,
noSections: hasNoSections,
)
: _buildMobileCardList(
sortedStudents,
noSections: hasNoSections,
),
);
},
),
),
),
],
),
),
],
),
),
// ── Pagination ──
pageAsync.maybeWhen(
data: (page) => _buildPagination(query, page),
orElse: () => const SizedBox.shrink(),
),
],
),
),
],
),
),
),
),
),
);
}
Future<void> _showSearchDialog(BuildContext context) async {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_searchFocusNode.requestFocus();
}
});
await showDialog(
context: context,
barrierColor: Colors.black54,
builder: (context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
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(
controller: _searchController,
focusNode: _searchFocusNode,
collapsible: false,
hint: 'Search by LRN or name...',
maxWidth: 600,
onSubmitted: (value) {
Navigator.of(context).pop(); // Close dialog
_onSearchSubmitted(value);
},
),
),
),
);
},
);
if (mounted) {
_shortcutFocusNode.requestFocus();
}
}
// ================================================================
// HEADER + CONTROLS
// ================================================================
Widget _buildHeaderControls(
BuildContext context,
StudentQueryParams query,
WidgetRef ref,
int activeCount,
) {
return LayoutBuilder(
builder: (_, c) {
final isDesktop = c.maxWidth > 800;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Students Directory',
style: TextStyle(
fontSize: isDesktop ? 28 : 22,
fontWeight: FontWeight.bold,
color: isDark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
),
Tooltip(
richMessage: query.search.isNotEmpty
? const TextSpan(text: 'Clear Search')
: const TextSpan(
text: 'Search Students ',
children: [
TextSpan(
text: '(Ctrl+F)',
style: TextStyle(fontStyle: FontStyle.italic),
),
],
),
child: IconButton(
icon: Icon(
query.search.isNotEmpty ? Icons.close : Icons.search,
size: 28,
color: isDark ? AppColors.darkTextPrimary : Colors.black87,
),
onPressed: () {
if (query.search.isNotEmpty) {
_searchController.clear();
ref.read(studentQueryProvider.notifier).setSearch('');
} else {
_showSearchDialog(context);
}
},
),
),
...[
if (widget.userRole != 'teacher' &&
defaultTargetPlatform != TargetPlatform.android) ...[
const SizedBox(width: 8),
_buildMultiSelectToggle(true),
],
const SizedBox(width: 8),
// "Add" + "Bulk Add" buttons for Windows (replaces FAB)
if (defaultTargetPlatform == TargetPlatform.windows &&
widget.userRole != 'teacher') ...[
SizedBox(
height: 36,
child: ElevatedButton.icon(
onPressed: _openBulkOcrImport,
icon: const Icon(Icons.document_scanner_outlined, size: 18),
label: const Text('Bulk Add', style: TextStyle(fontSize: 13)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.darkGreen,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12),
),
),
),
const SizedBox(width: 8),
SizedBox(
height: 36,
child: ElevatedButton.icon(
onPressed: () => _openModal(),
icon: const Icon(Icons.person_add, size: 18),
label: const Text('Add', style: TextStyle(fontSize: 13)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12),
),
),
),
const SizedBox(width: 8),
],
// Filter icon (icon only, no background)
IconButton(
onPressed: () =>
StudentFilterDialog.show(context, query: query),
icon: Badge(
isLabelVisible: activeCount > 0,
label: Text(activeCount.toString()),
child: const Icon(
Icons.tune_rounded,
color: AppColors.primaryGreen,
),
),
),
],
],
);
},
);
}
// ================================================================
// MULTI-SELECT TOGGLE
// ================================================================
Widget _buildMultiSelectToggle(bool isIconOnly) {
return Tooltip(
message: 'Multi-Select',
child: IconButton(
onPressed: () {
setState(() {
_showMultiSelect = !_showMultiSelect;
if (!_showMultiSelect) _selectedStudentIds.clear();
});
},
icon: Icon(
_showMultiSelect
? Icons.check_box_rounded
: Icons.check_box_outline_blank_rounded,
color: _showMultiSelect
? AppColors.primaryGreen
: AppColors.textSecondary,
),
),
);
}
List<StudentModel> _sortStudents(
List<StudentModel> rawStudents,
StudentQueryParams query,
) {
if (rawStudents.isEmpty || query.sortBy.isEmpty) {
return List<StudentModel>.from(rawStudents);
}
final students = List<StudentModel>.from(rawStudents);
if (query.sortBy == 'lrn') {
students.sort((a, b) {
final comp = a.lrn.compareTo(b.lrn);
return query.sortOrder == 'desc' ? -comp : comp;
});
} else if (query.sortBy == 'name') {
students.sort((a, b) {
final compA = '${a.lastName} ${a.firstName}'.toLowerCase();
final compB = '${b.lastName} ${b.firstName}'.toLowerCase();
final comp = compA.compareTo(compB);
return query.sortOrder == 'desc' ? -comp : comp;
});
} else if (query.sortBy == 'grade_section') {
students.sort((a, b) {
final gComp = (a.latestGradeLevel ?? 0).compareTo(
b.latestGradeLevel ?? 0,
);
if (gComp != 0) {
return query.sortOrder == 'desc' ? -gComp : gComp;
}
final sComp = (a.latestSection ?? '').toLowerCase().compareTo(
(b.latestSection ?? '').toLowerCase(),
);
return query.sortOrder == 'desc' ? -sComp : sComp;
});
} else if (query.sortBy == 'doc_status') {
students.sort((a, b) {
final comp = a.missingDocumentsCount.compareTo(
b.missingDocumentsCount,
);
return query.sortOrder == 'desc' ? -comp : comp;
});
}
return students;
}
// ================================================================
// DESKTOP DATA TABLE
// ================================================================
Widget _buildDesktopTable(
List<StudentModel> rawStudents,
StudentQueryParams query, {
bool noSections = false,
}) {
if (rawStudents.isEmpty) return _buildEmptyState(noSections: noSections);
List<StudentModel> students = _sortStudents(rawStudents, query);
return LayoutBuilder(
builder: (context, constraints) {
DataCell buildHoverCell(Widget child, StudentModel student) {
return DataCell(
GestureDetector(
onSecondaryTapDown: defaultTargetPlatform == TargetPlatform.windows
? (details) => _showStudentContextMenu(
context,
details.globalPosition,
student,
)
: null,
child: MouseRegion(
hitTestBehavior: HitTestBehavior.translucent,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Align(alignment: Alignment.centerLeft, child: child),
),
),
),
onTap: () => _viewProfile(student),
);
}
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
child: DataTable2(
minWidth: 950,
columnSpacing: 12,
horizontalMargin: 16,
headingRowColor: WidgetStateProperty.all(
AppColors.primaryGreen.withValues(alpha: 0.06),
),
dataRowHeight: 56,
columns: [
if (widget.userRole != 'teacher' && _showMultiSelect)
DataColumn2(
fixedWidth: 40,
label: defaultTargetPlatform == TargetPlatform.windows
? const SizedBox.shrink()
: Checkbox(
activeColor: AppColors.primaryGreen,
value:
students.isNotEmpty &&
students.every(
(s) => _selectedStudentIds.contains(s.id),
),
onChanged: (val) {
setState(() {
if (val == true) {
for (var s in students) {
if (!_selectedStudentIds.contains(s.id)) {
_selectedStudentIds.add(s.id);
}
}
} else {
for (var s in students) {
_selectedStudentIds.remove(s.id);
}
}
});
},
),
),
DataColumn2(
size: ColumnSize.M,
label: Row(
children: [
const Text(
'LRN',
style: TextStyle(fontWeight: FontWeight.bold),
),
PopupMenuButton<String>(
icon: const Icon(Icons.arrow_drop_down, size: 18),
onSelected: (val) {
ref
.read(studentQueryProvider.notifier)
.setSort(val.isEmpty ? '' : 'lrn', val);
},
itemBuilder: (ctx) => [
CheckedPopupMenuItem(
value: '',
checked: query.sortBy == 'lrn' && query.sortOrder == '',
child: const Text('None'),
),
CheckedPopupMenuItem(
value: 'asc',
checked: query.sortBy == 'lrn' && query.sortOrder == 'asc',
child: const Text('ASC'),
),
CheckedPopupMenuItem(
value: 'desc',
checked: query.sortBy == 'lrn' && query.sortOrder == 'desc',
child: const Text('DESC'),
),
],
),
],
),
),
DataColumn2(
size: ColumnSize.L,
label: Row(
children: [
const Text(
'Name',
style: TextStyle(fontWeight: FontWeight.bold),
),
PopupMenuButton<String>(
icon: const Icon(Icons.arrow_drop_down, size: 18),
onSelected: (val) {
ref
.read(studentQueryProvider.notifier)
.setSort(val.isEmpty ? '' : 'name', val);
},
itemBuilder: (ctx) => [
CheckedPopupMenuItem(
value: '',
checked: query.sortBy == 'name' && query.sortOrder == '',
child: const Text('None'),
),
CheckedPopupMenuItem(
value: 'asc',
checked: query.sortBy == 'name' && query.sortOrder == 'asc',
child: const Text('A-Z'),
),
CheckedPopupMenuItem(
value: 'desc',
checked: query.sortBy == 'name' && query.sortOrder == 'desc',
child: const Text('Z-A'),
),
],
),
],
),
),
DataColumn2(
size: ColumnSize.M,
label: Row(
children: [
const Text(
'Grade & Sec.',
style: TextStyle(fontWeight: FontWeight.bold),
),
PopupMenuButton<String>(
icon: const Icon(Icons.arrow_drop_down, size: 18),
onSelected: (val) {
ref
.read(studentQueryProvider.notifier)
.setSort(val.isEmpty ? '' : 'grade_section', val);
},
itemBuilder: (ctx) => [
CheckedPopupMenuItem(
value: '',
checked: query.sortBy == 'grade_section' && query.sortOrder == '',
child: const Text('None'),
),
CheckedPopupMenuItem(
value: 'asc',
checked: query.sortBy == 'grade_section' && query.sortOrder == 'asc',
child: const Text('7-12 & A-Z'),
),
CheckedPopupMenuItem(
value: 'desc',
checked: query.sortBy == 'grade_section' && query.sortOrder == 'desc',
child: const Text('12-7 & Z-A'),
),
],
),
],
),
),
DataColumn2(
size: ColumnSize.S,
label: Row(
children: [
const Text(
'4Ps',
style: TextStyle(fontWeight: FontWeight.bold),
),
PopupMenuButton<String>(
icon: const Icon(Icons.arrow_drop_down, size: 18),
onSelected: (val) {
ref
.read(studentQueryProvider.notifier)
.setIs4Ps(val);
},
itemBuilder: (ctx) => [
CheckedPopupMenuItem(
value: '',
checked: query.is4Ps == '',
child: const Text('All'),
),
CheckedPopupMenuItem(
value: 'true',
checked: query.is4Ps == 'true',
child: const Text('Yes'),
),
CheckedPopupMenuItem(
value: 'false',
checked: query.is4Ps == 'false',
child: const Text('No'),
),
],
),
],
),
),
DataColumn2(
size: ColumnSize.M,
label: Row(
children: [
const Text(
'Status',
style: TextStyle(fontWeight: FontWeight.bold),
),
PopupMenuButton<String>(
icon: const Icon(Icons.arrow_drop_down, size: 18),
onSelected: (val) {
ref
.read(studentQueryProvider.notifier)
.setStatus(val);
},
itemBuilder: (ctx) => [
CheckedPopupMenuItem(
value: '',
checked: query.status == '',
child: const Text('All Status'),
),
CheckedPopupMenuItem(
value: 'Enrolled',
checked: query.status == 'Enrolled',
child: const Text('Enrolled'),
),
CheckedPopupMenuItem(
value: 'Graduated',
checked: query.status == 'Graduated',
child: const Text('Graduated'),
),
CheckedPopupMenuItem(
value: 'Transferred',
checked: query.status == 'Transferred',
child: const Text('Transferred'),
),
CheckedPopupMenuItem(
value: 'Dropped',
checked: query.status == 'Dropped',
child: const Text('Dropped'),
),
CheckedPopupMenuItem(
value: 'Inactive',
checked: query.status == 'Inactive',
child: const Text('Inactive'),
),
],
),
],
),
),
DataColumn2(
size: ColumnSize.M,
label: Row(
children: [
const Text(
'Doc Status',
style: TextStyle(fontWeight: FontWeight.bold),
),
PopupMenuButton<String>(
icon: const Icon(Icons.arrow_drop_down, size: 18),
onSelected: (val) {
ref
.read(studentQueryProvider.notifier)
.setSort(val.isEmpty ? '' : 'doc_status', val);
},
itemBuilder: (ctx) => [
CheckedPopupMenuItem(
value: '',
checked: query.sortBy == 'doc_status' && query.sortOrder == '',
child: const Text('None'),
),
CheckedPopupMenuItem(
value: 'asc',
checked: query.sortBy == 'doc_status' && query.sortOrder == 'asc',
child: const Text('Low-High Attention'),
),
CheckedPopupMenuItem(
value: 'desc',
checked: query.sortBy == 'doc_status' && query.sortOrder == 'desc',
child: const Text('High-Low Attention'),
),
],
),
],
),
),
const DataColumn2(
size: ColumnSize.S,
label: Text(
'Action',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
rows: students.map((student) {
final isSelected = _selectedStudentIds.contains(student.id);
return DataRow(
selected: isSelected,
cells: [
if (widget.userRole != 'teacher' && _showMultiSelect)
DataCell(
Checkbox(
activeColor: AppColors.primaryGreen,
value: isSelected,
onChanged: (val) {
setState(() {
if (val == true) {
_selectedStudentIds.add(student.id);
} else {
_selectedStudentIds.remove(student.id);
}
});
},
),
),
buildHoverCell(
Text(
student.lrn,
style: const TextStyle(fontWeight: FontWeight.w600),
),
student,
),
buildHoverCell(Text(student.listDisplayName), student),
buildHoverCell(Text(student.gradeSection), student),
buildHoverCell(
student.is4ps
? Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: (isDark
? const Color(0xFF8B8ED8)
: AppColors.fourPs)
.withValues(alpha: isDark ? 0.2 : 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: (isDark
? const Color(0xFF8B8ED8)
: AppColors.fourPs)
.withValues(alpha: isDark ? 0.6 : 0.35),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle,
color: isDark
? const Color(0xFF8B8ED8)
: AppColors.fourPs,
size: 13,
),
const SizedBox(width: 4),
Text(
'4Ps',
style: TextStyle(
color: isDark
? const Color(0xFF8B8ED8)
: AppColors.fourPs,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
],
),
)
: Icon(
Icons.cancel_outlined,
color: isDark
? AppColors.darkTextMuted
: Colors.grey.shade400,
size: 18,
),
student,
),
buildHoverCell(
_StatusChip(status: student.status),
student,
),
buildHoverCell(
_DocumentProgressBar(
missingCount: student.missingDocumentsCount,
totalCount: student.totalDocumentsCount,
missingDocuments: student.missingDocuments,
),
student,
),
DataCell(
_ActionButtons(
onOpenDocuments: () => _openDocumentsFolder(student),
),
),
],
);
}).toList(),
),
),
);
},
);
}
// ================================================================
// MOBILE CARD LIST
// ================================================================
Widget _buildMobileCardList(
List<StudentModel> students, {
bool noSections = false,
}) {
if (students.isEmpty) return _buildEmptyState(noSections: noSections);
return RefreshIndicator(
color: AppColors.primaryGreen,
onRefresh: () async {
ref.invalidate(studentPageProvider);
ref.invalidate(academicYearsListProvider);
ref.invalidate(gradeLevelsListProvider);
ref.invalidate(sectionsListProvider);
// Wait for the provider to rebuild
await ref.read(studentPageProvider.future);
},
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: students.length,
separatorBuilder: (ctx, index) => const SizedBox(height: AppSizes.p12),
itemBuilder: (_, i) {
final s = students[i];
return GestureDetector(
onSecondaryTapDown: defaultTargetPlatform == TargetPlatform.windows
? (details) => _showStudentContextMenu(
context,
details.globalPosition,
s,
)
: null,
child: InkWell(
onTap: () {
if (_showMultiSelect || _selectedStudentIds.isNotEmpty) {
setState(() {
if (_selectedStudentIds.contains(s.id)) {
_selectedStudentIds.remove(s.id);
} else {
_selectedStudentIds.add(s.id);
}
});
} else {
_viewProfile(s);
}
},
onLongPress: defaultTargetPlatform != TargetPlatform.windows &&
widget.userRole != 'teacher'
? () {
setState(() {
_showMultiSelect = true;
if (!_selectedStudentIds.contains(s.id)) {
_selectedStudentIds.add(s.id);
}
});
}
: null,
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
child: Container(
padding: const EdgeInsets.all(AppSizes.p16),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark ? AppColors.darkSurfaceCard : AppColors.surfaceWhite,
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
border: Border.all(color: Theme.of(context).brightness == Brightness.dark ? AppColors.darkBorder : Colors.transparent),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.userRole != 'teacher' && _showMultiSelect) ...[
Checkbox(
activeColor: AppColors.primaryGreen,
value: _selectedStudentIds.contains(s.id),
onChanged: (val) {
setState(() {
if (val == true) {
_selectedStudentIds.add(s.id);
} else {
_selectedStudentIds.remove(s.id);
}
});
},
),
const SizedBox(width: 4),
],
// Avatar
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: CircleAvatar(
radius: 22,
backgroundColor: AppColors.primaryGreen.withValues(
alpha: 0.1,
),
child: Text(
'${s.firstName.isNotEmpty ? s.firstName[0] : ''}${s.lastName.isNotEmpty ? s.lastName[0] : ''}',
style: const TextStyle(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
const SizedBox(width: AppSizes.p16),
// Info Column
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
s.listDisplayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Theme.of(context).brightness == Brightness.dark ? AppColors.darkTextPrimary : AppColors.textPrimary,
),
),
),
const SizedBox(width: 8),
if (s.is4ps) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: (Theme.of(context).brightness ==
Brightness.dark
? const Color(0xFF8B8ED8)
: AppColors.fourPs)
.withValues(
alpha: Theme.of(context).brightness ==
Brightness.dark
? 0.2
: 0.08,
),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: (Theme.of(context).brightness ==
Brightness.dark
? const Color(0xFF8B8ED8)
: AppColors.fourPs)
.withValues(
alpha: Theme.of(context).brightness ==
Brightness.dark
? 0.6
: 0.35,
),
),
),
child: Text(
'4Ps',
style: TextStyle(
color: Theme.of(context).brightness ==
Brightness.dark
? const Color(0xFF8B8ED8)
: AppColors.fourPs,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
],
_StatusChip(status: s.status),
],
),
const SizedBox(height: 2),
Text(
'LRN: ${s.lrn} · ${s.gradeSection}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).brightness == Brightness.dark ? AppColors.darkTextSecondary : AppColors.textSecondary,
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Divider(height: 1, color: Theme.of(context).brightness == Brightness.dark ? AppColors.darkBorder : Colors.grey.shade200),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
children: [
Icon(
Icons.folder_outlined,
size: 14,
color: Theme.of(context).brightness == Brightness.dark ? AppColors.darkTextSecondary : AppColors.textSecondary,
),
const SizedBox(width: 4),
_DocumentProgressBar(
missingCount: s.missingDocumentsCount,
totalCount: s.totalDocumentsCount,
missingDocuments: s.missingDocuments,
),
],
),
_ActionButtons(
onOpenDocuments: () => _openDocumentsFolder(s),
),
],
),
],
),
),
],
),
),
),
);
},
),
);
}
// ================================================================
// PAGINATION CONTROLS
// ================================================================
Widget _buildPagination(StudentQueryParams query, dynamic page) {
if (_searchFocusNode.hasFocus) return const SizedBox.shrink();
return AppPagination(
currentPage: query.page,
totalPages: page.totalPages as int,
onPageChanged: (p) => ref.read(studentQueryProvider.notifier).setPage(p),
);
}
Widget _buildEmptyState({bool noSections = false}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
if (noSections) {
// Teacher has no sections assigned at all
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? Colors.orange.withValues(alpha: 0.15) : 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.people_outline, size: 64, color: isDark ? AppColors.darkTextMuted : Colors.grey.shade300),
const SizedBox(height: 16),
Text(
'No students found.',
style: TextStyle(color: isDark ? AppColors.darkTextSecondary : AppColors.textSecondary, fontSize: 16),
),
const SizedBox(height: 8),
Text(
'Try adjusting your search or filters.',
style: TextStyle(color: isDark ? AppColors.darkTextMuted : AppColors.textMuted, fontSize: 13),
),
],
),
);
}
Widget _buildError(String message) {
return AppErrorState.fromError(
error: message,
onRetry: () => ref.invalidate(studentPageProvider),
);
}
}
// ================================================================
// EXTRACTED COMPONENTS
// ================================================================
class _StatusChip extends StatelessWidget {
final String status;
const _StatusChip({required this.status});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final bg = switch (status) {
'Enrolled' => AppColors.primaryGreen.withValues(alpha: isDark ? 0.25 : 0.10),
'Graduated' => Colors.blue.withValues(alpha: isDark ? 0.25 : 0.10),
'Transferred' => Colors.orange.withValues(alpha: isDark ? 0.25 : 0.10),
'Dropped' => Colors.red.withValues(alpha: isDark ? 0.25 : 0.10),
_ => isDark ? AppColors.darkSurface2 : Colors.grey.shade200,
};
final fg = switch (status) {
'Enrolled' => isDark ? Colors.green.shade300 : AppColors.primaryGreen,
'Graduated' => isDark ? Colors.blue.shade300 : Colors.blue.shade700,
'Transferred' => isDark ? Colors.orange.shade300 : Colors.orange.shade800,
'Dropped' => isDark ? Colors.red.shade300 : Colors.red.shade700,
_ => isDark ? AppColors.darkTextPrimary : Colors.grey.shade700,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(20),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
status,
maxLines: 1,
softWrap: false,
style: TextStyle(
color: fg,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
);
}
}
// ----------------------------------------------------------------
// NEW PROGRESS BAR COMPONENT (Replaces _MissingDocsBadge)
// ----------------------------------------------------------------
class _DocumentProgressBar extends StatelessWidget {
final int missingCount;
final int totalCount;
final List<String> missingDocuments;
const _DocumentProgressBar({
required this.missingCount,
required this.totalCount,
required this.missingDocuments,
});
@override
Widget build(BuildContext context) {
// Calculate how many documents are completed
final isDark = Theme.of(context).brightness == Brightness.dark;
final int completedCount = (totalCount - missingCount).clamp(0, totalCount);
// Prevent division by zero if totalCount is 0 (e.g. no requirements)
final double progress = totalCount == 0 ? 1.0 : completedCount / totalCount;
final bool isComplete = missingCount == 0 && totalCount > 0;
String tooltipMessage = '';
if (!isComplete && missingDocuments.isNotEmpty) {
final jhsDocs = missingDocuments
.where((d) => d.startsWith('[JHS]'))
.map((d) => d.replaceFirst('[JHS] ', ''))
.toList();
final shsDocs = missingDocuments
.where((d) => d.startsWith('[SHS]'))
.map((d) => d.replaceFirst('[SHS] ', ''))
.toList();
final otherDocs = missingDocuments
.where((d) => !d.startsWith('[JHS]') && !d.startsWith('[SHS]'))
.toList();
final lines = <String>['Missing Documents:'];
if (jhsDocs.isNotEmpty) {
lines.add('JHS:');
lines.addAll(jhsDocs.map((d) => ' • $d'));
}
if (shsDocs.isNotEmpty) {
lines.add('SHS:');
lines.addAll(shsDocs.map((d) => ' • $d'));
}
if (otherDocs.isNotEmpty) {
if (jhsDocs.isNotEmpty || shsDocs.isNotEmpty) lines.add('Other:');
lines.addAll(otherDocs.map((d) => ' • $d'));
}
tooltipMessage = lines.join('\n');
} else if (isComplete) {
tooltipMessage = 'All documents completed';
} else {
tooltipMessage = 'No documents required';
}
return Tooltip(
message: tooltipMessage,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$completedCount / $totalCount Docs',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isComplete
? AppColors.primaryGreen
: (isDark ? AppColors.darkTextPrimary : AppColors.textPrimary),
),
),
if (isComplete) ...[
const SizedBox(width: 4),
const Icon(
Icons.check_circle,
color: AppColors.primaryGreen,
size: 14,
),
],
],
),
const SizedBox(height: 6),
SizedBox(
width: 100, // Fixed width to keep column formatting tidy
child: LinearProgressIndicator(
value: progress,
backgroundColor: isDark ? AppColors.darkBorder : Colors.grey.shade200,
color: isComplete
? AppColors.primaryGreen
: Colors.orange, // Orange indicates pending docs
minHeight: 6,
borderRadius: BorderRadius.circular(AppSizes.radiusCircular),
),
),
],
),
);
}
}
class _ActionButtons extends StatelessWidget {
final VoidCallback onOpenDocuments;
const _ActionButtons({required this.onOpenDocuments});
@override
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.folder_open, color: Colors.orange, size: 22),
tooltip: 'Open Folder',
onPressed: onOpenDocuments,
);
}
}
// ============================================================
// BULK ENROLLMENT MODAL DIALOG
// ============================================================
class BulkEnrollDialog extends ConsumerStatefulWidget {
final List<int> studentIds;
final VoidCallback onSuccess;
const BulkEnrollDialog({
super.key,
required this.studentIds,
required this.onSuccess,
});
@override
ConsumerState<BulkEnrollDialog> createState() => _BulkEnrollDialogState();
}
class _BulkEnrollDialogState extends ConsumerState<BulkEnrollDialog> {
final _formKey = GlobalKey<FormState>();
int? _selectedAcademicYearId;
int? _selectedGradeLevel;
int? _selectedSectionId;
String? _trackStrand;
bool _isLoading = false;
@override
Widget build(BuildContext context) {
final yearsAsync = ref.watch(academicYearsListProvider);
final gradeLevelsAsync = ref.watch(gradeLevelsListProvider);
final sectionsAsync = ref.watch(sectionsListProvider);
return AlertDialog(
title: Text('Bulk Enroll ${widget.studentIds.length} Students'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
yearsAsync.when(
data: (years) {
if (_selectedAcademicYearId == null && years.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted)
setState(
() => _selectedAcademicYearId = years.first.id,
);
});
}
return DropdownButtonFormField<int>(
value: _selectedAcademicYearId,
decoration: const InputDecoration(
labelText: 'Academic Year',
prefixIcon: Icon(Icons.calendar_today),
border: OutlineInputBorder(),
),
items: years
.map(
(y) => DropdownMenuItem<int>(
value: y.id,
child: Text(y.yearRange),
),
)
.toList(),
onChanged: (val) {
setState(() {
_selectedAcademicYearId = val;
_selectedGradeLevel = 7;
_selectedSectionId = null;
});
},
validator: (v) =>
v == null ? 'Academic year is required.' : null,
);
},
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
),
),
error: (e, _) => Text('Error: $e'),
),
const SizedBox(height: AppSizes.p16),
gradeLevelsAsync.when(
data: (grades) {
return DropdownButtonFormField<int>(
value: _selectedGradeLevel,
decoration: const InputDecoration(
labelText: 'Grade Level',
prefixIcon: Icon(Icons.grade),
border: OutlineInputBorder(),
),
items: grades
.map(
(g) => DropdownMenuItem<int>(
value: g.level,
child: Text(g.name),
),
)
.toList(),
onChanged: (val) {
setState(() {
_selectedGradeLevel = val;
_selectedSectionId = null;
});
},
validator: (v) =>
v == null ? 'Grade level is required.' : null,
);
},
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
),
),
error: (e, _) => Text('Error: $e'),
),
const SizedBox(height: AppSizes.p16),
sectionsAsync.when(
data: (sections) {
final filtered = sections
.where(
(sec) =>
sec.academicYearId == _selectedAcademicYearId &&
sec.gradeLevel == _selectedGradeLevel,
)
.toList();
return DropdownButtonFormField<int>(
value: _selectedSectionId,
decoration: const InputDecoration(
labelText: 'Section',
prefixIcon: Icon(Icons.segment),
border: OutlineInputBorder(),
),
items: filtered
.map(
(s) => DropdownMenuItem<int>(
value: s.id,
child: Text(s.name),
),
)
.toList(),
onChanged: (val) =>
setState(() => _selectedSectionId = val),
validator: (v) =>
v == null ? 'Section is required.' : null,
);
},
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
),
),
error: (e, _) => Text('Error: $e'),
),
if (_selectedGradeLevel != null &&
_selectedGradeLevel! >= 11) ...[
const SizedBox(height: AppSizes.p16),
TextFormField(
decoration: const InputDecoration(
labelText: 'Track & Strand (for SHS)',
prefixIcon: Icon(Icons.school_outlined),
border: OutlineInputBorder(),
),
onChanged: (val) =>
_trackStrand = val.trim().isEmpty ? null : val.trim(),
),
],
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'CANCEL',
style: TextStyle(
color: AppColors.textSecondary,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(
width: 150,
child: PrimaryButton(
label: 'ENROLL',
isLoading: _isLoading,
onPressed: _handleSubmit,
),
),
],
);
}
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
await ref
.read(studentMutationProvider.notifier)
.bulkEnroll(
studentIds: widget.studentIds,
academicYearId: _selectedAcademicYearId!,
gradeLevel: _selectedGradeLevel!,
sectionId: _selectedSectionId!,
trackStrand: _trackStrand,
);
if (!mounted) return;
Navigator.pop(context);
widget.onSuccess();
} catch (e) {
if (!mounted) return;
showErrorDialog(
context,
'Bulk Enrollment Failed',
'Failed to bulk enroll: $e',
);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
}