From 8ae1ab6f0bad92e4135cc1b9853a4504c3f213e5 Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Mon, 31 Aug 2026 22:51:06 +0800 Subject: [PATCH] feat(navigation): integrate student search, multi-select, and filter actions into Android AppBar header --- .../ui/layouts/android_bottom_nav_layout.dart | 127 +++++++++++++++++- .../lib/ui/providers/student_provider.dart | 19 +++ .../ui/screens/students/students_screen.dart | 35 ++++- 3 files changed, 173 insertions(+), 8 deletions(-) diff --git a/frontend/lib/ui/layouts/android_bottom_nav_layout.dart b/frontend/lib/ui/layouts/android_bottom_nav_layout.dart index 8c872a9..9d26b8f 100644 --- a/frontend/lib/ui/layouts/android_bottom_nav_layout.dart +++ b/frontend/lib/ui/layouts/android_bottom_nav_layout.dart @@ -26,6 +26,8 @@ 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 '../screens/students/widgets/student_filter_dialog.dart'; +import '../shared/inputs/app_search_bar.dart'; // Dummy screen for placeholders class PlaceholderScreen extends StatelessWidget { @@ -82,6 +84,46 @@ class _AndroidBottomNavLayoutState extends ConsumerState }); } + void _showStudentSearchDialog(BuildContext context, WidgetRef ref) { + 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(); + ref.read(studentQueryProvider.notifier).setSearch(value); + }, + ), + ), + ), + ); + }, + ); + } + void _initTabs() { final primaryTabsConfig = [ { @@ -332,6 +374,87 @@ class _AndroidBottomNavLayoutState extends ConsumerState ), elevation: 0, surfaceTintColor: Colors.transparent, + actions: [ + if (activeTab == 'Students') ...[ + // 1. Search Icon + Consumer( + builder: (context, ref, _) { + final query = ref.watch(studentQueryProvider); + return Tooltip( + message: query.search.isNotEmpty + ? 'Clear Search' + : 'Search Students', + child: IconButton( + icon: Icon( + query.search.isNotEmpty + ? Icons.close + : Icons.search, + size: 22, + ), + onPressed: () { + if (query.search.isNotEmpty) { + ref.read(studentQueryProvider.notifier).setSearch(''); + } else { + _showStudentSearchDialog(context, ref); + } + }, + ), + ); + }, + ), + // 2. Multi-Select Toggle (for non-teachers) + if (widget.userRole != 'teacher') + Consumer( + builder: (context, ref, _) { + final isMultiSelect = ref.watch(studentMultiSelectProvider); + return Tooltip( + message: isMultiSelect + ? 'Exit Multi-Select' + : 'Multi-Select', + child: IconButton( + icon: Icon( + Icons.checklist_rounded, + size: 22, + color: isMultiSelect + ? AppColors.primaryGreen + : null, + ), + onPressed: () { + final next = !isMultiSelect; + ref.read(studentMultiSelectProvider.notifier).state = next; + if (!next) { + ref.read(studentSelectedIdsProvider.notifier).state = []; + } + }, + ), + ); + }, + ), + // 3. Filter Icon with Badge + Consumer( + builder: (context, ref, _) { + final activeCount = ref.watch(studentActiveFilterCountProvider); + final query = ref.watch(studentQueryProvider); + return Tooltip( + message: 'Filter Students', + child: IconButton( + onPressed: () => + StudentFilterDialog.show(context, query: query), + icon: Badge( + isLabelVisible: activeCount > 0, + label: Text(activeCount.toString()), + child: const Icon( + Icons.tune_rounded, + size: 22, + ), + ), + ), + ); + }, + ), + const SizedBox(width: 4), + ], + ], ), drawer: Drawer( backgroundColor: Theme.of(context).colorScheme.surface, @@ -716,8 +839,8 @@ class _PageSkeletonLoaderState extends State<_PageSkeletonLoader> child: ListView.separated( itemCount: 5, physics: const NeverScrollableScrollPhysics(), - separatorBuilder: (_, __) => const SizedBox(height: 16), - itemBuilder: (_, __) => Container( + separatorBuilder: (_, _) => const SizedBox(height: 16), + itemBuilder: (_, _) => Container( height: 60, decoration: BoxDecoration( color: secondaryColor, diff --git a/frontend/lib/ui/providers/student_provider.dart b/frontend/lib/ui/providers/student_provider.dart index 45b573b..110c27b 100644 --- a/frontend/lib/ui/providers/student_provider.dart +++ b/frontend/lib/ui/providers/student_provider.dart @@ -200,6 +200,25 @@ class StudentQueryNotifier extends Notifier { void reset() => state = const StudentQueryParams(); } +// ============================================================ +// Multi-Select & Filter State Providers +// ============================================================ +final studentMultiSelectProvider = StateProvider((ref) => false); +final studentSelectedIdsProvider = StateProvider>((ref) => []); + +final studentActiveFilterCountProvider = Provider((ref) { + final query = ref.watch(studentQueryProvider); + return [ + query.gradeLevel.isNotEmpty, + query.section.isNotEmpty, + query.status.isNotEmpty, + query.schoolYear.isNotEmpty, + query.is4Ps.isNotEmpty, + query.sortBy.isNotEmpty, + query.limit != 20, + ].where((v) => v).length; +}); + // ============================================================ // Async data provider — re-fetches when query changes // ============================================================ diff --git a/frontend/lib/ui/screens/students/students_screen.dart b/frontend/lib/ui/screens/students/students_screen.dart index aedc489..5075a08 100644 --- a/frontend/lib/ui/screens/students/students_screen.dart +++ b/frontend/lib/ui/screens/students/students_screen.dart @@ -369,10 +369,14 @@ class _StudentsScreenState extends ConsumerState { message: 'Cancel selection', child: IconButton( icon: Icon(Icons.close, color: buttonColor), - onPressed: () => setState(() { - _selectedStudentIds.clear(); - _showMultiSelect = false; - }), + onPressed: () { + setState(() { + _selectedStudentIds.clear(); + _showMultiSelect = false; + }); + ref.read(studentMultiSelectProvider.notifier).state = false; + ref.read(studentSelectedIdsProvider.notifier).state = []; + }, ), ), const SizedBox(width: 8), @@ -792,6 +796,21 @@ class _StudentsScreenState extends ConsumerState { query.limit != 20, ].where((v) => v).length; + final multiSelectFromProvider = ref.watch(studentMultiSelectProvider); + if (multiSelectFromProvider != _showMultiSelect) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && multiSelectFromProvider != _showMultiSelect) { + setState(() { + _showMultiSelect = multiSelectFromProvider; + if (!multiSelectFromProvider) _selectedStudentIds.clear(); + }); + } + }); + } + + final isMobileOrAndroid = MediaQuery.of(context).size.width < 800 || + defaultTargetPlatform == TargetPlatform.android; + return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.keyF, control: true): () { @@ -811,6 +830,8 @@ class _StudentsScreenState extends ConsumerState { _showMultiSelect = false; _selectedStudentIds.clear(); }); + ref.read(studentMultiSelectProvider.notifier).state = false; + ref.read(studentSelectedIdsProvider.notifier).state = []; return; } ref.read(activeTabProvider.notifier).setTab('Dashboard'); @@ -849,7 +870,7 @@ class _StudentsScreenState extends ConsumerState { _buildInlineMultiSelectHeader( pageAsync.value?.students ?? [], ) - else + else if (!isMobileOrAndroid) Padding( padding: const EdgeInsets.only( left: AppSizes.p24, @@ -862,7 +883,9 @@ class _StudentsScreenState extends ConsumerState { ref, activeCount, ), - ), + ) + else + const SizedBox(height: 12), const SizedBox(height: AppSizes.p24), // ── Data Table / Cards ──