학생 계정 관리 화면 레이아웃 재구성 (정사각형 2칸 + 타일형 학생 목록)
웹 넓은 화면에서 계정 추가/엑셀 일괄 등록을 정사각형 2칸으로 나란히 배치(왼쪽 계정 추가, 오른쪽 엑셀 등록)하고, 그 아래 학생 목록을 두 칸 합친 폭에 맞춘 타일 그리드로 변경. 각 타일은 아이콘/ 이름/상태/액션 버튼을 모두 중앙 정렬. 폰 화면은 기존처럼 세로 스택 유지. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -372,59 +372,30 @@ class _TeacherStudentManagementPageState
|
||||
);
|
||||
}
|
||||
|
||||
@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(
|
||||
// 🏷️ 섹션 제목 (색 인디케이터 바 + 텍스트).
|
||||
Widget _sectionTitle(String text, {Color color = Colors.orange}) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange,
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'신규 학생 계정 추가',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
);
|
||||
}
|
||||
|
||||
// 📝 학생 추가 컨테이너 폼
|
||||
// 🖥️ 웹 넓은 화면에서 폼이 지나치게 길어지지 않도록 현재 폭의 2/3로 제한한다.
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth * 2 / 3,
|
||||
),
|
||||
child: Container(
|
||||
// 📝 [정사각형 박스 1] 신규 학생 계정 추가 폼.
|
||||
Widget _buildAddAccountBox() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
@@ -436,7 +407,9 @@ class _TeacherStudentManagementPageState
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _addIdController,
|
||||
@@ -475,17 +448,14 @@ class _TeacherStudentManagementPageState
|
||||
DropdownMenuItem(value: 2, child: Text('2학년')),
|
||||
DropdownMenuItem(value: 3, child: Text('3학년')),
|
||||
],
|
||||
onChanged: (value) =>
|
||||
setState(() => _selectedGrade = value),
|
||||
onChanged: (value) => setState(() => _selectedGrade = value),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: _controller.isWorking
|
||||
? null
|
||||
: _addStudentAccount,
|
||||
onPressed: _controller.isWorking ? null : _addStudentAccount,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
@@ -494,9 +464,7 @@ class _TeacherStudentManagementPageState
|
||||
),
|
||||
),
|
||||
child: _controller.isWorking
|
||||
? const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
)
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text(
|
||||
'학생 등록 완료',
|
||||
style: TextStyle(
|
||||
@@ -510,34 +478,11 @@ class _TeacherStudentManagementPageState
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
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(
|
||||
// 📤 [정사각형 박스 2] 엑셀 일괄 등록 드롭존.
|
||||
Widget _buildExcelBox() {
|
||||
return DropTarget(
|
||||
onDragEntered: (_) => setState(() => _isDragging = true),
|
||||
onDragExited: (_) => setState(() => _isDragging = false),
|
||||
onDragDone: (details) async {
|
||||
@@ -552,71 +497,227 @@ class _TeacherStudentManagementPageState
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 28),
|
||||
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,
|
||||
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],
|
||||
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,
|
||||
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],
|
||||
),
|
||||
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: 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(
|
||||
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칸으로 나란히, 폰은 기존처럼 세로로 쌓는다.
|
||||
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: [
|
||||
_sectionTitle('신규 학생 계정 추가'),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: isWide ? squareSize : double.infinity,
|
||||
height: isWide ? squareSize : null,
|
||||
child: _buildAddAccountBox(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final Widget excelBoxColumn = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle('엑셀로 한번에 추가 (신입생 등)'),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: isWide ? squareSize : double.infinity,
|
||||
height: isWide ? squareSize : 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: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
_sectionTitle('전체 학생 목록', color: Colors.blue),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
@@ -630,14 +731,18 @@ class _TeacherStudentManagementPageState
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.',
|
||||
style: TextStyle(color: Colors.black54, fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: Colors.black54,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_controller.isLoadingStudents
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 40),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: _controller.students.isEmpty
|
||||
? const Padding(
|
||||
@@ -649,98 +754,29 @@ class _TeacherStudentManagementPageState
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
: 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) {
|
||||
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,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildStudentTile(
|
||||
_controller.students[index],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user