// πŸ‘₯ κ΅μ‚¬μš© 학생 계정 관리 ν™”λ©΄ (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'; // πŸ“ "μ‹€μ‹œκ°„ μΆœμ„ 확인" λͺ©λ‘(teacher_attendance_page.dart)의 타일 규격과 λ™μΌν•˜κ²Œ λ§žμΆ˜λ‹€. const double kAttendanceTileWidth = 220; const double kAttendanceTileHeight = 148; // ----------------------------------------------------------------------------- // πŸ‘₯ [μ„œλΈŒ ν™”λ©΄ 2] 학생 계정 관리 λž€ (μΆ”κ°€ 양식 폼 + κ°•μ œ μ‚­μ œ λ‹€μ΄μ–Όλ‘œκ·Έ μ™„μ „ λ‚΄μž₯) // ----------------------------------------------------------------------------- class TeacherStudentManagementPage extends StatefulWidget { const TeacherStudentManagementPage({super.key}); @override State createState() => _TeacherStudentManagementPageState(); } class _TeacherStudentManagementPageState extends State { final TeacherStudentManagementController _controller = TeacherStudentManagementController(); final TextEditingController _addIdController = TextEditingController(); final TextEditingController _addNameController = TextEditingController(); final TextEditingController _addPwController = TextEditingController(); int? _selectedGrade; bool _isDragging = false; @override void initState() { super.initState(); _controller.init(); } @override void dispose() { _controller.dispose(); _addIdController.dispose(); _addNameController.dispose(); _addPwController.dispose(); super.dispose(); } // βž• [학생 μΆ”κ°€ λ²„νŠΌ λ™μž‘] Future _addStudentAccount() async { final sId = _addIdController.text.trim(); final sName = _addNameController.text.trim(); final sPw = _addPwController.text.trim(); if (sId.isEmpty || sName.isEmpty || sPw.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('⚠️ λͺ¨λ“  μž…λ ₯λž€μ„ μ±„μ›Œμ£Όμ„Έμš”.'))); return; } final (success, message) = await _controller.addStudentAccount( studentId: sId, name: sName, password: sPw, grade: _selectedGrade, ); if (!mounted) return; if (success) { _addIdController.clear(); _addNameController.clear(); _addPwController.clear(); setState(() => _selectedGrade = null); } ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } // πŸ“„ [파일 선택 λ²„νŠΌ λ™μž‘] "파일 선택"으둜 μ—‘μ…€ κ³ λ₯΄κΈ° Future _pickExcelFile() async { final bytes = await pickExcelFileBytes(); if (bytes == null) return; if (!mounted) return; await _handleExcelBytes(bytes); } // πŸ“„ μ—‘μ…€ λ°”μ΄νŠΈλ₯Ό νŒŒμ‹±ν•΄μ„œ 미리보기 λ‹€μ΄μ–Όλ‘œκ·Έλ₯Ό λ„μš΄λ‹€ (λ“œλž˜κ·Έ/νŒŒμΌμ„ νƒ 곡용). Future _handleExcelBytes(Uint8List bytes) async { List rows; try { rows = _controller.parseExcelBytes(bytes); } catch (e) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('❌ μ—‘μ…€ νŒŒμΌμ„ μ½λŠ” 데 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€: $e'))); return; } if (rows.isEmpty) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('⚠️ μœ νš¨ν•œ 학생 데이터λ₯Ό μ°Ύμ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. (1행은 λ¨Έλ¦¬κΈ€λ‘œ κ±΄λ„ˆλœλ‹ˆλ‹€)'), ), ); return; } _showBulkPreviewDialog(rows); } // πŸ“‹ [일괄 등둝 확인 νŒμ—…] μ—‘μ…€μ—μ„œ 뽑아낸 λͺ…단을 미리 보여주고 ν™•μ •λ°›λŠ”λ‹€. void _showBulkPreviewDialog(List 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: Colors.orange, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Text('${rows.length}λͺ… 일괄 등둝'), ), ], ), ); } // πŸš€ μ‹€μ œ 일괄 등둝 μ‹€ν–‰ + μ§„ν–‰ 상황 ν‘œμ‹œ + κ²°κ³Ό μš”μ•½ νŒμ—…. Future _runBulkImport(List 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 _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( 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; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } // πŸ”“ [λͺ©λ‘ ν–‰μ˜ "κΈ°κΈ° 리셋" λ²„νŠΌ] 학생이 폰을 바꿨을 λ•Œ 등둝 μ΄ˆκΈ°ν™”. void _confirmResetDevice(String studentId, String studentName) { showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text( '⚠️ κΈ°κΈ° 등둝 μ΄ˆκΈ°ν™”', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple), ), content: Text('$studentName ν•™μƒμ˜ 슀마트폰 κΈ°κΈ° 등둝과 λΉ„λ°€λ²ˆν˜Έ(1234)λ₯Ό μ΄ˆκΈ°ν™”ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('μ·¨μ†Œ', style: TextStyle(color: Colors.grey)), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.purple, foregroundColor: Colors.white, ), onPressed: () async { Navigator.pop(ctx); final (_, message) = await _controller.resetDevice( studentId, studentName, ); if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(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; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); }, child: const Text('영ꡬ μ‚­μ œ'), ), ], ), ); } // 🏷️ μ„Ήμ…˜ 제λͺ© (색 인디케이터 λ°” + ν…μŠ€νŠΈ). Widget _sectionTitle(String text, {Color color = Colors.orange}) { 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: Colors.white, borderRadius: BorderRadius.circular(24), 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), TextField( controller: _addPwController, obscureText: true, style: const TextStyle(fontSize: 12), decoration: fieldDecoration('초기 λΉ„λ°€λ²ˆν˜Έ', Icons.lock), ), const SizedBox(height: 4), DropdownButtonFormField( 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: Colors.orange, foregroundColor: Colors.white, 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] : Colors.white, borderRadius: BorderRadius.circular(24), border: Border.all( color: _isDragging ? Colors.orange : Colors.grey.shade300, width: _isDragging ? 2 : 1, ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.upload_file_rounded, size: 40, color: _isDragging ? Colors.orange : Colors.grey[400], ), const SizedBox(height: 12), Text( _isDragging ? '여기에 λ†“μœΌμ„Έμš”' : 'μ—‘μ…€ νŒŒμΌμ„ λ“œλž˜κ·Έν•˜κ±°λ‚˜ λˆŒλŸ¬μ„œ 선택', textAlign: TextAlign.center, style: TextStyle( fontWeight: FontWeight.bold, color: _isDragging ? Colors.orange[800] : Colors.black87, ), ), const SizedBox(height: 4), Text( '.xlsx Β· 1행은 머리글, Aμ—΄=ν•™λ²ˆ Bμ—΄=이름 Cμ—΄=ν•™λ…„(선택)', style: TextStyle(fontSize: 12, color: Colors.grey[500]), textAlign: TextAlign.center, ), ], ), ), ), ); } // πŸ§‘β€πŸŽ“ [학생 λͺ©λ‘ 타일] μ•„μ΄μ½˜/이름/μƒνƒœ/μ•‘μ…˜μ„ λͺ¨λ‘ 쀑앙 μ •λ ¬ν•œ 정사각 타일. Widget _buildStudentTile(Map 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: Colors.white, borderRadius: BorderRadius.circular(20), 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 : Colors.blue).withValues( alpha: 0.1, ), shape: BoxShape.circle, ), child: Icon( needsReset ? Icons.lock_reset : Icons.person, size: 18, color: needsReset ? Colors.red : Colors.blue, ), ), 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: Colors.purple, 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: Colors.grey[100], appBar: AppBar( title: const Text( 'βš™οΈ 학생 톡합 관리 μ„Όν„°', style: TextStyle(fontWeight: FontWeight.bold), ), backgroundColor: Colors.orange, foregroundColor: Colors.white, elevation: 0, ), 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('전체 학생 λͺ©λ‘', color: Colors.blue), 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), _controller.isLoadingStudents ? const Padding( padding: EdgeInsets.symmetric(vertical: 40), child: Center( child: CircularProgressIndicator(), ), ) : _controller.students.isEmpty ? const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center( child: Text( 'κ°€μž…λœ 학생 계정이 μ—†μŠ΅λ‹ˆλ‹€.', style: TextStyle(color: Colors.grey), ), ), ) : GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: kAttendanceTileWidth, mainAxisExtent: kAttendanceTileHeight, crossAxisSpacing: 12, mainAxisSpacing: 12, ), itemCount: _controller.students.length, itemBuilder: (context, index) => _buildStudentTile( _controller.students[index], ), ), ], ), ), ], ); }, ), ), ); }, ); } }