From 5b3eb2fcf827b7579d0b95bab4497fe4fd86d43b Mon Sep 17 00:00:00 2001 From: sihoo Date: Tue, 1 Sep 2026 00:28:51 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=99=EC=83=9D=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=99=94=EB=A9=B4=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=9E=AC=EA=B5=AC=EC=84=B1=20(=EC=A0=95?= =?UTF-8?q?=EC=82=AC=EA=B0=81=ED=98=95=202=EC=B9=B8=20+=20=ED=83=80?= =?UTF-8?q?=EC=9D=BC=ED=98=95=20=ED=95=99=EC=83=9D=20=EB=AA=A9=EB=A1=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 웹 넓은 화면에서 계정 추가/엑셀 일괄 등록을 정사각형 2칸으로 나란히 배치(왼쪽 계정 추가, 오른쪽 엑셀 등록)하고, 그 아래 학생 목록을 두 칸 합친 폭에 맞춘 타일 그리드로 변경. 각 타일은 아이콘/ 이름/상태/액션 버튼을 모두 중앙 정렬. 폰 화면은 기존처럼 세로 스택 유지. Co-Authored-By: Claude Sonnet 5 --- lib/ui/teacher_student_management_page.dart | 704 ++++++++++---------- 1 file changed, 370 insertions(+), 334 deletions(-) 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, - ), - ), - ], - ), - ), - ); - }, - ), - ], + ); + }, ), ), );