Files
TIS_RMS/frontend/lib/ui/layouts/android_bottom_nav_layout.dart
T

1413 lines
57 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/constants/app_colors.dart';
import '../screens/dashboard/dashboard_screen.dart';
import '../screens/settings/settings_screen.dart';
import '../screens/students/students_screen.dart';
import '../screens/documents/documents_screen.dart';
import '../screens/archives/archives_screen.dart';
import '../screens/reports/reports_screen.dart';
import '../screens/users/users_screen.dart';
import '../screens/settings/teacher_management_screen.dart';
import '../screens/audit_trail/audit_trail_screen.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; // Import Riverpod
import '../providers/dashboard_provider.dart';
import '../providers/student_provider.dart' hide academicYearsProvider;
import '../providers/document_provider.dart';
import '../providers/archives_provider.dart';
import '../providers/reports_provider.dart';
import '../providers/users_provider.dart';
import '../providers/activity_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/setup_provider.dart';
import '../providers/system_settings_provider.dart';
import '../shared/dialogs/logout_dialog.dart';
import '../shared/widgets/abstract_background.dart';
import '../providers/navigation_provider.dart';
import '../providers/connected_users_provider.dart';
import '../screens/capstone_members/capstone_members_screen.dart';
import '../shared/dialogs/disconnected_dialog.dart';
import '../shared/inputs/app_search_bar.dart';
import '../shared/menus/profile_dropdown_menu.dart';
import '../shared/widgets/notification_icon_button.dart';
import '../screens/students/widgets/student_bulk_actions.dart';
import '../screens/documents/widgets/print_queue_modal.dart';
import '../screens/documents/widgets/upload_ocr_modal.dart';
import '../shared/widgets/server_connection_pill.dart';
// Dummy screen for placeholders
class PlaceholderScreen extends StatelessWidget {
final String title;
const PlaceholderScreen(this.title, {super.key});
@override
Widget build(BuildContext context) =>
Center(child: Text(title, style: const TextStyle(fontSize: 24)));
}
class AndroidBottomNavLayout extends ConsumerStatefulWidget {
final String userRole;
const AndroidBottomNavLayout({super.key, required this.userRole});
@override
ConsumerState<AndroidBottomNavLayout> createState() =>
_AndroidBottomNavLayoutState();
}
class _AndroidBottomNavLayoutState extends ConsumerState<AndroidBottomNavLayout>
with TickerProviderStateMixin {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final Set<int> _visitedIndices = {};
Timer? _holdTimer;
Timer? _tabLoadingTimer;
bool _isTabLoading = false;
TabController? _tabController;
List<Map<String, dynamic>> _tabs = [];
List<Map<String, dynamic>> _allowedPrimary = [];
List<Map<String, dynamic>> _allowedSecondary = [];
@override
void initState() {
super.initState();
_initTabs();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(heartbeatServiceProvider).onConnectionLost = () {
if (mounted) {
DisconnectedDialog.show(
context,
onReconnected: () {
final currentIdx = _tabController?.index ?? 0;
if (currentIdx < _allowedPrimary.length) {
final label =
_allowedPrimary[currentIdx]['label'] as String? ?? '';
_reloadTabContent(label);
}
},
);
}
};
});
}
void _showStudentSearchDialog(
BuildContext context,
WidgetRef ref, {
bool navigateToStudents = false,
}) {
final currentSearch = ref.read(studentQueryProvider).search;
final searchController = TextEditingController(text: currentSearch);
final searchFocusNode = FocusNode();
WidgetsBinding.instance.addPostFrameCallback((_) {
searchFocusNode.requestFocus();
});
showDialog(
context: context,
barrierColor: Colors.black54,
builder: (ctx) {
final isDark = Theme.of(ctx).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(ctx).pop();
if (value.trim().isNotEmpty) {
ref.read(studentQueryProvider.notifier).setFilters(
status: '',
schoolYear: '',
gradeLevel: '',
section: '',
);
}
ref.read(studentQueryProvider.notifier).setSearch(value);
if (navigateToStudents) {
ref.invalidate(studentPageProvider);
ref.read(activeTabProvider.notifier).setTab('Students');
}
},
),
),
),
);
},
);
}
void _showDocumentSearchDialog(
BuildContext context,
WidgetRef ref,
) {
final currentSearch = ref.read(documentQueryProvider).search;
final searchController = TextEditingController(text: currentSearch);
final searchFocusNode = FocusNode();
WidgetsBinding.instance.addPostFrameCallback((_) {
searchFocusNode.requestFocus();
});
showDialog(
context: context,
barrierColor: Colors.black54,
builder: (ctx) {
final isDark = Theme.of(ctx).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 name, LRN, file…',
maxWidth: 600,
onSubmitted: (value) {
Navigator.of(ctx).pop();
ref.read(documentQueryProvider.notifier).setSearch(value);
ref.invalidate(foldersProvider);
ref.invalidate(studentFoldersProvider);
ref.invalidate(documentPageProvider);
},
),
),
),
);
},
);
}
void _showArchiveSearchDialog(
BuildContext context,
WidgetRef ref,
) {
final currentSearch = ref.read(archiveDocumentQueryProvider).search;
final searchController = TextEditingController(text: currentSearch);
final searchFocusNode = FocusNode();
WidgetsBinding.instance.addPostFrameCallback((_) {
searchFocusNode.requestFocus();
});
showDialog(
context: context,
barrierColor: Colors.black54,
builder: (ctx) {
final isDark = Theme.of(ctx).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 archives by name, LRN, file…',
maxWidth: 600,
onSubmitted: (value) {
Navigator.of(ctx).pop();
ref
.read(archiveDocumentQueryProvider.notifier)
.setSearch(value);
ref.invalidate(archiveStudentFoldersProvider);
ref.invalidate(archiveDocumentPageProvider);
},
),
),
),
);
},
);
}
void _showUserSearchDialog(
BuildContext context,
WidgetRef ref,
) {
final currentSearch = ref.read(userSearchQueryProvider);
final searchController = TextEditingController(text: currentSearch);
final searchFocusNode = FocusNode();
WidgetsBinding.instance.addPostFrameCallback((_) {
searchFocusNode.requestFocus();
});
showDialog(
context: context,
barrierColor: Colors.black54,
builder: (ctx) {
final isDark = Theme.of(ctx).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 username, name, email or role...',
maxWidth: 600,
onChanged: (value) {
ref.read(userSearchQueryProvider.notifier).state = value;
},
onSubmitted: (value) {
Navigator.of(ctx).pop();
ref.read(userSearchQueryProvider.notifier).state = value;
},
),
),
),
);
},
);
}
void _initTabs() {
final primaryTabsConfig = [
{
'label': 'Dashboard',
'icon': Icons.dashboard_outlined,
'activeIcon': Icons.dashboard,
'screen': const DashboardScreen(),
'roles': ['admin', 'teacher'],
},
{
'label': 'Students',
'icon': Icons.people_outline,
'activeIcon': Icons.people,
'screen': StudentsScreen(userRole: widget.userRole),
'roles': ['admin', 'teacher'],
},
{
'label': 'Documents',
'icon': Icons.folder_outlined,
'activeIcon': Icons.folder,
'screen': DocumentsScreen(userRole: widget.userRole),
'roles': ['admin', 'teacher'],
},
{
'label': 'Archives',
'icon': Icons.archive_outlined,
'activeIcon': Icons.archive,
'screen': ArchivesScreen(userRole: widget.userRole),
'roles': ['admin'],
},
];
final secondaryTabsConfig = [
{
'label': 'Reports',
'icon': Icons.bar_chart,
'activeIcon': Icons.bar_chart,
'screen': ReportsScreen(userRole: widget.userRole),
'roles': ['admin'],
},
{
'label': 'Users',
'icon': Icons.manage_accounts_outlined,
'activeIcon': Icons.manage_accounts,
'screen': const UsersScreen(),
'roles': ['admin'],
},
{
'label': 'Activity History',
'icon': Icons.manage_history_outlined,
'activeIcon': Icons.manage_history_rounded,
'screen': AuditTrailScreen(userRole: widget.userRole),
'roles': ['admin'],
},
{
'label': 'Settings',
'icon': Icons.settings_outlined,
'activeIcon': Icons.settings,
'screen': SettingsScreen(userRole: widget.userRole),
'roles': ['admin', 'teacher'],
},
];
_allowedPrimary = primaryTabsConfig
.where((t) => (t['roles'] as List<String>).contains(widget.userRole))
.toList();
_allowedSecondary = secondaryTabsConfig
.where((t) => (t['roles'] as List<String>).contains(widget.userRole))
.toList();
_tabs = [..._allowedPrimary, ..._allowedSecondary];
_tabController = TabController(length: _tabs.length, vsync: this);
_tabController!.addListener(_handleTabSelection);
}
void _handleTabSelection() {
if (_tabController!.indexIsChanging) return;
final newLabel = _tabs[_tabController!.index]['label'] as String;
if (ref.read(activeTabProvider) != newLabel) {
ref.read(activeTabProvider.notifier).setTab(newLabel);
_reloadTabContent(newLabel);
}
}
@override
void dispose() {
_tabController?.dispose();
_holdTimer?.cancel();
_tabLoadingTimer?.cancel();
super.dispose();
}
void _reloadTabContent(String label) {
switch (label) {
case 'Dashboard':
ref.invalidate(dashboardDataProvider);
break;
case 'Students':
ref.read(studentViewTabProvider.notifier).state = StudentViewTab.enrolled;
ref.read(studentQueryProvider.notifier).reset();
ref.invalidate(studentPageProvider);
break;
case 'Documents':
ref.invalidate(foldersProvider);
ref.invalidate(documentPageProvider);
break;
case 'Archives':
ref.invalidate(archivePageProvider);
break;
case 'Reports':
ref.invalidate(reportStatsProvider);
ref.invalidate(academicYearsProvider);
ref.invalidate(yearlyComparisonProvider);
break;
case 'Users':
ref.read(userSearchQueryProvider.notifier).state = '';
ref.invalidate(usersProvider);
break;
case 'History':
case 'Activity History':
ref.invalidate(activityQueryProvider);
ref.invalidate(recentActivitiesPageProvider);
ref.invalidate(userHistoryQueryProvider);
ref.invalidate(userHistoryPageProvider);
break;
case 'Settings':
ref.invalidate(academicYearsListProvider);
ref.invalidate(sectionsListProvider);
ref.invalidate(gradeLevelsListProvider);
ref.invalidate(systemSettingsProvider);
ref.invalidate(profileProvider);
ref.invalidate(requirementsSettingsProvider);
break;
}
}
void _onNavTapped(String label) {
if (ref.read(studentMultiSelectProvider)) {
ref.read(studentMultiSelectProvider.notifier).state = false;
ref.read(studentSelectedIdsProvider.notifier).state = [];
}
setState(() {
_isTabLoading = true;
});
_tabLoadingTimer?.cancel();
_tabLoadingTimer = Timer(const Duration(milliseconds: 100), () {
if (mounted) {
setState(() {
_isTabLoading = false;
});
}
});
ref.read(activeTabProvider.notifier).setTab(label);
_reloadTabContent(label);
}
void _showCapstoneMembers(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const CapstoneMembersScreen()),
);
}
@override
Widget build(BuildContext context) {
final activeTab = ref.watch(activeTabProvider);
int currentIndex = _tabs.indexWhere((t) => t['label'] == activeTab);
if (currentIndex == -1) currentIndex = 0; // Fallback to Dashboard
// if (_tabController != null && _tabController!.index != currentIndex) {
// _tabController!.animateTo(currentIndex);
// }
_visitedIndices.add(currentIndex);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
// If any text input has focus, unfocus it first before navigating
final primaryFocus = FocusManager.instance.primaryFocus;
if (primaryFocus != null && primaryFocus.hasFocus) {
primaryFocus.unfocus();
return;
}
if (activeTab != 'Dashboard') {
if (activeTab == 'Documents' ||
activeTab == 'Students' ||
activeTab == 'Archives') {
return;
}
_onNavTapped('Dashboard');
return;
}
final shouldExit = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.redAccent),
SizedBox(width: 10),
Text('Confirm Exit'),
],
),
content: const Text('Are you sure you want to exit the app?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text(
'CANCEL',
style: TextStyle(color: Colors.grey),
),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text(
'EXIT',
style: TextStyle(
color: Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
if (shouldExit == true) {
SystemNavigator.pop();
}
},
child: Builder(
builder: (context) {
final mediaQuery = MediaQuery.of(context);
final lockedMediaQuery = mediaQuery.copyWith(
viewInsets: EdgeInsets.zero,
padding: mediaQuery.viewPadding,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
final overlayStyle = SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness:
isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness:
isDark ? Brightness.light : Brightness.dark,
systemNavigationBarDividerColor: Colors.transparent,
);
final isStudentMultiSelect = (activeTab == 'Students') &&
ref.watch(studentMultiSelectProvider);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: isStudentMultiSelect ? SystemUiOverlayStyle.light : overlayStyle,
child: MediaQuery(
data: lockedMediaQuery,
child: Scaffold(
key: _scaffoldKey,
drawerEnableOpenDragGesture: !isStudentMultiSelect,
drawerEdgeDragWidth: isStudentMultiSelect ? 0.0 : 64.0,
resizeToAvoidBottomInset: false,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: isStudentMultiSelect
? AppBar(
systemOverlayStyle: SystemUiOverlayStyle.light,
backgroundColor: isDark
? AppColors.darkSurfaceCard
: AppColors.primaryGreen,
foregroundColor: Colors.white,
elevation: 1,
leading: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
tooltip: 'Exit Selection',
onPressed: () {
ref.read(studentMultiSelectProvider.notifier).state = false;
ref.read(studentSelectedIdsProvider.notifier).state = [];
},
),
title: Consumer(
builder: (context, ref, _) {
final selectedIds = ref.watch(studentSelectedIdsProvider);
return Text(
'${selectedIds.length} selected',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
},
),
actions: [
Consumer(
builder: (context, ref, _) {
final selectedIds = ref.watch(studentSelectedIdsProvider);
final pageAsync = ref.watch(studentPageProvider);
final currentStudents = pageAsync.value?.students ?? [];
final allSelected = currentStudents.isNotEmpty &&
currentStudents.every((s) => selectedIds.contains(s.id));
return IconButton(
icon: Icon(
allSelected ? Icons.deselect : Icons.select_all,
color: Colors.white,
),
tooltip: allSelected ? 'Unselect All' : 'Select All',
onPressed: () {
if (allSelected) {
ref.read(studentSelectedIdsProvider.notifier).state = [];
} else {
ref.read(studentSelectedIdsProvider.notifier).state =
currentStudents.map((s) => s.id).toList();
}
},
);
},
),
if (widget.userRole != 'teacher')
Consumer(
builder: (context, ref, _) {
final selectedIds = ref.watch(studentSelectedIdsProvider);
if (selectedIds.isEmpty) return const SizedBox(width: 4);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.group_add_rounded, color: Colors.white),
tooltip: 'Enroll',
onPressed: () => StudentBulkActions.bulkEnroll(context, ref),
),
IconButton(
icon: const Icon(Icons.school_rounded, color: Colors.white),
tooltip: 'Graduate',
onPressed: () => StudentBulkActions.bulkGraduate(context, ref),
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert_rounded, color: Colors.white),
tooltip: 'More Actions',
onSelected: (action) {
if (action == 'Transferred' || action == 'Dropped' || action == 'Inactive') {
StudentBulkActions.bulkChangeStatus(context, ref, action);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'Transferred',
child: Row(
children: [
Icon(Icons.transfer_within_a_station_rounded, size: 20, color: AppColors.primaryGreen),
SizedBox(width: 10),
Text('Transfer'),
],
),
),
const PopupMenuItem(
value: 'Dropped',
child: Row(
children: [
Icon(Icons.person_off_rounded, size: 20, color: Colors.redAccent),
SizedBox(width: 10),
Text('Drop'),
],
),
),
const PopupMenuItem(
value: 'Inactive',
child: Row(
children: [
Icon(Icons.do_not_disturb_on_total_silence_rounded, size: 20, color: Colors.orangeAccent),
SizedBox(width: 10),
Text('Set Inactive'),
],
),
),
],
),
const SizedBox(width: 4),
],
);
},
),
],
)
: AppBar(
systemOverlayStyle: overlayStyle,
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
foregroundColor: Theme.of(context).colorScheme.onSurface,
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
leading: Builder(
builder: (context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) {
if (details.primaryDelta != null &&
details.primaryDelta! > 4) {
_scaffoldKey.currentState?.openDrawer();
}
},
child: IconButton(
icon: const Icon(Icons.menu_rounded),
tooltip: 'Open navigation menu',
onPressed: () {
_scaffoldKey.currentState?.openDrawer();
},
),
),
),
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
activeTab,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 8),
const ServerConnectionPill(compact: true),
],
),
elevation: 0,
surfaceTintColor: Colors.transparent,
actions: [
if (activeTab == 'Dashboard') ...[
// 1. Search Icon
Consumer(
builder: (context, ref, _) {
return Tooltip(
message: 'Search Students',
child: IconButton(
icon: const Icon(Icons.search, size: 22),
onPressed: () => _showStudentSearchDialog(
context,
ref,
navigateToStudents: true,
),
),
);
},
),
// 2. Notification Icon with Unread Badge
const NotificationIconButton(iconSize: 22),
// 3. Profile Avatar Dropdown
Consumer(
builder: (context, ref, _) {
final user = ref.watch(authProvider).value;
return Padding(
padding: const EdgeInsets.only(left: 4, right: 12),
child: ProfileDropdownMenu(
user: user,
onRefresh: () =>
ref.read(authProvider.notifier).refreshUser(),
),
);
},
),
],
if (activeTab == 'Documents') ...[
// 1. Search Icon (left side of Print List icon)
Consumer(
builder: (context, ref, _) {
return Tooltip(
message: 'Search Documents',
child: IconButton(
icon: const Icon(Icons.search, size: 22),
onPressed: () =>
_showDocumentSearchDialog(context, ref),
),
);
},
),
// 2. Print List Icon (for non-teachers)
if (widget.userRole != 'teacher')
Consumer(
builder: (context, ref, _) {
final queueAsync = ref.watch(printQueueProvider);
final count = queueAsync.maybeWhen(
data: (items) => items.length,
orElse: () => 0,
);
return Tooltip(
message: 'Print List',
child: IconButton(
icon: Badge(
isLabelVisible: count > 0,
label: Text(count.toString()),
child: const Icon(Icons.print_rounded, size: 22),
),
onPressed: () => PrintQueueModal.show(context),
),
);
},
),
// 3. Upload Document Icon (High-Attraction Elevated Primary CTA)
Tooltip(
message: 'Upload Document',
child: Container(
width: 36,
height: 36,
margin: const EdgeInsets.only(left: 4, right: 8),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFF00B074),
AppColors.primaryGreen,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: AppColors.primaryGreen.withValues(alpha: 0.4),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () {
final openedFolder = ref.read(openedFolderProvider);
final studentId = openedFolder?.id ??
ref.read(documentQueryProvider).studentId;
UploadOcrModal.show(
context,
prefilledStudentId: studentId,
prefilledLrn: openedFolder?.lrn,
);
},
child: const Center(
child: Icon(
Icons.cloud_upload_rounded,
size: 20,
color: Colors.white,
),
),
),
),
),
),
const SizedBox(width: 4),
],
if (activeTab == 'Archives') ...[
// 1. Search Icon
Consumer(
builder: (context, ref, _) {
return Tooltip(
message: 'Search Archives',
child: IconButton(
icon: const Icon(Icons.search, size: 22),
onPressed: () =>
_showArchiveSearchDialog(context, ref),
),
);
},
),
// 2. Print List Icon (for non-teachers)
if (widget.userRole != 'teacher')
Consumer(
builder: (context, ref, _) {
final queueAsync = ref.watch(printQueueProvider);
final count = queueAsync.maybeWhen(
data: (items) => items.length,
orElse: () => 0,
);
return Tooltip(
message: 'Print List',
child: IconButton(
icon: Badge(
isLabelVisible: count > 0,
label: Text(count.toString()),
child: const Icon(Icons.print_rounded, size: 22),
),
onPressed: () => PrintQueueModal.show(context),
),
);
},
),
const SizedBox(width: 4),
],
if (activeTab == 'Users') ...[
// 1. Search Icon
Consumer(
builder: (context, ref, _) {
final query = ref.watch(userSearchQueryProvider);
return Tooltip(
message: query.isNotEmpty
? 'Clear Search'
: 'Search Users',
child: IconButton(
icon: Icon(
query.isNotEmpty
? Icons.close
: Icons.search,
size: 22,
),
onPressed: () {
if (query.isNotEmpty) {
ref.read(userSearchQueryProvider.notifier).state = '';
} else {
_showUserSearchDialog(context, ref);
}
},
),
);
},
),
// 2. Academic & Class Management (Admin shortcut)
if (widget.userRole == 'admin')
Tooltip(
message: 'Academic & Class Management',
child: IconButton(
icon: const Icon(Icons.school_outlined, size: 22),
onPressed: () =>
TeacherManagementModal.open(context),
),
),
// 3. Add User Button (High-Attraction Elevated Primary CTA)
Tooltip(
message: 'Add User',
child: Container(
width: 36,
height: 36,
margin: const EdgeInsets.only(left: 4, right: 8),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFF00B074),
AppColors.primaryGreen,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: AppColors.primaryGreen.withValues(alpha: 0.4),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => AddEditUserModal.show(context),
child: const Center(
child: Icon(
Icons.person_add_rounded,
size: 20,
color: Colors.white,
),
),
),
),
),
),
const SizedBox(width: 4),
],
],
),
drawer: Drawer(
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
width: 280,
child: SafeArea(
child: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.only(
top: 16,
bottom: 16,
left: 24,
right: 16,
),
child: Row(
children: [
GestureDetector(
onLongPressStart: (_) {
_holdTimer = Timer(const Duration(seconds: 2), () {
_showCapstoneMembers(context);
});
},
onLongPressEnd: (_) => _holdTimer?.cancel(),
onLongPressCancel: () => _holdTimer?.cancel(),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
'assets/images/logo.webp',
width: 36,
height: 36,
fit: BoxFit.contain,
),
),
),
),
const SizedBox(width: 12),
Text(
'TIS RMS',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
letterSpacing: 1.2,
color: Theme.of(context).colorScheme.onSurface,
),
),
const Spacer(),
const _DrawerCloseButton(),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ListView(
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
children: [
if (_allowedPrimary.isNotEmpty) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
alignment: Alignment.centerLeft,
child: Text(
'MAIN',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.38),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
),
child: Column(
children: [
..._allowedPrimary.map(
(tab) => _buildDrawerItem(
tab,
currentIndex,
_tabs,
),
),
],
),
),
],
],
),
),
if (_allowedSecondary.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
alignment: Alignment.centerLeft,
child: Text(
'ADMINISTRATION',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.38),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
),
child: Column(
children: [
..._allowedSecondary.map(
(tab) => _buildDrawerItem(
tab,
currentIndex,
_tabs,
),
),
],
),
),
],
),
],
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Align(
alignment: Alignment.centerLeft,
child: ServerConnectionPill(compact: false),
),
),
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 16, 24),
child: Row(
children: [
Builder(
builder: (context) {
final user = ref.watch(authProvider).value;
final initials =
user != null &&
user.firstName.isNotEmpty &&
user.lastName.isNotEmpty
? '${user.firstName[0]}${user.lastName[0]}'
.toUpperCase()
: 'SA';
return CircleAvatar(
radius: 20,
backgroundColor: AppColors.primaryGreen,
child: Text(
initials,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
);
},
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ref.watch(authProvider).value?.fullName ??
'Unknown User',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
widget.userRole.toUpperCase(),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
IconButton(
icon: Icon(
Icons.logout,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
onPressed: () {
Navigator.pop(context); // Close drawer first
showLogoutConfirmationDialog(context);
},
),
],
),
),
],
),
),
),
body: AbstractBackground(
child: Stack(
children: [
IndexedStack(
index: currentIndex,
children: _tabs.asMap().entries.map((entry) {
return _visitedIndices.contains(entry.key)
? entry.value['screen'] as Widget
: const SizedBox.shrink();
}).toList(),
),
if (_isTabLoading)
Container(
color: Theme.of(context).scaffoldBackgroundColor,
width: double.infinity,
height: double.infinity,
child: const _PageSkeletonLoader(),
),
],
),
),
),
),
);
},
),
);
}
Widget _buildDrawerItem(
Map<String, dynamic> tab,
int currentIndex,
List<Map<String, dynamic>> allTabs,
) {
final label = tab['label'] as String;
final icon = tab['icon'] as IconData;
final activeIcon = tab['activeIcon'] as IconData? ?? icon;
final index = allTabs.indexWhere((t) => t['label'] == label);
final isSelected = index == currentIndex;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
child: Material(
color: isSelected
? AppColors.primaryGreen.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
Navigator.pop(context); // Close drawer
_onNavTapped(label);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 12.0,
),
child: Row(
children: [
Icon(
isSelected ? activeIcon : icon,
color: isSelected
? AppColors.primaryGreen
: (isDark ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6) : Colors.grey.shade600),
size: 22,
),
const SizedBox(width: 16),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected
? AppColors.primaryGreen
: (isDark ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7) : Colors.grey.shade700),
),
),
],
),
),
),
),
);
}
}
class _PageSkeletonLoader extends StatefulWidget {
const _PageSkeletonLoader();
@override
State<_PageSkeletonLoader> createState() => _PageSkeletonLoaderState();
}
class _PageSkeletonLoaderState extends State<_PageSkeletonLoader>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseColor = isDark ? Colors.grey.shade800 : Colors.grey.shade300;
final secondaryColor = isDark ? Colors.grey.shade900 : Colors.grey.shade200;
return FadeTransition(
opacity: Tween<double>(begin: 0.3, end: 1.0).animate(_controller),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 40,
width: 180,
decoration: BoxDecoration(
color: baseColor,
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: Container(
height: 80,
decoration: BoxDecoration(
color: secondaryColor,
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Container(
height: 80,
decoration: BoxDecoration(
color: secondaryColor,
borderRadius: BorderRadius.circular(12),
),
),
),
],
),
const SizedBox(height: 24),
Expanded(
child: ListView.separated(
itemCount: 5,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (_, _) => const SizedBox(height: 16),
itemBuilder: (_, _) => Container(
height: 60,
decoration: BoxDecoration(
color: secondaryColor,
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
),
);
}
}
class _DrawerCloseButton extends StatefulWidget {
const _DrawerCloseButton();
@override
State<_DrawerCloseButton> createState() => _DrawerCloseButtonState();
}
class _DrawerCloseButtonState extends State<_DrawerCloseButton> {
bool _useArrow = false;
@override
void initState() {
super.initState();
Future.delayed(const Duration(milliseconds: 150), () {
if (mounted) {
setState(() {
_useArrow = true;
});
}
});
}
@override
Widget build(BuildContext context) {
return IconButton(
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(scale: animation, child: child),
);
},
child: Icon(
_useArrow ? Icons.arrow_back : Icons.menu,
key: ValueKey(_useArrow),
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.54),
),
),
onPressed: () => Navigator.pop(context),
);
}
}