diff --git a/lib/ui/teacher_student_management_page.dart b/lib/ui/teacher_student_management_page.dart index 9ae1e2a..034d7f0 100644 --- a/lib/ui/teacher_student_management_page.dart +++ b/lib/ui/teacher_student_management_page.dart @@ -372,6 +372,268 @@ class _TeacherStudentManagementPageState ); } + // 🏷️ μ„Ήμ…˜ 제λͺ© (색 인디케이터 λ°” + ν…μŠ€νŠΈ). + 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() { + return Container( + padding: const EdgeInsets.all(20), + 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, + decoration: const InputDecoration( + labelText: 'ν•™λ²ˆ', + prefixIcon: Icon(Icons.badge), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _addNameController, + decoration: const InputDecoration( + labelText: '이름', + prefixIcon: Icon(Icons.person), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _addPwController, + obscureText: true, + decoration: const InputDecoration( + labelText: '초기 λΉ„λ°€λ²ˆν˜Έ', + prefixIcon: Icon(Icons.lock), + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _selectedGrade, + decoration: const InputDecoration( + labelText: 'ν•™λ…„ (선택)', + prefixIcon: Icon(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: 20), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _controller.isWorking ? null : _addStudentAccount, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _controller.isWorking + ? const CircularProgressIndicator(color: Colors.white) + : const Text( + '학생 등둝 μ™„λ£Œ', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + ), + ), + ], + ), + ), + ); + } + + // πŸ“€ [μ •μ‚¬κ°ν˜• λ°•μŠ€ 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.04), + blurRadius: 12, + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar( + radius: 24, + backgroundColor: needsReset ? Colors.red[50] : Colors.blue[50], + child: Icon( + needsReset ? Icons.lock_reset : Icons.person, + color: needsReset ? Colors.red : Colors.blue, + ), + ), + const SizedBox(height: 10), + Text( + '$studentName ($studentId)', + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + const SizedBox(height: 4), + Text( + needsReset ? 'μ΄ˆκΈ°ν™” λŒ€κΈ°μ€‘' : '정상 등둝', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: needsReset ? Colors.red : Colors.grey, + fontWeight: needsReset ? FontWeight.bold : FontWeight.normal, + ), + ), + const SizedBox(height: 10), + Wrap( + alignment: WrapAlignment.center, + spacing: 2, + runSpacing: 2, + children: [ + Builder( + builder: (chipContext) => ActionChip( + visualDensity: VisualDensity.compact, + label: Text( + grade != null ? '$gradeν•™λ…„' : 'λ―Έλ°°μ •', + style: const TextStyle(fontSize: 11), + ), + onPressed: () => _showGradeMenu( + chipContext, + studentId, + studentName, + grade, + ), + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon( + Icons.lock_reset, + color: Colors.purple, + size: 20, + ), + tooltip: 'κΈ°κΈ° 리셋', + onPressed: () => _confirmResetDevice(studentId, studentName), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon( + Icons.delete_forever_rounded, + color: Colors.redAccent, + size: 20, + ), + tooltip: '계정 영ꡬ μ‚­μ œ', + onPressed: () => _confirmDeleteStudent(studentId, studentName), + ), + ], + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -390,356 +652,130 @@ class _TeacherStudentManagementPageState ), body: SingleChildScrollView( padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 🏷️ 인디케이터 λ°” 1 (μΆ”κ°€ 메뉴) - Row( + child: LayoutBuilder( + builder: (context, constraints) { + // πŸ–₯️ μ›Ή(넓은 ν™”λ©΄)은 계정 μΆ”κ°€/μ—‘μ…€ 등둝을 μ •μ‚¬κ°ν˜• 2칸으둜 λ‚˜λž€νžˆ, 폰은 기쑴처럼 μ„Έλ‘œλ‘œ μŒ“λŠ”λ‹€. + final bool isWide = constraints.maxWidth >= 800; + final double squareSize = (constraints.maxWidth - 20) / 2; + final double listWidth = isWide + ? squareSize * 2 + 20 + : constraints.maxWidth; + + final Widget addBoxColumn = Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 4, - height: 16, - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - const Text( - 'μ‹ κ·œ 학생 계정 μΆ”κ°€', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + _sectionTitle('μ‹ κ·œ 학생 계정 μΆ”κ°€'), + const SizedBox(height: 16), + SizedBox( + width: isWide ? squareSize : double.infinity, + height: isWide ? squareSize : null, + child: _buildAddAccountBox(), ), ], - ), - const SizedBox(height: 16), + ); - // πŸ“ 학생 μΆ”κ°€ μ»¨ν…Œμ΄λ„ˆ 폼 - // πŸ–₯️ μ›Ή 넓은 ν™”λ©΄μ—μ„œ 폼이 μ§€λ‚˜μΉ˜κ²Œ κΈΈμ–΄μ§€μ§€ μ•Šλ„λ‘ ν˜„μž¬ 폭의 2/3둜 μ œν•œν•œλ‹€. - LayoutBuilder( - builder: (context, constraints) { - return ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constraints.maxWidth * 2 / 3, - ), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 16, - ), - ], - ), - child: Column( - children: [ - TextField( - controller: _addIdController, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: 'ν•™λ²ˆ', - prefixIcon: Icon(Icons.badge), - ), - ), - const SizedBox(height: 12), - TextField( - controller: _addNameController, - decoration: const InputDecoration( - labelText: '이름', - prefixIcon: Icon(Icons.person), - ), - ), - const SizedBox(height: 12), - TextField( - controller: _addPwController, - obscureText: true, - decoration: const InputDecoration( - labelText: '초기 λΉ„λ°€λ²ˆν˜Έ', - prefixIcon: Icon(Icons.lock), - ), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _selectedGrade, - decoration: const InputDecoration( - labelText: 'ν•™λ…„ (선택)', - prefixIcon: Icon(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: 20), - SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: _controller.isWorking - ? null - : _addStudentAccount, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.orange, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: _controller.isWorking - ? const CircularProgressIndicator( - color: Colors.white, - ) - : const Text( - '학생 등둝 μ™„λ£Œ', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - ), - ), - ), - ), - ], - ), - ), - ); - }, - ), - const SizedBox(height: 36), - - // 🏷️ 인디케이터 λ°” (μ—‘μ…€ 일괄 등둝) - Row( + final Widget excelBoxColumn = Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 4, - height: 16, - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - const Text( - 'μ—‘μ…€λ‘œ ν•œλ²ˆμ— μΆ”κ°€ (μ‹ μž…μƒ λ“±)', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + _sectionTitle('μ—‘μ…€λ‘œ ν•œλ²ˆμ— μΆ”κ°€ (μ‹ μž…μƒ λ“±)'), + const SizedBox(height: 16), + SizedBox( + width: isWide ? squareSize : double.infinity, + height: isWide ? squareSize : 240, + child: _buildExcelBox(), ), ], - ), - const SizedBox(height: 16), + ); - 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, - padding: const EdgeInsets.symmetric(vertical: 28), - 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, - ), - ), + 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: [ - Icon( - Icons.upload_file_rounded, - size: 40, - color: _isDragging - ? Colors.orange - : Colors.grey[400], + 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), - Text( - _isDragging ? '여기에 λ†“μœΌμ„Έμš”' : 'μ—‘μ…€ νŒŒμΌμ„ λ“œλž˜κ·Έν•˜κ±°λ‚˜ λˆŒλŸ¬μ„œ 선택', - 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, - ), + _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: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: isWide ? 4 : 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: isWide ? 1.5 : 0.75, + ), + itemCount: _controller.students.length, + itemBuilder: (context, index) => + _buildStudentTile( + _controller.students[index], + ), + ), ], ), ), - ), - ), - const SizedBox(height: 36), - - // 🏷️ 인디케이터 λ°” (전체 학생 λͺ©λ‘) - Row( - children: [ - Container( - width: 4, - height: 16, - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - const Text( - '전체 학생 λͺ©λ‘', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - 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), - ), - ), - ) - : ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _controller.students.length, - itemBuilder: (context, index) { - final student = _controller.students[index]; - 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 Card( - elevation: 1, - margin: const EdgeInsets.only(bottom: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - leading: CircleAvatar( - backgroundColor: needsReset - ? Colors.red[50] - : Colors.blue[50], - child: Icon( - needsReset ? Icons.lock_reset : Icons.person, - color: needsReset ? Colors.red : Colors.blue, - ), - ), - title: Text( - '$studentName ($studentId)', - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - subtitle: Text( - needsReset ? 'κΈ°κΈ° μ΄ˆκΈ°ν™” 승인 λŒ€κΈ°μ€‘' : '정상 등둝 μƒνƒœ', - style: TextStyle( - color: needsReset ? Colors.red : Colors.grey, - fontWeight: needsReset - ? FontWeight.bold - : FontWeight.normal, - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Builder( - builder: (chipContext) => ActionChip( - label: Text( - grade != null ? '$gradeν•™λ…„' : 'λ―Έλ°°μ •', - ), - onPressed: () => _showGradeMenu( - chipContext, - studentId, - studentName, - grade, - ), - ), - ), - IconButton( - icon: const Icon( - Icons.lock_reset, - color: Colors.purple, - ), - tooltip: 'κΈ°κΈ° 리셋', - onPressed: () => _confirmResetDevice( - studentId, - studentName, - ), - ), - IconButton( - icon: const Icon( - Icons.delete_forever_rounded, - color: Colors.redAccent, - ), - tooltip: '계정 영ꡬ μ‚­μ œ', - onPressed: () => _confirmDeleteStudent( - studentId, - studentName, - ), - ), - ], - ), - ), - ); - }, - ), - ], + ); + }, ), ), );