// πŸ‘₯ κ΅μ‚¬μš© 학생 계정 관리 ν™”λ©΄ (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'; // ----------------------------------------------------------------------------- // πŸ‘₯ [μ„œλΈŒ ν™”λ©΄ 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 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('확인'), ), ], ), ); } // 🏫 [κΈ°μ‘΄ 학생 ν•™λ…„ μ§€μ •/λ³€κ²½ λ‹€μ΄μ–Όλ‘œκ·Έ] void _showUpdateGradeDialog() { final TextEditingController idController = TextEditingController(); int? grade; showDialog( context: context, builder: (context) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), ), title: const Text( '🏫 ν•™λ…„ μ§€μ •/λ³€κ²½', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '이미 λ§Œλ“€μ–΄μ§„ 학생 κ³„μ •μ˜ 학년을 μ§€μ •ν•˜κ±°λ‚˜ λ°”κΏ‰λ‹ˆλ‹€.', style: TextStyle(color: Colors.black54, fontSize: 13), ), const SizedBox(height: 16), TextField( controller: idController, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'ν•™λ²ˆ', border: OutlineInputBorder(), prefixIcon: Icon(Icons.badge), ), ), const SizedBox(height: 12), DropdownButtonFormField( initialValue: grade, decoration: const InputDecoration( labelText: 'ν•™λ…„', border: OutlineInputBorder(), prefixIcon: Icon(Icons.class_), ), items: const [ DropdownMenuItem(value: null, child: Text('λ―Έλ°°μ •')), DropdownMenuItem(value: 1, child: Text('1ν•™λ…„')), DropdownMenuItem(value: 2, child: Text('2ν•™λ…„')), DropdownMenuItem(value: 3, child: Text('3ν•™λ…„')), ], onChanged: (value) => setDialogState(() => grade = value), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('μ·¨μ†Œ', style: TextStyle(color: Colors.grey)), ), ElevatedButton( onPressed: () async { final inputId = idController.text.trim(); if (inputId.isEmpty) return; Navigator.pop(context); final (_, message) = await _controller.updateStudentGrade( studentId: inputId, grade: grade, ); if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.blue, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: const Text( '적용', style: TextStyle(fontWeight: FontWeight.bold), ), ), ], ); }, ); }, ); } // ❌ [학생 μ‚­μ œ λ²„νŠΌ λ™μž‘] Future _deleteStudentAccount(String studentId) async { final (_, message) = await _controller.deleteStudentAccount(studentId); if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } // 🚨 계정 μ‚­μ œ 확인 νŒμ—…μ°½ λͺ¨λ‹¬ void _showDeleteDialog() { final TextEditingController deleteIdController = TextEditingController(); showDialog( context: context, builder: (context) { return AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), ), title: Row( children: [ Icon(Icons.warning_amber_rounded, color: Colors.orange[700]), const SizedBox(width: 10), const Text( '학생 계정 κ°•μ œ μ‚­μ œ', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), ), ], ), content: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( '영ꡬ μ‚­μ œν•  ν•™μƒμ˜ ν•™λ²ˆμ„ μ •ν™•ν•˜κ²Œ μž…λ ₯ν•˜μ„Έμš”.', style: TextStyle(color: Colors.black54, fontSize: 13), ), const SizedBox(height: 16), TextField( controller: deleteIdController, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: 'ν•™λ²ˆ μž…λ ₯', labelStyle: TextStyle(color: Colors.orange[700]), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide( color: Colors.orange[700]!, width: 2, ), ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), ), prefixIcon: const Icon(Icons.no_accounts_rounded), ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('μ·¨μ†Œ', style: TextStyle(color: Colors.grey)), ), ElevatedButton( onPressed: () { final inputId = deleteIdController.text.trim(); if (inputId.isNotEmpty) { Navigator.pop(context); _deleteStudentAccount(inputId); } }, style: ElevatedButton.styleFrom( backgroundColor: Colors.orange[700], foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: const Text( 'μ‚­μ œ ν™•μ •', style: TextStyle(fontWeight: FontWeight.bold), ), ), ], ); }, ); } @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: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 🏷️ 인디케이터 λ°” 1 (μΆ”κ°€ 메뉴) Row( 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, ), ), ], ), const SizedBox(height: 16), // πŸ“ 학생 μΆ”κ°€ μ»¨ν…Œμ΄λ„ˆ 폼 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( 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, ), ), ], ), 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, ), ), child: Column( children: [ Icon( Icons.upload_file_rounded, size: 40, color: _isDragging ? Colors.orange : Colors.grey[400], ), 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, ), ], ), ), ), ), 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 SizedBox(height: 16), InkWell( onTap: _showUpdateGradeDialog, borderRadius: BorderRadius.circular(24), child: Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.blue[50], borderRadius: BorderRadius.circular(24), border: Border.all(color: Colors.blue.shade200), ), child: const Row( children: [ Icon(Icons.class_rounded, color: Colors.blue, size: 32), SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'ν•™λ²ˆμœΌλ‘œ ν•™λ…„ μ§€μ •', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blue, ), ), SizedBox(height: 2), Text( '이미 λ§Œλ“  κ³„μ •μ˜ 학년을 λ‚˜μ€‘μ— μ§€μ •ν•˜κ±°λ‚˜ λ°”κΏ€ λ•Œ μ‚¬μš©', style: TextStyle( fontSize: 12, color: Colors.blueAccent, ), ), ], ), ), Icon( Icons.arrow_forward_ios_rounded, color: Colors.blue, size: 16, ), ], ), ), ), const SizedBox(height: 36), // 🏷️ 인디케이터 λ°” 2 (μ‚­μ œ κΆŒν•œ μ œμ–΄λ©”λ‰΄) Row( children: [ Container( width: 4, height: 16, decoration: BoxDecoration( color: Colors.red, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 8), const Text( 'μœ„ν—˜ ꡬ역 (Account Reset)', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: Colors.redAccent, ), ), ], ), const SizedBox(height: 16), // 🚨 학생 μ‚­μ œ 트리거 μΉ΄λ“œ InkWell( onTap: _showDeleteDialog, borderRadius: BorderRadius.circular(24), child: Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.red[50], borderRadius: BorderRadius.circular(24), border: Border.all(color: Colors.red.shade200), ), child: const Row( children: [ Icon( Icons.delete_sweep_rounded, color: Colors.red, size: 32, ), SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '학생 계정 κ°•μ œ 원격 μ‚­μ œ', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.red, ), ), SizedBox(height: 2), Text( '인증 μ΄ˆκΈ°ν™” 및 DB 제거용', style: TextStyle( fontSize: 12, color: Colors.redAccent, ), ), ], ), ), Icon( Icons.arrow_forward_ios_rounded, color: Colors.red, size: 16, ), ], ), ), ), ], ), ), ); }, ); } }