학생 계정 관리 화면 레이아웃 재구성 (정사각형 2칸 + 타일형 학생 목록)

웹 넓은 화면에서 계정 추가/엑셀 일괄 등록을 정사각형 2칸으로
나란히 배치(왼쪽 계정 추가, 오른쪽 엑셀 등록)하고, 그 아래 학생
목록을 두 칸 합친 폭에 맞춘 타일 그리드로 변경. 각 타일은 아이콘/
이름/상태/액션 버튼을 모두 중앙 정렬. 폰 화면은 기존처럼 세로 스택
유지.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 00:28:51 +09:00
co-authored by Claude Sonnet 5
parent debf733afd
commit 5b3eb2fcf8
+227 -191
View File
@@ -372,59 +372,30 @@ class _TeacherStudentManagementPageState
); );
} }
@override // 🏷️ 섹션 제목 (색 인디케이터 바 + 텍스트).
Widget build(BuildContext context) { Widget _sectionTitle(String text, {Color color = Colors.orange}) {
return ListenableBuilder( return Row(
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: [ children: [
Container( Container(
width: 4, width: 4,
height: 16, height: 16,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.orange, color: color,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( Text(
'신규 학생 계정 추가', text,
style: TextStyle( style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
fontSize: 18,
fontWeight: FontWeight.bold,
),
), ),
], ],
), );
const SizedBox(height: 16), }
// 📝 학생 추가 컨테이너 폼 // 📝 [정사각형 박스 1] 신규 학생 계정 추가 폼.
// 🖥️ 웹 넓은 화면에서 폼이 지나치게 길어지지 않도록 현재 폭의 2/3로 제한한다. Widget _buildAddAccountBox() {
LayoutBuilder( return Container(
builder: (context, constraints) {
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 2 / 3,
),
child: Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
@@ -436,7 +407,9 @@ class _TeacherStudentManagementPageState
), ),
], ],
), ),
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
TextField( TextField(
controller: _addIdController, controller: _addIdController,
@@ -475,17 +448,14 @@ class _TeacherStudentManagementPageState
DropdownMenuItem(value: 2, child: Text('2학년')), DropdownMenuItem(value: 2, child: Text('2학년')),
DropdownMenuItem(value: 3, child: Text('3학년')), DropdownMenuItem(value: 3, child: Text('3학년')),
], ],
onChanged: (value) => onChanged: (value) => setState(() => _selectedGrade = value),
setState(() => _selectedGrade = value),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 50, height: 50,
child: ElevatedButton( child: ElevatedButton(
onPressed: _controller.isWorking onPressed: _controller.isWorking ? null : _addStudentAccount,
? null
: _addStudentAccount,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
foregroundColor: Colors.white, foregroundColor: Colors.white,
@@ -494,9 +464,7 @@ class _TeacherStudentManagementPageState
), ),
), ),
child: _controller.isWorking child: _controller.isWorking
? const CircularProgressIndicator( ? const CircularProgressIndicator(color: Colors.white)
color: Colors.white,
)
: const Text( : const Text(
'학생 등록 완료', '학생 등록 완료',
style: TextStyle( style: TextStyle(
@@ -510,34 +478,11 @@ class _TeacherStudentManagementPageState
), ),
), ),
); );
}, }
),
const SizedBox(height: 36),
// 🏷️ 인디케이터 바 (엑셀 일괄 등록) // 📤 [정사각형 박스 2] 엑셀 일괄 등록 드롭존.
Row( Widget _buildExcelBox() {
children: [ return DropTarget(
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), onDragEntered: (_) => setState(() => _isDragging = true),
onDragExited: (_) => setState(() => _isDragging = false), onDragExited: (_) => setState(() => _isDragging = false),
onDragDone: (details) async { onDragDone: (details) async {
@@ -552,71 +497,227 @@ class _TeacherStudentManagementPageState
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
child: Container( child: Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 28), height: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _isDragging ? Colors.orange[50] : Colors.white, color: _isDragging ? Colors.orange[50] : Colors.white,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
border: Border.all( border: Border.all(
color: _isDragging color: _isDragging ? Colors.orange : Colors.grey.shade300,
? Colors.orange
: Colors.grey.shade300,
width: _isDragging ? 2 : 1, width: _isDragging ? 2 : 1,
), ),
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
Icons.upload_file_rounded, Icons.upload_file_rounded,
size: 40, size: 40,
color: _isDragging color: _isDragging ? Colors.orange : Colors.grey[400],
? Colors.orange
: Colors.grey[400],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
_isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택', _isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택',
textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: _isDragging color: _isDragging ? Colors.orange[800] : Colors.black87,
? Colors.orange[800]
: Colors.black87,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'.xlsx · 1행은 머리글, A열=학번 B열=이름 C열=학년(선택)', '.xlsx · 1행은 머리글, A열=학번 B열=이름 C열=학년(선택)',
style: TextStyle( style: TextStyle(fontSize: 12, color: Colors.grey[500]),
fontSize: 12,
color: Colors.grey[500],
),
textAlign: TextAlign.center, 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), const SizedBox(height: 36),
// 🏷️ 인디케이터 바 (전체 학생 목록) // 🏷️ 인디케이터 바 (전체 학생 목록) — 위 두 칸을 합친 폭에 맞춘다.
SizedBox(
width: listWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row( Row(
children: [ children: [
Container( _sectionTitle('전체 학생 목록', color: Colors.blue),
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(), const Spacer(),
IconButton( IconButton(
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
@@ -630,14 +731,18 @@ class _TeacherStudentManagementPageState
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
'학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.', '학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.',
style: TextStyle(color: Colors.black54, fontSize: 12), style: TextStyle(
color: Colors.black54,
fontSize: 12,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_controller.isLoadingStudents _controller.isLoadingStudents
? const Padding( ? const Padding(
padding: EdgeInsets.symmetric(vertical: 40), padding: EdgeInsets.symmetric(vertical: 40),
child: Center(child: CircularProgressIndicator()), child: Center(
child: CircularProgressIndicator(),
),
) )
: _controller.students.isEmpty : _controller.students.isEmpty
? const Padding( ? const Padding(
@@ -649,98 +754,29 @@ class _TeacherStudentManagementPageState
), ),
), ),
) )
: ListView.builder( : GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isWide ? 4 : 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: isWide ? 1.5 : 0.75,
),
itemCount: _controller.students.length, itemCount: _controller.students.length,
itemBuilder: (context, index) { itemBuilder: (context, index) =>
final student = _controller.students[index]; _buildStudentTile(
final String studentId = student['id'].toString(); _controller.students[index],
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,
), ),
), ),
], ],
), ),
), ),
],
); );
}, },
), ),
],
),
), ),
); );
}, },