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

1013 lines
42 KiB
Dart

import 'dart:async';
import 'dart:ui' show ImageFilter;
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/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 '../shared/widgets/server_connection_pill.dart';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:window_manager/window_manager.dart';
import '../screens/capstone_members/capstone_members_screen.dart';
import '../screens/documentation/documentation_screen.dart';
import '../shared/dialogs/disconnected_dialog.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 WindowsSidebarLayout extends ConsumerStatefulWidget {
final String userRole;
final bool initialMinimized;
const WindowsSidebarLayout({
super.key,
required this.userRole,
this.initialMinimized = false,
});
@override
ConsumerState<WindowsSidebarLayout> createState() =>
_WindowsSidebarLayoutState();
}
class _WindowsSidebarLayoutState extends ConsumerState<WindowsSidebarLayout> {
final Set<int> _visitedIndices = {};
Timer? _holdTimer;
Timer? _tabLoadingTimer;
late bool _isMinimized;
bool _isTabLoading = false;
bool _isLogoHovered = false;
@override
void initState() {
super.initState();
_isMinimized = widget.initialMinimized;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(heartbeatServiceProvider).onConnectionLost = () {
if (mounted) {
DisconnectedDialog.show(
context,
onReconnected: () {
final activeTab = ref.read(activeTabProvider);
_reloadTabContent(activeTab);
},
);
}
};
});
}
@override
void dispose() {
_holdTimer?.cancel();
_tabLoadingTimer?.cancel();
super.dispose();
}
void _reloadTabContent(String label) {
switch (label) {
case 'Dashboard':
ref.invalidate(dashboardDataProvider);
break;
case 'Students':
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 _showCapstoneMembers(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const CapstoneMembersScreen()),
);
}
Widget _buildNavItem(
Map<String, Object> tab,
bool isSelected,
bool isCategoryHeader, {
required bool isDark,
bool isFirstItem = false,
}) {
final String? category = tab['category'] as String?;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isCategoryHeader && category != null)
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: _isMinimized
? const SizedBox.shrink()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isFirstItem) const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.only(
left: 14,
bottom: 6,
top: 4,
),
child: Text(
category.toUpperCase(),
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.55),
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.9,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Tooltip(
message: _isMinimized ? tab['label'] as String : '',
waitDuration: const Duration(milliseconds: 500),
child: Material(
color: isSelected
? (isDark
? AppColors.primaryGreen.withValues(alpha: 0.16)
: AppColors.primaryGreen.withValues(alpha: 0.10))
: Colors.transparent,
borderRadius: BorderRadius.circular(6),
clipBehavior: Clip.antiAlias,
child: InkWell(
borderRadius: BorderRadius.circular(6),
hoverColor: isDark
? Colors.white.withValues(alpha: 0.05)
: Colors.black.withValues(alpha: 0.04),
onTap: () {
setState(() {
_isTabLoading = true;
});
_tabLoadingTimer?.cancel();
_tabLoadingTimer = Timer(
const Duration(milliseconds: 100),
() {
if (mounted) {
setState(() {
_isTabLoading = false;
});
}
},
);
ref
.read(activeTabProvider.notifier)
.setTab(tab['label'] as String);
_reloadTabContent(tab['label'] as String);
},
child: SizedBox(
height: 38,
child: Row(
children: [
// WinUI 3 Fluent Active Indicator Pill
Padding(
padding: const EdgeInsets.only(left: 3),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
width: 3,
height: isSelected ? 16 : 0,
decoration: BoxDecoration(
color: isSelected
? AppColors.primaryGreen
: Colors.transparent,
borderRadius: BorderRadius.circular(2),
),
),
),
SizedBox(width: _isMinimized ? 20 : 10),
Icon(
(isSelected
? tab['activeIcon'] ?? tab['icon']
: tab['icon'])
as IconData,
color: isSelected
? AppColors.primaryGreen
: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.7),
size: 19,
),
if (!_isMinimized) ...[
const SizedBox(width: 12),
Expanded(
child: Text(
tab['label'] as String,
style: TextStyle(
fontSize: 13.5,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w500,
color: isSelected
? (isDark
? Colors.white
: const Color(0xFF1B1B1B))
: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.8),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
],
],
),
),
),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final allTabs = [
{
'category': 'MAIN',
'label': 'Dashboard',
'icon': Icons.dashboard_outlined,
'activeIcon': Icons.dashboard,
'screen': const DashboardScreen(),
'roles': ['admin', 'teacher'],
},
{
'category': 'MAIN',
'label': 'Students',
'icon': Icons.people_outline,
'activeIcon': Icons.people,
'screen': StudentsScreen(userRole: widget.userRole),
'roles': ['admin', 'teacher'],
},
{
'category': 'MAIN',
'label': 'Documents',
'icon': Icons.folder_outlined,
'activeIcon': Icons.folder,
'screen': DocumentsScreen(userRole: widget.userRole),
'roles': ['admin', 'teacher'],
},
{
'category': 'MAIN',
'label': 'Archives',
'icon': Icons.archive_outlined,
'activeIcon': Icons.archive,
'screen': ArchivesScreen(userRole: widget.userRole),
'roles': ['admin'],
},
{
'category': 'ADMINISTRATION',
'label': 'Reports',
'icon': Icons.bar_chart,
'activeIcon': Icons.bar_chart,
'screen': ReportsScreen(userRole: widget.userRole),
'roles': ['admin'],
},
{
'category': 'ADMINISTRATION',
'label': 'Users',
'icon': Icons.manage_accounts_outlined,
'activeIcon': Icons.manage_accounts,
'screen': const UsersScreen(),
'roles': ['admin'],
},
{
'category': 'ADMINISTRATION',
'label': 'Activity History',
'icon': Icons.manage_history_outlined,
'activeIcon': Icons.manage_history_rounded,
'screen': AuditTrailScreen(userRole: widget.userRole),
'roles': ['admin'],
},
{
'category': 'ADMINISTRATION',
'label': 'Settings',
'icon': Icons.settings_outlined,
'activeIcon': Icons.settings,
'screen': SettingsScreen(userRole: widget.userRole),
'roles': ['admin', 'teacher'],
},
];
final tabs = allTabs
.where(
(tab) => (tab['roles'] as List<String>).contains(widget.userRole),
)
.toList();
final activeTab = ref.watch(activeTabProvider);
int currentIndex = tabs.indexWhere((t) => t['label'] == activeTab);
if (currentIndex == -1) currentIndex = 0; // Fallback to Dashboard
_visitedIndices.add(currentIndex);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
if (activeTab != 'Dashboard') {
ref.read(activeTabProvider.notifier).setTab('Dashboard');
_reloadTabContent('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: Scaffold(
backgroundColor: Colors.transparent,
body: AbstractBackground(
blurSigma: 0.0,
child: Column(
children: [
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS))
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Container(
height: 32,
decoration: BoxDecoration(
color: (isDark
? const Color(0xFF181818)
: const Color(0xFFF3F3F3))
.withValues(alpha: isDark ? 0.60 : 0.45),
border: Border(
bottom: BorderSide(
color: (isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.08)),
width: 1,
),
),
),
child: WindowCaption(
brightness:
isDark ? Brightness.dark : Brightness.light,
backgroundColor: Colors.transparent,
title: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: Image.asset(
'assets/images/logo.webp',
width: 16,
height: 16,
fit: BoxFit.contain,
),
),
const SizedBox(width: 8),
Text(
'TIS Record Management System',
style: TextStyle(
color: isDark
? Colors.white.withValues(alpha: 0.9)
: const Color(0xFF1F1F1F),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
),
Expanded(
child: Row(
children: [
// ==========================================
// WINDOWS SIDEBAR (Fixed Width: 260px)
// ==========================================
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
width: _isMinimized ? 80 : 260,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
decoration: BoxDecoration(
color: (isDark
? const Color(0xFF181818)
: const Color(0xFFF3F3F3))
.withValues(alpha: isDark ? 0.60 : 0.45),
border: Border(
right: BorderSide(
color: (isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.08)),
width: 1,
),
),
),
child: Column(
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: _isMinimized ? 16 : 14,
vertical: 12,
),
child: Row(
children: [
// App Logo acts as the interactive Expand trigger when minimized
Tooltip(
message: _isMinimized
? 'Expand Sidebar (Click to expand)'
: 'TIS Record Management System',
waitDuration: const Duration(milliseconds: 300),
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) =>
setState(() => _isLogoHovered = true),
onExit: (_) =>
setState(() => _isLogoHovered = false),
child: GestureDetector(
onTap: () {
if (_isMinimized) {
setState(() => _isMinimized = false);
}
},
onLongPressStart: (_) {
_holdTimer = Timer(
const Duration(seconds: 3),
() {
_showCapstoneMembers(context);
},
);
},
onLongPressEnd: (_) =>
_holdTimer?.cancel(),
onLongPressCancel: () =>
_holdTimer?.cancel(),
child: AnimatedScale(
scale: (_isMinimized && _isLogoHovered)
? 1.08
: 1.0,
duration:
const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
child: AnimatedContainer(
duration:
const Duration(milliseconds: 200),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(8),
color: _isMinimized
? (_isLogoHovered
? AppColors.primaryGreen
.withValues(
alpha: isDark
? 0.25
: 0.15)
: (isDark
? Colors.white
.withValues(
alpha: 0.08)
: Colors.black
.withValues(
alpha: 0.05)))
: Colors.transparent,
border: Border.all(
color: _isMinimized
? (_isLogoHovered
? AppColors.primaryGreen
.withValues(alpha: 0.45)
: (isDark
? Colors.white
.withValues(
alpha: 0.12)
: Colors.black
.withValues(
alpha: 0.08)))
: Colors.transparent,
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: 0.04,
),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius:
BorderRadius.circular(6),
child: Image.asset(
'assets/images/logo.webp',
width: 32,
height: 32,
fit: BoxFit.contain,
),
),
),
),
),
),
),
if (!_isMinimized) ...[
const SizedBox(width: 10),
Expanded(
child: Text(
'TIS RMS',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Theme.of(context)
.colorScheme
.onSurface,
letterSpacing: 0.9,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: AnimatedRotation(
turns: _isMinimized ? 0.5 : 0.0,
duration:
const Duration(milliseconds: 320),
curve: Curves.easeInOutCubic,
child: Icon(
Icons.menu,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.65),
size: 20,
),
),
hoverColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.black.withValues(alpha: 0.05),
style: IconButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(6),
),
),
onPressed: () {
setState(() => _isMinimized = true);
},
tooltip: 'Collapse Sidebar',
),
],
],
),
),
const SizedBox(height: 6),
// Navigation Items: Single Unified Scrollable List
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
itemCount: tabs.length,
itemBuilder: (context, index) {
final tab = tabs[index];
final isSelected =
tab['label'] ==
tabs[currentIndex]['label'];
final isCategoryHeader =
index == 0 ||
tabs[index - 1]['category'] !=
tab['category'];
return _buildNavItem(
tab,
isSelected,
isCategoryHeader,
isDark: isDark,
isFirstItem: index == 0,
);
},
),
),
// Persistent Server Connection Status Pill
Padding(
padding: EdgeInsets.symmetric(
horizontal: _isMinimized ? 12 : 20,
vertical: 8,
),
child: Center(
child: ServerConnectionPill(compact: _isMinimized),
),
),
// Bottom Section: User Profile & Logout
Divider(
height: 1,
color: isDark
? const Color(0xFF2D2D2D)
: const Color(0xFFE5E5E5),
),
Container(
margin: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 8.0,
),
padding: EdgeInsets.symmetric(
horizontal: _isMinimized ? 4.0 : 8.0,
vertical: 6.0,
),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.03)
: Colors.black.withValues(alpha: 0.02),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.black.withValues(alpha: 0.05),
width: 1,
),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: SizedBox(
width: _isMinimized ? 56 : 228,
child: Row(
children: [
GestureDetector(
onTap: () {
if (_isMinimized) {
setState(() => _isMinimized = false);
}
},
child: 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: 17,
backgroundColor:
AppColors.primaryGreen,
child: Text(
initials,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
);
},
),
),
if (!_isMinimized) ...[
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
ref
.watch(authProvider)
.value
?.fullName ??
'Unknown User',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: Theme.of(context)
.colorScheme
.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
widget.userRole.toUpperCase(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.55),
),
),
],
),
),
IconButton(
icon: Icon(
Icons.menu_book_outlined,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.65),
size: 18,
),
tooltip: 'User Guide & Documentation',
hoverColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.black.withValues(alpha: 0.05),
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(6),
constraints: const BoxConstraints(
minWidth: 30,
minHeight: 30,
),
onPressed: () {
DocumentationScreen.open(context);
},
),
IconButton(
icon: Icon(
Icons.logout,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.65),
size: 18,
),
tooltip: 'Logout',
hoverColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.black.withValues(alpha: 0.05),
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(6),
constraints: const BoxConstraints(
minWidth: 30,
minHeight: 30,
),
onPressed: () {
showLogoutConfirmationDialog(context);
},
),
],
],
),
),
),
),
],
),
),
),
),
),
// ==========================================
// MAIN CONTENT AREA
// ==========================================
Expanded(
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(),
),
],
),
),
],
),
),
],
),
),
),
);
}
}
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),
),
),
),
),
],
),
),
);
}
}