From 2a2b1ed0a8003a04f2b94e95c5b597a15947adbd Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Sun, 19 Jul 2026 23:04:39 +0800 Subject: [PATCH] Feat: implement notifications, search history, and UI enhancements --- backend/data/backup_info.json | 4 + backend/data/backup_logs.txt | 6 + .../src/controllers/notificationController.js | 16 + backend/src/routes/notifications.js | 1 + .../repositories/notification_repository.dart | 12 + .../ui/providers/notification_provider.dart | 17 + .../ui/providers/search_history_provider.dart | 70 ++++ .../ui/screens/archives/archives_screen.dart | 8 + .../screens/dashboard/dashboard_screen.dart | 327 +++++------------- .../widgets/notification_dropdown.dart | 194 +++++++++++ .../screens/documents/documents_screen.dart | 9 + .../widgets/document_preview_modal.dart | 4 +- .../documents/widgets/upload_ocr_modal.dart | 321 ++++++++++++----- .../ui/screens/settings/settings_screen.dart | 15 +- .../ui/screens/students/students_screen.dart | 10 +- 15 files changed, 693 insertions(+), 321 deletions(-) create mode 100644 backend/data/backup_info.json create mode 100644 backend/data/backup_logs.txt create mode 100644 frontend/lib/ui/providers/search_history_provider.dart create mode 100644 frontend/lib/ui/screens/dashboard/widgets/notification_dropdown.dart diff --git a/backend/data/backup_info.json b/backend/data/backup_info.json new file mode 100644 index 0000000..c9ac1f6 --- /dev/null +++ b/backend/data/backup_info.json @@ -0,0 +1,4 @@ +{ + "lastBackup": "2026-07-17T15:15:10.870Z", + "lastRestore": "2026-07-17T15:15:21.665Z" +} \ No newline at end of file diff --git a/backend/data/backup_logs.txt b/backend/data/backup_logs.txt new file mode 100644 index 0000000..84b6762 --- /dev/null +++ b/backend/data/backup_logs.txt @@ -0,0 +1,6 @@ +[2026-07-17T12:48:13.930Z] BACKUP completed successfully. +[2026-07-17T12:48:21.114Z] RESTORE completed successfully. +[2026-07-17T15:05:37.543Z] BACKUP completed successfully. +[2026-07-17T15:05:53.041Z] RESTORE completed successfully. +[2026-07-17T15:15:10.870Z] BACKUP completed successfully. +[2026-07-17T15:15:21.665Z] RESTORE completed successfully. diff --git a/backend/src/controllers/notificationController.js b/backend/src/controllers/notificationController.js index 786c663..a36ffa9 100644 --- a/backend/src/controllers/notificationController.js +++ b/backend/src/controllers/notificationController.js @@ -126,3 +126,19 @@ exports.clearNotifications = (req, res) => { } }; +// DELETE /api/notifications/:id - Delete a specific notification +exports.deleteNotification = (req, res) => { + const { id } = req.params; + try { + const userId = req.user.id; + const result = db.prepare('DELETE FROM notifications WHERE id = ? AND (user_id IS NULL OR user_id = ?)') + .run(id, userId); + + if (result.changes === 0) { + return res.status(404).json({ message: 'Notification not found or access denied.' }); + } + res.json({ message: 'Notification deleted' }); + } catch (error) { + res.status(500).json({ message: 'Failed to delete notification', error: error.message }); + } +}; diff --git a/backend/src/routes/notifications.js b/backend/src/routes/notifications.js index db0ace4..26ea0d0 100644 --- a/backend/src/routes/notifications.js +++ b/backend/src/routes/notifications.js @@ -7,5 +7,6 @@ router.get('/', authenticateToken, notificationController.getNotifications); router.put('/mark-all-read', authenticateToken, notificationController.markAllRead); router.put('/:id/read', authenticateToken, notificationController.markRead); router.delete('/clear', authenticateToken, notificationController.clearNotifications); +router.delete('/:id', authenticateToken, notificationController.deleteNotification); module.exports = router; diff --git a/frontend/lib/domain/repositories/notification_repository.dart b/frontend/lib/domain/repositories/notification_repository.dart index 5e92628..6e6772f 100644 --- a/frontend/lib/domain/repositories/notification_repository.dart +++ b/frontend/lib/domain/repositories/notification_repository.dart @@ -61,4 +61,16 @@ class NotificationRepository { throw Exception(e.response?.data['message'] ?? 'Failed to clear notifications.'); } } + + Future deleteNotification(int id) async { + try { + final options = await _getAuthOptions(); + await _dio.delete( + '/notifications/$id', + options: options, + ); + } on DioException catch (e) { + throw Exception(e.response?.data['message'] ?? 'Failed to delete notification.'); + } + } } diff --git a/frontend/lib/ui/providers/notification_provider.dart b/frontend/lib/ui/providers/notification_provider.dart index 5515e60..a3c3bda 100644 --- a/frontend/lib/ui/providers/notification_provider.dart +++ b/frontend/lib/ui/providers/notification_provider.dart @@ -99,4 +99,21 @@ class NotificationNotifier extends AsyncNotifier> { // Keep original state on failure } } + + Future deleteNotification(int id) async { + try { + final repo = ref.read(notificationRepositoryProvider); + + // Optimistically remove from state + if (state.hasValue) { + final updatedList = state.value!.where((n) => n.id != id).toList(); + state = AsyncData(updatedList); + } + + await repo.deleteNotification(id); + } catch (e) { + // Refresh to restore original state if delete fails + refreshNotifications(); + } + } } diff --git a/frontend/lib/ui/providers/search_history_provider.dart b/frontend/lib/ui/providers/search_history_provider.dart new file mode 100644 index 0000000..0bbc5b9 --- /dev/null +++ b/frontend/lib/ui/providers/search_history_provider.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _searchHistoryKey = 'app_search_history'; + +final searchHistoryProvider = NotifierProvider>(() { + return SearchHistoryNotifier(); +}); + +class SearchHistoryNotifier extends Notifier> { + @override + List build() { + _loadHistory(); + return []; + } + + Future _loadHistory() async { + try { + final prefs = await SharedPreferences.getInstance(); + final data = prefs.getString(_searchHistoryKey); + if (data != null) { + final List decoded = jsonDecode(data); + state = decoded.map((e) => e.toString()).toList(); + } + } catch (e) { + // Ignore errors on load + } + } + + Future addSearch(String query) async { + final trimQuery = query.trim(); + if (trimQuery.isEmpty) return; + + // Remove if exists to push to top + final newList = List.from(state)..remove(trimQuery); + + // Add to top + newList.insert(0, trimQuery); + + // Keep max 10 + if (newList.length > 10) { + newList.removeLast(); + } + + state = newList; + await _saveHistory(newList); + } + + Future removeSearch(String query) async { + final newList = List.from(state)..remove(query); + state = newList; + await _saveHistory(newList); + } + + Future clearHistory() async { + state = []; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_searchHistoryKey); + } + + Future _saveHistory(List list) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_searchHistoryKey, jsonEncode(list)); + } catch (e) { + // Ignore errors on save + } + } +} diff --git a/frontend/lib/ui/screens/archives/archives_screen.dart b/frontend/lib/ui/screens/archives/archives_screen.dart index 65e8e00..875718c 100644 --- a/frontend/lib/ui/screens/archives/archives_screen.dart +++ b/frontend/lib/ui/screens/archives/archives_screen.dart @@ -177,6 +177,11 @@ class _ArchivesScreenState extends ConsumerState }); } + void _onSearchSubmitted(String query) { + _debounce?.cancel(); + ref.read(archiveDocumentQueryProvider.notifier).setSearch(query); + } + void _applyFilters() { final n = ref.read(archiveDocumentQueryProvider.notifier); n.setStatus(_selectedStatus); @@ -573,7 +578,10 @@ class _ArchivesScreenState extends ConsumerState child: AppSearchBar( hint: 'Search by name, LRN, file…', controller: _searchController, + collapsible: true, + hideIconWhenExpanded: true, onChanged: _onSearchChanged, + onSubmitted: _onSearchSubmitted, maxWidth: constraints.maxWidth, ), ), diff --git a/frontend/lib/ui/screens/dashboard/dashboard_screen.dart b/frontend/lib/ui/screens/dashboard/dashboard_screen.dart index 168f116..074ca54 100644 --- a/frontend/lib/ui/screens/dashboard/dashboard_screen.dart +++ b/frontend/lib/ui/screens/dashboard/dashboard_screen.dart @@ -18,8 +18,10 @@ import 'recent_activities_screen.dart'; import 'user_history_screen.dart'; import '../../shared/menus/profile_dropdown_menu.dart'; import '../../shared/inputs/app_search_bar.dart'; -import '../settings/teacher_management_screen.dart'; +import '../../shared/dialogs/error_dialog.dart'; import '../settings/requirements_settings_screen.dart'; +import '../settings/teacher_management_screen.dart'; +import 'widgets/notification_dropdown.dart'; import '../../shared/modals/view_activity_modal.dart'; import '../../shared/modals/reset_requests_modal.dart'; @@ -185,84 +187,25 @@ class _DashboardScreenState extends ConsumerState { ) // ── Notification items ──────────────────────────────────────── else - ...list.take(5).map((note) => PopupMenuItem( - mouseCursor: SystemMouseCursors.click, - onTap: () { - ref.read(notificationsProvider.notifier).markAsRead(note.id); - Future.delayed(const Duration(milliseconds: 100), () { - if (context.mounted) { - final role = ref.read(authProvider).value?.role; - if (note.title.toLowerCase().contains('password') && role == 'admin') { - ResetRequestsModal.show(context); - } else { - ViewActivityModal.show( - context: context, - title: note.title, - description: note.message, - date: pht.formatModalDate(note.createdAt), - icon: _getNotificationIcon(note.title), - actionColor: _getNotificationColor(note.title), - ); - } - } - }); - }, - child: SizedBox( - width: 300, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: _getNotificationColor(note.title).withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon(_getNotificationIcon(note.title), color: _getNotificationColor(note.title), size: 20), - ), - const SizedBox(width: 12), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - note.title, - style: TextStyle( - fontWeight: note.isRead ? FontWeight.normal : FontWeight.bold, - fontSize: 13, - color: Colors.black87, - ), - ), - ), - if (!note.isRead) - Container( - width: 8, height: 8, - decoration: const BoxDecoration(color: Colors.redAccent, shape: BoxShape.circle), - ), - ], - ), - const SizedBox(height: 4), - Text( - note.message, - style: TextStyle( - fontSize: 12, - color: note.isRead ? Colors.black54 : Colors.black87, - fontWeight: note.isRead ? FontWeight.normal : FontWeight.w500, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text(_formatDate(note.createdAt), style: const TextStyle(fontSize: 10, color: Colors.black38)), - ], - )), - ], - ), + PopupMenuItem( + enabled: false, + padding: EdgeInsets.zero, + child: NotificationDropdownWidget( + notifications: list, + onViewActivity: (context, title, desc, date, icon, color) { + ViewActivityModal.show( + context: context, + title: title, + description: desc, + date: date, + icon: icon, + actionColor: color, + ); + }, + getIcon: _getNotificationIcon, + getColor: _getNotificationColor, ), - )), + ), ], ); } @@ -556,9 +499,11 @@ class _DashboardScreenState extends ConsumerState { Expanded( child: LayoutBuilder( builder: (context, constraints) => Align( - alignment: Alignment.centerLeft, + alignment: Alignment.centerRight, child: AppSearchBar( controller: _searchController, + collapsible: true, + hideIconWhenExpanded: true, hint: 'Search students by LRN or Name...', maxWidth: constraints.maxWidth > 420 ? 420 : constraints.maxWidth, onSubmitted: (value) { @@ -658,10 +603,12 @@ class _DashboardScreenState extends ConsumerState { return LayoutBuilder(builder: (context, constraints) { int crossAxisCount; - if (constraints.maxWidth >= 800) { + if (constraints.maxWidth >= 1000) { crossAxisCount = isAdmin ? 4 : 3; - } else { + } else if (constraints.maxWidth >= 650) { crossAxisCount = 2; + } else { + crossAxisCount = 1; } return GridView( shrinkWrap: true, @@ -670,7 +617,7 @@ class _DashboardScreenState extends ConsumerState { crossAxisCount: crossAxisCount, crossAxisSpacing: 16, mainAxisSpacing: 16, - mainAxisExtent: 130, + mainAxisExtent: crossAxisCount == 1 ? 110 : 130, ), children: [ StatCard( @@ -827,95 +774,48 @@ class _DashboardScreenState extends ConsumerState { itemBuilder: (context, index) { final a = activities[index]; - if (isMobile) { return Material( color: Colors.transparent, child: InkWell( mouseCursor: SystemMouseCursors.click, - onTap: () => ViewActivityModal.show( - context: context, - title: a.entityType.toUpperCase(), - description: a.description, - date: pht.formatModalDate(a.createdAt), - performedBy: a.performedBy ?? a.username ?? 'System', - action: a.action, - actionColor: _actionColor(a.action), - icon: _actionIcon(a.action, a.entityType), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - CircleAvatar( - radius: 16, - backgroundColor: _actionColor(a.action).withValues(alpha: 0.1), - child: Icon(_actionIcon(a.action, a.entityType), color: _actionColor(a.action), size: 16), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - '${a.performedBy ?? a.username ?? 'System'} · ${_formatDate(a.createdAt)}', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - _buildActionChip(a.action), - const SizedBox(width: 8), - Icon(Icons.chevron_right_rounded, size: 20, color: Colors.grey.shade400), - ], - ), - const SizedBox(height: 8), - Text( - a.description, - style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13, color: Colors.black87), - ), - ], + onTap: () => ViewActivityModal.show( + context: context, + title: a.entityType.toUpperCase(), + description: a.description, + date: pht.formatModalDate(a.createdAt), + performedBy: a.performedBy ?? a.username ?? 'System', + action: a.action, + actionColor: _actionColor(a.action), + icon: _actionIcon(a.action, a.entityType), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + leading: CircleAvatar( + backgroundColor: _actionColor(a.action).withValues(alpha: 0.1), + child: Icon(_actionIcon(a.action, a.entityType), color: _actionColor(a.action), size: 20), + ), + title: Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: Align( + alignment: Alignment.centerLeft, + child: _buildActionChip(a.action), + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(a.description, style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13, color: Colors.black87)), + const SizedBox(height: 4), + Text( + '${a.performedBy ?? a.username ?? 'System'} · ${_formatDate(a.createdAt)}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + trailing: Icon(Icons.chevron_right_rounded, size: 20, color: Colors.grey.shade400), ), - ), ), ); - } - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => ViewActivityModal.show( - context: context, - title: a.entityType.toUpperCase(), - description: a.description, - date: pht.formatModalDate(a.createdAt), - performedBy: a.performedBy ?? a.username ?? 'System', - action: a.action, - actionColor: _actionColor(a.action), - icon: _actionIcon(a.action, a.entityType), - ), - child: ListTile( - mouseCursor: SystemMouseCursors.click, - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - leading: CircleAvatar( - backgroundColor: _actionColor(a.action).withValues(alpha: 0.1), - child: Icon(_actionIcon(a.action, a.entityType), color: _actionColor(a.action), size: 20), - ), - title: Text(a.description, style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14)), - subtitle: Text( - '${a.performedBy ?? a.username ?? 'System'} · ${_formatDate(a.createdAt)}', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildActionChip(a.action), - const SizedBox(width: 8), - Icon(Icons.chevron_right_rounded, size: 20, color: Colors.grey.shade400), - ], - ), - ), - ), - ); }, ), ); @@ -952,64 +852,10 @@ class _DashboardScreenState extends ConsumerState { final h = history[index]; final desc = '${h.action.toUpperCase()} User: ${h.fullName}'; - if (isMobile) { - return Material( - color: Colors.transparent, - child: InkWell( - mouseCursor: SystemMouseCursors.click, - onTap: () { - ViewActivityModal.show( - context: context, - title: 'USER ACTIVITY', - description: desc, - date: pht.formatModalDate(h.createdAt), - performedBy: h.performedByName ?? h.performedByUsername ?? 'System', - action: h.action, - actionColor: _actionColor(h.action), - icon: _actionIcon(h.action, 'user'), - ); - }, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - CircleAvatar( - radius: 16, - backgroundColor: _actionColor(h.action).withValues(alpha: 0.1), - child: Icon(_actionIcon(h.action, 'user'), color: _actionColor(h.action), size: 16), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - '${h.performedByName ?? h.performedByUsername ?? 'System'} · ${_formatDate(h.createdAt)}', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - _buildActionChip(h.action), - const SizedBox(width: 8), - Icon(Icons.chevron_right_rounded, size: 20, color: Colors.grey.shade400), - ], - ), - const SizedBox(height: 8), - Text( - desc, - style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13, color: Colors.black87), - ), - ], - ), - ), - ), - ); - } - return Material( color: Colors.transparent, child: InkWell( + mouseCursor: SystemMouseCursors.click, onTap: () { ViewActivityModal.show( context: context, @@ -1023,25 +869,33 @@ class _DashboardScreenState extends ConsumerState { ); }, child: ListTile( - mouseCursor: SystemMouseCursors.click, - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), leading: CircleAvatar( backgroundColor: _actionColor(h.action).withValues(alpha: 0.1), - child: Icon(_actionIcon(h.action, 'user'), color: _actionColor(h.action), size: 20), + child: Text( + _getInitials(h.fullName), + style: TextStyle(color: _actionColor(h.action), fontSize: 12, fontWeight: FontWeight.bold), + ), ), - title: Text(desc, style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14)), - subtitle: Text( - '${h.performedByName ?? h.performedByUsername ?? 'System'} · ${_formatDate(h.createdAt)}', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + title: Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: Align( + alignment: Alignment.centerLeft, + child: _buildActionChip(h.action), + ), ), - trailing: Row( - mainAxisSize: MainAxisSize.min, + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildActionChip(h.action), - const SizedBox(width: 8), - Icon(Icons.chevron_right_rounded, size: 20, color: Colors.grey.shade400), + Text(desc, style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13, color: Colors.black87)), + const SizedBox(height: 4), + Text( + '${h.performedByName ?? h.performedByUsername ?? 'System'} · ${_formatDate(h.createdAt)}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), ], ), + trailing: Icon(Icons.chevron_right_rounded, size: 20, color: Colors.grey.shade400), ), ), ); @@ -1097,4 +951,15 @@ class _DashboardScreenState extends ConsumerState { } String _formatDate(String raw) => pht.formatRelative(raw); + + String _getInitials(String name) { + if (name.trim().isEmpty) return '?'; + final parts = name.trim().split(RegExp(r'\s+')); + if (parts.length > 1) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } + return parts[0].length > 1 + ? parts[0].substring(0, 2).toUpperCase() + : parts[0].toUpperCase(); + } } \ No newline at end of file diff --git a/frontend/lib/ui/screens/dashboard/widgets/notification_dropdown.dart b/frontend/lib/ui/screens/dashboard/widgets/notification_dropdown.dart new file mode 100644 index 0000000..b606dd1 --- /dev/null +++ b/frontend/lib/ui/screens/dashboard/widgets/notification_dropdown.dart @@ -0,0 +1,194 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../providers/notification_provider.dart'; +import '../../../providers/auth_provider.dart'; +import '../../../../domain/entities/notification_model.dart'; +import '../../../../core/utils/date_utils.dart' as pht; +import '../../../shared/modals/view_activity_modal.dart'; +import '../../../shared/modals/reset_requests_modal.dart'; +import '../../settings/requirements_settings_screen.dart'; + +class NotificationDropdownWidget extends ConsumerStatefulWidget { + final List notifications; + final Function(BuildContext, String, String, String, IconData, Color) onViewActivity; + final Function(String) getIcon; + final Function(String) getColor; + + const NotificationDropdownWidget({ + super.key, + required this.notifications, + required this.onViewActivity, + required this.getIcon, + required this.getColor, + }); + + @override + ConsumerState createState() => _NotificationDropdownWidgetState(); +} + +class _NotificationDropdownWidgetState extends ConsumerState { + int _displayCount = 5; + int? _hoveredId; + + @override + Widget build(BuildContext context) { + final notificationsState = ref.watch(notificationsProvider); + final list = notificationsState.value ?? widget.notifications; + final displayList = list.take(_displayCount).toList(); + final hasMore = list.length > _displayCount; + + if (list.isEmpty) { + return SizedBox( + width: 300, + height: 120, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.notifications_off_rounded, size: 48, color: Colors.grey), + const SizedBox(height: 12), + const Text('No new notifications', style: TextStyle(color: Colors.grey, fontSize: 13)), + ], + ), + ), + ); + } + + return SizedBox( + width: 320, + height: math.min(400, (displayList.length * 80.0) + (hasMore ? 50.0 : 0.0) + 16.0), // fixed max height + child: ListView.builder( + shrinkWrap: true, + itemCount: displayList.length + (hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == displayList.length) { + return InkWell( + onTap: () { + setState(() { + _displayCount += 5; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + alignment: Alignment.center, + child: const Text('Load More', style: TextStyle(color: Color(0xFF1C8248), fontWeight: FontWeight.w600, fontSize: 13)), + ), + ); + } + + final note = displayList[index]; + final icon = widget.getIcon(note.title) as IconData; + final color = widget.getColor(note.title) as Color; + + return Dismissible( + key: ValueKey(note.id), + direction: DismissDirection.horizontal, + onDismissed: (_) { + ref.read(notificationsProvider.notifier).deleteNotification(note.id); + }, + background: Container( + color: Colors.redAccent, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + secondaryBackground: Container( + color: Colors.redAccent, + alignment: Alignment.centerRight, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + child: MouseRegion( + onEnter: (_) => setState(() => _hoveredId = note.id), + onExit: (_) => setState(() => _hoveredId = null), + child: InkWell( + onTap: () { + ref.read(notificationsProvider.notifier).markAsRead(note.id); + Future.delayed(const Duration(milliseconds: 100), () { + if (context.mounted) { + final role = ref.read(authProvider).value?.role; + if (note.title.toLowerCase().contains('password') && role == 'admin') { + ResetRequestsModal.show(context); + } else { + widget.onViewActivity(context, note.title, note.message, pht.formatModalDate(note.createdAt), icon, color); + } + } + }); + }, + child: Container( + width: 320, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + note.title, + style: TextStyle( + fontWeight: note.isRead ? FontWeight.normal : FontWeight.bold, + fontSize: 13, + color: Colors.black87, + ), + ), + ), + if (!note.isRead) + Container( + width: 8, height: 8, + decoration: const BoxDecoration(color: Colors.redAccent, shape: BoxShape.circle), + ), + ], + ), + const SizedBox(height: 4), + Text( + note.message, + style: TextStyle( + fontSize: 12, + color: note.isRead ? Colors.black54 : Colors.black87, + fontWeight: note.isRead ? FontWeight.normal : FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text(pht.formatRelative(note.createdAt), style: const TextStyle(fontSize: 10, color: Colors.black38)), + ], + ), + ), + if (_hoveredId == note.id) + IconButton( + icon: const Icon(Icons.close, size: 16, color: Colors.grey), + onPressed: () { + ref.read(notificationsProvider.notifier).deleteNotification(note.id); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashRadius: 12, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/frontend/lib/ui/screens/documents/documents_screen.dart b/frontend/lib/ui/screens/documents/documents_screen.dart index f127f90..ae71a9b 100644 --- a/frontend/lib/ui/screens/documents/documents_screen.dart +++ b/frontend/lib/ui/screens/documents/documents_screen.dart @@ -212,6 +212,12 @@ class _DocumentsScreenState extends ConsumerState }); } + void _onSearchSubmitted(String query) { + _debounce?.cancel(); + setState(() => _foldersPage = 1); + ref.read(documentQueryProvider.notifier).setSearch(query); + } + void _applyFilters() { setState(() => _foldersPage = 1); final n = ref.read(documentQueryProvider.notifier); @@ -1118,7 +1124,10 @@ class _DocumentsScreenState extends ConsumerState hint: 'Search by name, LRN, file…', controller: _searchController, focusNode: _searchFocusNode, + collapsible: true, + hideIconWhenExpanded: true, onChanged: _onSearchChanged, + onSubmitted: _onSearchSubmitted, maxWidth: double.infinity, ), ), diff --git a/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart b/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart index 5626a49..422e63d 100644 --- a/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart +++ b/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart @@ -150,7 +150,9 @@ class _DocumentPreviewDialogState extends State<_DocumentPreviewDialog> { Future _downloadFile() async { try { - showSuccessDialog(context, message: 'Download started...'); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Download started...')), + ); await DownloadService.downloadFile(url: _downloadUrl, fileName: _fileName); if (!mounted) return; showSuccessDialog(context, message: 'Document downloaded successfully.'); diff --git a/frontend/lib/ui/screens/documents/widgets/upload_ocr_modal.dart b/frontend/lib/ui/screens/documents/widgets/upload_ocr_modal.dart index 0123209..e276ea7 100644 --- a/frontend/lib/ui/screens/documents/widgets/upload_ocr_modal.dart +++ b/frontend/lib/ui/screens/documents/widgets/upload_ocr_modal.dart @@ -13,6 +13,7 @@ import '../../../providers/document_provider.dart'; import '../../../providers/student_provider.dart'; import '../../../shared/dialogs/success_dialog.dart'; import '../../../shared/dialogs/error_dialog.dart'; +import '../../../../domain/entities/student_model.dart'; import 'package:wolt_modal_sheet/wolt_modal_sheet.dart'; class UploadOcrModal extends ConsumerStatefulWidget { @@ -56,33 +57,91 @@ class _UploadOcrModalState extends ConsumerState { int? _matchedStudentId; bool _isSubmitting = false; + bool _isSearchingStudent = false; + StudentModel? _matchedStudent; @override void initState() { super.initState(); - // If we opened this from a specific student's folder, fetch their LRN automatically if (widget.prefilledStudentId != null) { _matchedStudentId = widget.prefilledStudentId; _fetchPrefilledStudentLrn(); } + _lrnController.addListener(_onLrnChanged); } @override void dispose() { + _lrnController.removeListener(_onLrnChanged); _lrnController.dispose(); super.dispose(); } + void _onLrnChanged() { + final text = _lrnController.text.trim(); + if (text.length == 12) { + if (_matchedStudent == null || _matchedStudent!.lrn != text) { + _searchStudentByLrn(text); + } + } else { + if (_matchedStudent != null) { + setState(() { + _matchedStudent = null; + _matchedStudentId = null; + _selectedRequirementId = null; + _selectedDocumentType = null; + }); + } + } + } + Future _fetchPrefilledStudentLrn() async { + setState(() => _isSearchingStudent = true); try { final student = await ref.read(studentDetailProvider(widget.prefilledStudentId!).future); if (mounted) { setState(() { - _lrnController.text = student.lrn; // Auto-fill the LRN + _lrnController.text = student.lrn; + _matchedStudent = student; + _isSearchingStudent = false; }); } } catch (e) { - // Fails silently, the user can still type it manually if needed + if (mounted) setState(() => _isSearchingStudent = false); + } + } + + Future _searchStudentByLrn(String lrn) async { + setState(() => _isSearchingStudent = true); + try { + final storage = const FlutterSecureStorage(); + final token = await storage.read(key: 'jwt_token'); + final dio = Dio(BaseOptions(baseUrl: ApiConstants.baseUrl, headers: {'Authorization': 'Bearer $token'})); + + final studentsRes = await dio.get('/students', queryParameters: {'search': lrn}); + final studentsList = studentsRes.data['students'] as List; + + if (studentsList.isNotEmpty) { + final studentId = studentsList[0]['id'] as int; + final student = await ref.read(studentDetailProvider(studentId).future); + if (mounted && _lrnController.text.trim() == lrn) { + setState(() { + _matchedStudent = student; + _matchedStudentId = studentId; + _isSearchingStudent = false; + }); + } + } else { + if (mounted) { + setState(() { + _matchedStudent = null; + _matchedStudentId = null; + _isSearchingStudent = false; + }); + } + } + } catch (e) { + if (mounted) setState(() => _isSearchingStudent = false); } } @@ -389,93 +448,183 @@ class _UploadOcrModalState extends ConsumerState { const SizedBox(height: AppSizes.p16), // DOCUMENT TYPE DROPDOWN (Grouped by JHS / SHS) - requirementsAsync.when( - data: (requirements) { - final jhsReqs = requirements.where((r) => r.category == 'JHS').toList(); - final shsReqs = requirements.where((r) => r.category == 'SHS').toList(); - - final entries = >[]; - - if (jhsReqs.isNotEmpty) { - entries.add(const DropdownMenuEntry( - value: -1, - label: 'Junior High School', - enabled: false, - style: ButtonStyle( - foregroundColor: WidgetStatePropertyAll(Colors.teal), - textStyle: WidgetStatePropertyAll(TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), - ), - )); - for (final req in jhsReqs) { - entries.add(DropdownMenuEntry( - value: req.id, - label: req.name, - style: const ButtonStyle( - padding: WidgetStatePropertyAll(EdgeInsets.only(left: 32)), - textStyle: WidgetStatePropertyAll(TextStyle(fontSize: 14)), - ), - )); + if (_isSearchingStudent) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator(color: AppColors.primaryGreen)), + ) + else if (_matchedStudent == null) + Container( + padding: const EdgeInsets.all(AppSizes.p16), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(AppSizes.radiusMedium), + border: Border.all(color: Colors.grey.shade300), + ), + child: const Row( + children: [ + Icon(Icons.search, color: AppColors.textSecondary), + SizedBox(width: AppSizes.p12), + Expanded(child: Text('Enter a valid 12-digit LRN to load applicable document types.', style: TextStyle(color: AppColors.textSecondary))), + ], + ), + ) + else + requirementsAsync.when( + data: (requirements) { + bool hasJHS = false; + bool hasSHS = false; + + if (_matchedStudent!.enrollments != null && _matchedStudent!.enrollments!.isNotEmpty) { + for (final env in _matchedStudent!.enrollments!) { + if (env.gradeLevel <= 10) hasJHS = true; + if (env.gradeLevel >= 11) hasSHS = true; + } + } else { + // Fallback if no enrollments + hasJHS = true; + hasSHS = true; } - } - if (shsReqs.isNotEmpty) { - entries.add(const DropdownMenuEntry( - value: -2, - label: 'Senior High School', - enabled: false, - style: ButtonStyle( - foregroundColor: WidgetStatePropertyAll(Colors.purple), - textStyle: WidgetStatePropertyAll(TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), - ), - )); - for (final req in shsReqs) { - entries.add(DropdownMenuEntry( - value: req.id, - label: req.name, - style: const ButtonStyle( - padding: WidgetStatePropertyAll(EdgeInsets.only(left: 32)), - textStyle: WidgetStatePropertyAll(TextStyle(fontSize: 14)), - ), - )); - } - } + List jhsReqs = []; + List shsReqs = []; - return DropdownMenu( - initialSelection: _selectedRequirementId, - hintText: 'Select Document Type', - expandedInsets: EdgeInsets.zero, - menuHeight: 300, - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: AppColors.surfaceWhite, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppSizes.radiusMedium), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppSizes.radiusMedium), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppSizes.radiusMedium), - borderSide: const BorderSide(color: AppColors.primaryGreen, width: 2), - ), - ), - dropdownMenuEntries: entries, - onSelected: (val) { - if (val == null || val < 0) return; - setState(() { - _selectedRequirementId = val; - final reqMatch = requirements.firstWhere((r) => r.id == val); - _selectedDocumentType = reqMatch.name; + if (hasJHS) { + jhsReqs = requirements.where((r) => r.category == 'JHS').toList(); + jhsReqs.sort((a, b) { + if (a.isMandatory && !b.isMandatory) return -1; + if (!a.isMandatory && b.isMandatory) return 1; + return a.name.compareTo(b.name); }); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator(color: AppColors.primaryGreen)), - error: (e, st) => Text('Failed to load types: $e', style: const TextStyle(color: AppColors.error)), - ), + } + + if (hasSHS) { + shsReqs = requirements.where((r) => r.category == 'SHS').toList(); + shsReqs.sort((a, b) { + if (a.isMandatory && !b.isMandatory) return -1; + if (!a.isMandatory && b.isMandatory) return 1; + return a.name.compareTo(b.name); + }); + } + + final entries = >[]; + + if (jhsReqs.isNotEmpty) { + entries.add(const DropdownMenuEntry( + value: -1, + label: 'Junior High School', + enabled: false, + style: ButtonStyle( + foregroundColor: WidgetStatePropertyAll(Colors.teal), + textStyle: WidgetStatePropertyAll(TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + ), + )); + for (final req in jhsReqs) { + entries.add(DropdownMenuEntry( + value: req.id, + label: '${req.name}${req.isMandatory ? " *" : ""}', + trailingIcon: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: req.isMandatory ? Colors.red.shade50 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: req.isMandatory ? Colors.red.shade200 : Colors.grey.shade300), + ), + child: Text( + req.isMandatory ? 'Mandatory' : 'Optional', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: req.isMandatory ? Colors.red.shade700 : Colors.grey.shade600, + ), + ), + ), + style: const ButtonStyle( + padding: WidgetStatePropertyAll(EdgeInsets.only(left: 32, right: 16)), + textStyle: WidgetStatePropertyAll(TextStyle(fontSize: 14)), + ), + )); + } + } + + if (shsReqs.isNotEmpty) { + entries.add(const DropdownMenuEntry( + value: -2, + label: 'Senior High School', + enabled: false, + style: ButtonStyle( + foregroundColor: WidgetStatePropertyAll(Colors.purple), + textStyle: WidgetStatePropertyAll(TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + ), + )); + for (final req in shsReqs) { + entries.add(DropdownMenuEntry( + value: req.id, + label: '${req.name}${req.isMandatory ? " *" : ""}', + trailingIcon: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: req.isMandatory ? Colors.red.shade50 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: req.isMandatory ? Colors.red.shade200 : Colors.grey.shade300), + ), + child: Text( + req.isMandatory ? 'Mandatory' : 'Optional', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: req.isMandatory ? Colors.red.shade700 : Colors.grey.shade600, + ), + ), + ), + style: const ButtonStyle( + padding: WidgetStatePropertyAll(EdgeInsets.only(left: 32, right: 16)), + textStyle: WidgetStatePropertyAll(TextStyle(fontSize: 14)), + ), + )); + } + } + + if (entries.isEmpty) { + return const Text('No document types found for this student.', style: TextStyle(color: AppColors.error)); + } + + return DropdownMenu( + initialSelection: _selectedRequirementId, + hintText: 'Select Document Type', + expandedInsets: EdgeInsets.zero, + menuHeight: 300, + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceWhite, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppSizes.radiusMedium), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppSizes.radiusMedium), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppSizes.radiusMedium), + borderSide: const BorderSide(color: AppColors.primaryGreen, width: 2), + ), + ), + dropdownMenuEntries: entries, + onSelected: (val) { + if (val == null || val < 0) return; + setState(() { + _selectedRequirementId = val; + final reqMatch = requirements.firstWhere((r) => r.id == val); + _selectedDocumentType = reqMatch.name; + }); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator(color: AppColors.primaryGreen)), + error: (e, st) => Text('Failed to load types: $e', style: const TextStyle(color: AppColors.error)), + ), const SizedBox(height: AppSizes.p32), diff --git a/frontend/lib/ui/screens/settings/settings_screen.dart b/frontend/lib/ui/screens/settings/settings_screen.dart index fdf30d3..0d93b5f 100644 --- a/frontend/lib/ui/screens/settings/settings_screen.dart +++ b/frontend/lib/ui/screens/settings/settings_screen.dart @@ -17,6 +17,7 @@ import 'dart:io'; import '../../providers/backup_provider.dart'; import '../../shared/dialogs/info_dialog.dart'; import 'teacher_management_screen.dart'; +import '../../../core/utils/date_utils.dart' as pht; class SettingsScreen extends ConsumerStatefulWidget { final String? userRole; @@ -695,8 +696,18 @@ class _SettingsScreenState extends ConsumerState { padding: const EdgeInsets.only(bottom: 16), child: Row( children: [ - Expanded(child: Text('Last Backup: ${info['lastBackup']?.split('T').join(' ').split('.').first ?? 'Never'}', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary))), - Expanded(child: Text('Last Restore: ${info['lastRestore']?.split('T').join(' ').split('.').first ?? 'Never'}', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary))), + Expanded( + child: Text( + 'Last Backup: ${(info['lastBackup'] as String?)?.isNotEmpty == true ? pht.formatModalDate(info['lastBackup'] as String) : 'Never'}', + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary) + ) + ), + Expanded( + child: Text( + 'Last Restore: ${(info['lastRestore'] as String?)?.isNotEmpty == true ? pht.formatModalDate(info['lastRestore'] as String) : 'Never'}', + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary) + ) + ), ], ), ), diff --git a/frontend/lib/ui/screens/students/students_screen.dart b/frontend/lib/ui/screens/students/students_screen.dart index 31c97e5..419dbb7 100644 --- a/frontend/lib/ui/screens/students/students_screen.dart +++ b/frontend/lib/ui/screens/students/students_screen.dart @@ -94,6 +94,11 @@ class _StudentsScreenState extends ConsumerState { }); } + void _onSearchSubmitted(String query) { + _debounce?.cancel(); + ref.read(studentQueryProvider.notifier).setSearch(query); + } + void _onSearchFocusChanged() { if (mounted) setState(() {}); } @@ -605,6 +610,9 @@ class _StudentsScreenState extends ConsumerState { child: AppSearchBar( controller: _searchController, focusNode: _searchFocusNode, + collapsible: true, + hideIconWhenExpanded: true, + onSubmitted: _onSearchSubmitted, hint: 'Search by LRN or name...', maxWidth: isDesktop ? 420 : c.maxWidth, ), @@ -613,7 +621,7 @@ class _StudentsScreenState extends ConsumerState { const SizedBox(width: 8), _buildMultiSelectToggle(false), ], - if (isDesktop) ...[ + if (isDesktop && !_searchFocusNode.hasFocus) ...[ const SizedBox(width: 8), SizedBox( height: 42,