Files
school-attendance/lib/ui/teacher_student_management_page.dart
T
sihooandClaude Sonnet 5 0055b3d40b 학번 검색창 폭을 계정 추가 타일과 같게 조정
가로로 길게 늘어지던 검색창을 위쪽 "신규 학생 계정 추가" 타일과
같은 폭(formSquareSize)으로 맞추고, 좁은 화면에서 필터 칩들이
잘리지 않게 Row 대신 Wrap으로 변경.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 05:40:30 +00:00

860 lines
32 KiB
Dart

// 👥 교사용 학생 계정 관리 화면 (UI 전용). 신규 학생 계정 추가 폼과 강제 삭제 다이얼로그를 그린다.
// 서버 통신/상태는 lib/function/teacher_student_management_controller.dart가 담당한다.
import 'dart:typed_data';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import '../function/excel_file_picker.dart';
import '../function/teacher_student_management_controller.dart';
import '../theme/app_palette.dart';
import 'app_notice.dart';
import 'title_pill.dart';
// 📐 "실시간 출석 확인" 목록(teacher_attendance_page.dart)의 타일 규격과 동일하게 맞춘다.
const double kAttendanceTileWidth = 220;
const double kAttendanceTileHeight = 148;
// -----------------------------------------------------------------------------
// 👥 [서브 화면 2] 학생 계정 관리 란 (추가 양식 폼 + 강제 삭제 다이얼로그 완전 내장)
// -----------------------------------------------------------------------------
class TeacherStudentManagementPage extends StatefulWidget {
const TeacherStudentManagementPage({super.key});
@override
State<TeacherStudentManagementPage> createState() =>
_TeacherStudentManagementPageState();
}
class _TeacherStudentManagementPageState
extends State<TeacherStudentManagementPage> {
final TeacherStudentManagementController _controller =
TeacherStudentManagementController();
final TextEditingController _addIdController = TextEditingController();
final TextEditingController _addNameController = TextEditingController();
int? _selectedGrade;
bool _isDragging = false;
@override
void initState() {
super.initState();
_controller.init();
}
@override
void dispose() {
_controller.dispose();
_addIdController.dispose();
_addNameController.dispose();
super.dispose();
}
// ➕ [학생 추가 버튼 동작] 기본 비밀번호는 항상 1234로 고정.
Future<void> _addStudentAccount() async {
final sId = _addIdController.text.trim();
final sName = _addNameController.text.trim();
if (sId.isEmpty || sName.isEmpty) {
AppNotice.show(context, '모든 입력란을 채워주세요.');
return;
}
final (success, message) = await _controller.addStudentAccount(
studentId: sId,
name: sName,
password: '1234',
grade: _selectedGrade,
);
if (!mounted) return;
if (success) {
_addIdController.clear();
_addNameController.clear();
setState(() => _selectedGrade = null);
}
AppNotice.show(context, message);
}
// 📄 [파일 선택 버튼 동작] "파일 선택"으로 엑셀 고르기
Future<void> _pickExcelFile() async {
final bytes = await pickExcelFileBytes();
if (bytes == null) return;
if (!mounted) return;
await _handleExcelBytes(bytes);
}
// 📄 엑셀 바이트를 파싱해서 미리보기 다이얼로그를 띄운다 (드래그/파일선택 공용).
Future<void> _handleExcelBytes(Uint8List bytes) async {
List<BulkStudentRow> rows;
try {
rows = _controller.parseExcelBytes(bytes);
} catch (e) {
if (!mounted) return;
AppNotice.show(context, '엑셀 파일을 읽는 데 실패했습니다: $e');
return;
}
if (rows.isEmpty) {
if (!mounted) return;
AppNotice.show(context, '유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)');
return;
}
_showBulkPreviewDialog(rows);
}
// 📋 [일괄 등록 확인 팝업] 엑셀에서 뽑아낸 명단을 미리 보여주고 확정받는다.
void _showBulkPreviewDialog(List<BulkStudentRow> rows) {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text('${rows.length}명 확인됨'),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'아래 명단으로 계정을 일괄 생성합니다 (초기 비밀번호: 1234).',
style: TextStyle(color: Colors.black54, fontSize: 13),
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: ListView.builder(
shrinkWrap: true,
itemCount: rows.length,
itemBuilder: (context, i) {
final row = rows[i];
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 14,
child: Text(
'${i + 1}',
style: const TextStyle(fontSize: 11),
),
),
title: Text('${row.name} (${row.studentId})'),
trailing: Text(
row.grade != null ? '${row.grade}학년' : '미배정',
style: TextStyle(color: Colors.grey[600]),
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
_runBulkImport(rows);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text('${rows.length}명 일괄 등록'),
),
],
),
);
}
// 🚀 실제 일괄 등록 실행 + 진행 상황 표시 + 결과 요약 팝업.
Future<void> _runBulkImport(List<BulkStudentRow> rows) async {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => ListenableBuilder(
listenable: _controller,
builder: (context, _) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
content: Row(
children: [
const CircularProgressIndicator(),
const SizedBox(width: 20),
Text(
'등록 중... (${_controller.bulkDone}/${_controller.bulkTotal})',
),
],
),
),
),
);
final result = await _controller.bulkAddStudents(rows);
if (!mounted) return;
Navigator.pop(context); // 진행 팝업 닫기
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Text(result.failures.isEmpty ? '일괄 등록 완료' : '일괄 등록 완료 (일부 실패)'),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'성공 ${result.successCount}명 / 실패 ${result.failures.length}명',
),
if (result.failures.isNotEmpty) ...[
const SizedBox(height: 12),
const Text(
'실패 목록',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: Text(
result.failures.join('\n'),
style: const TextStyle(fontSize: 12, color: Colors.red),
),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('확인'),
),
],
),
);
}
// 🏫 [목록 행의 "학년" 칩] 눌러서 바로 학년 변경 (미배정/1/2/3학년 중 선택).
Future<void> _showGradeMenu(
BuildContext tileContext,
String studentId,
String studentName,
int? currentGrade,
) async {
final RenderBox box = tileContext.findRenderObject() as RenderBox;
final Offset position = box.localToGlobal(Offset.zero);
final selected = await showMenu<int?>(
context: tileContext,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + box.size.height,
position.dx,
0,
),
items: const [
PopupMenuItem(value: null, child: Text('미배정')),
PopupMenuItem(value: 1, child: Text('1학년')),
PopupMenuItem(value: 2, child: Text('2학년')),
PopupMenuItem(value: 3, child: Text('3학년')),
],
);
if (selected == currentGrade) return; // 취소했거나 같은 값 선택
if (!mounted) return;
final (_, message) = await _controller.updateStudentGrade(
studentId: studentId,
grade: selected,
);
if (!mounted) return;
AppNotice.show(context, message);
}
// 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화.
void _confirmResetDevice(String studentId, String studentName) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text(
'기기 등록 초기화',
style: TextStyle(fontWeight: FontWeight.bold, color: AppPalette.ink),
),
content: Text('$studentName 학생의 스마트폰 기기 등록과 비밀번호(1234)를 초기화하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
),
onPressed: () async {
Navigator.pop(ctx);
final (_, message) = await _controller.resetDevice(
studentId,
studentName,
);
if (!mounted) return;
AppNotice.show(context, message);
},
child: const Text('초기화 승인'),
),
],
),
);
}
// 🚨 [목록 행의 삭제 버튼] 계정 완전 삭제 확인 팝업.
void _confirmDeleteStudent(String studentId, String studentName) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.red[700]),
const SizedBox(width: 10),
const Text(
'계정 완전 삭제',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
],
),
content: Text(
'정말로 $studentName ($studentId) 학생의 계정을 영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('취소', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[600],
foregroundColor: Colors.white,
),
onPressed: () async {
Navigator.pop(ctx);
final (_, message) = await _controller.deleteStudentAccount(
studentId,
);
if (!mounted) return;
AppNotice.show(context, message);
},
child: const Text('영구 삭제'),
),
],
),
);
}
// 🏷️ 섹션 제목 (색 인디케이터 바 + 텍스트).
Widget _gradeFilterChip(int? grade, String label) {
final bool selected = _controller.gradeFilter == grade;
return ChoiceChip(
label: Text(label),
selected: selected,
onSelected: (_) => _controller.setGradeFilter(grade),
selectedColor: AppPalette.ink,
labelStyle: TextStyle(
color: selected ? Colors.white : AppPalette.ink,
fontWeight: FontWeight.bold,
),
);
}
Widget _sectionTitle(String text, {Color color = AppPalette.ink}) {
return Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
text,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
);
}
// 📝 [정사각형 박스 1] 신규 학생 계정 추가 폼. 박스가 작아도 버튼까지 다 보이도록 촘촘하게 배치.
Widget _buildAddAccountBox() {
const InputDecoration Function(String, IconData) fieldDecoration =
_compactFieldDecoration;
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppPalette.sage),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
),
],
),
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _addIdController,
keyboardType: TextInputType.number,
style: const TextStyle(fontSize: 12),
decoration: fieldDecoration('학번', Icons.badge),
),
const SizedBox(height: 4),
TextField(
controller: _addNameController,
style: const TextStyle(fontSize: 12),
decoration: fieldDecoration('이름', Icons.person),
),
const SizedBox(height: 4),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
Icon(Icons.lock, size: 14, color: Colors.grey),
SizedBox(width: 6),
Text(
'초기 비밀번호: 1234 (고정)',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
),
const SizedBox(height: 4),
DropdownButtonFormField<int>(
initialValue: _selectedGrade,
style: const TextStyle(fontSize: 12, color: Colors.black87),
decoration: fieldDecoration('학년 (선택)', Icons.class_),
items: const [
DropdownMenuItem(value: 1, child: Text('1학년')),
DropdownMenuItem(value: 2, child: Text('2학년')),
DropdownMenuItem(value: 3, child: Text('3학년')),
],
onChanged: (value) => setState(() => _selectedGrade = value),
),
const SizedBox(height: 6),
SizedBox(
width: double.infinity,
height: 30,
child: ElevatedButton(
onPressed: _controller.isWorking ? null : _addStudentAccount,
style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: _controller.isWorking
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text(
'학생 등록 완료',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
],
),
),
);
}
static InputDecoration _compactFieldDecoration(String label, IconData icon) {
return InputDecoration(
labelText: label,
labelStyle: const TextStyle(fontSize: 12),
prefixIcon: Icon(icon, size: 16),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
);
}
// 📤 [정사각형 박스 2] 엑셀 일괄 등록 드롭존.
Widget _buildExcelBox() {
return DropTarget(
onDragEntered: (_) => setState(() => _isDragging = true),
onDragExited: (_) => setState(() => _isDragging = false),
onDragDone: (details) async {
setState(() => _isDragging = false);
if (details.files.isEmpty) return;
final bytes = await details.files.first.readAsBytes();
if (!mounted) return;
await _handleExcelBytes(bytes);
},
child: InkWell(
onTap: _pickExcelFile,
borderRadius: BorderRadius.circular(24),
child: Container(
width: double.infinity,
height: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: _isDragging ? Colors.orange[50] : AppPalette.paper,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: _isDragging ? Colors.orange : AppPalette.sage,
width: _isDragging ? 2 : 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.upload_file_rounded,
size: 40,
color: _isDragging ? Colors.orange : AppPalette.sage,
),
const SizedBox(height: 12),
Text(
_isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
color: _isDragging ? Colors.orange[800] : AppPalette.ink,
),
),
const SizedBox(height: 4),
Text(
'.xlsx · 1행은 머리글, A열=학번 B열=이름 C열=학년(선택)',
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
// 🧑‍🎓 [학생 목록 타일] 아이콘/이름/상태/액션을 모두 중앙 정렬한 정사각 타일.
Widget _buildStudentTile(Map<String, dynamic> student) {
final String studentId = student['id'].toString();
final String studentName = student['name'].toString();
final int? grade = student['grade'] as int?;
final bool needsReset = student['device'] == '초기화 필요';
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppPalette.sage),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: needsReset
? Colors.red.withValues(alpha: 0.1)
: AppPalette.linen,
shape: BoxShape.circle,
),
child: Icon(
needsReset ? Icons.lock_reset : Icons.person,
size: 18,
color: needsReset ? Colors.red : AppPalette.ink,
),
),
const SizedBox(height: 8),
Text(
'$studentName ($studentId)',
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 4),
Text(
needsReset ? '초기화 대기중' : '정상 등록',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11.5,
color: needsReset ? Colors.red[700] : Colors.grey[600],
fontWeight: needsReset ? FontWeight.bold : FontWeight.normal,
),
),
const SizedBox(height: 6),
Wrap(
alignment: WrapAlignment.center,
spacing: 2,
runSpacing: 2,
children: [
Builder(
builder: (chipContext) => ActionChip(
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
labelPadding: const EdgeInsets.symmetric(horizontal: 6),
label: Text(
grade != null ? '$grade학년' : '미배정',
style: const TextStyle(fontSize: 11),
),
onPressed: () => _showGradeMenu(
chipContext,
studentId,
studentName,
grade,
),
),
),
IconButton(
icon: const Icon(
Icons.lock_reset,
color: AppPalette.ink,
size: 18,
),
tooltip: '기기 리셋',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
onPressed: () => _confirmResetDevice(studentId, studentName),
),
IconButton(
icon: const Icon(
Icons.delete_forever_rounded,
color: Colors.redAccent,
size: 18,
),
tooltip: '계정 영구 삭제',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
onPressed: () => _confirmDeleteStudent(studentId, studentName),
),
],
),
],
),
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
return Scaffold(
backgroundColor: AppPalette.mist,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: AppPalette.ink,
elevation: 0,
centerTitle: true,
title: const TitlePill('학생 통합 관리 센터'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: LayoutBuilder(
builder: (context, constraints) {
// 🖥️ 웹(넓은 화면)은 계정 추가/엑셀 등록을 정사각형 2칸으로 나란히, 폰은 기존처럼 세로로 쌓는다.
// 📐 학생 목록 타일은 "실시간 출석 확인" 목록의 타일(220x148)과 동일하게 맞춘다.
final bool isWide = constraints.maxWidth >= 800;
final double listWidth = constraints.maxWidth;
// 📉 계정 추가 / 엑셀 등록 칸: 정사각형 기본 크기의 25%(+ 버튼까지 다 보이도록 확대).
final double formSquareSize =
((constraints.maxWidth - 20) / 2) * 0.25 * 1.45;
final Widget addBoxColumn = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('신규 학생 계정 추가'),
const SizedBox(height: 16),
SizedBox(
width: isWide ? formSquareSize : double.infinity,
height: isWide ? formSquareSize : null,
child: _buildAddAccountBox(),
),
],
);
final Widget excelBoxColumn = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('엑셀로 한번에 추가 (신입생 등)'),
const SizedBox(height: 16),
SizedBox(
width: isWide ? formSquareSize : double.infinity,
height: isWide ? formSquareSize : 240,
child: _buildExcelBox(),
),
],
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
isWide
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
addBoxColumn,
const SizedBox(width: 20),
excelBoxColumn,
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
addBoxColumn,
const SizedBox(height: 24),
excelBoxColumn,
],
),
const SizedBox(height: 36),
// 🏷️ 인디케이터 바 (전체 학생 목록) — 위 두 칸을 합친 폭에 맞춘다.
SizedBox(
width: listWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_sectionTitle('전체 학생 목록'),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh_rounded),
onPressed: _controller.isLoadingStudents
? null
: _controller.fetchStudents,
tooltip: '새로고침',
),
],
),
const SizedBox(height: 8),
const Text(
'학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.',
style: TextStyle(
color: Colors.black54,
fontSize: 12,
),
),
const SizedBox(height: 12),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 8,
children: [
SizedBox(
width: isWide ? formSquareSize : 160,
child: TextField(
onChanged: _controller.setSearchQuery,
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: '학번으로 검색',
prefixIcon: const Icon(
Icons.search_rounded,
),
filled: true,
fillColor: AppPalette.paper,
contentPadding: const EdgeInsets.symmetric(
vertical: 0,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: AppPalette.sage,
),
),
),
),
),
_gradeFilterChip(null, '전체'),
_gradeFilterChip(1, '1학년'),
_gradeFilterChip(2, '2학년'),
_gradeFilterChip(3, '3학년'),
],
),
const SizedBox(height: 12),
_controller.isLoadingStudents
? const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(
child: CircularProgressIndicator(),
),
)
: _controller.filteredStudents.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(
vertical: 24,
),
child: Center(
child: Text(
_controller.students.isEmpty
? '가입된 학생 계정이 없습니다.'
: '검색/필터 조건에 맞는 학생이 없습니다.',
style: const TextStyle(
color: Colors.grey,
),
),
),
)
: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent:
kAttendanceTileWidth,
mainAxisExtent: kAttendanceTileHeight,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount:
_controller.filteredStudents.length,
itemBuilder: (context, index) =>
_buildStudentTile(
_controller.filteredStudents[index],
),
),
],
),
),
],
);
},
),
),
);
},
);
}
}