학생 계정 관리 화면 레이아웃 재구성 (정사각형 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
+370 -334
View File
@@ -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<int>(
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<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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder( return ListenableBuilder(
@@ -390,356 +652,130 @@ class _TeacherStudentManagementPageState
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.start, builder: (context, constraints) {
children: [ // 🖥️ 웹(넓은 화면)은 계정 추가/엑셀 등록을 정사각형 2칸으로 나란히, 폰은 기존처럼 세로로 쌓는다.
// 🏷️ 인디케이터 바 1 (추가 메뉴) final bool isWide = constraints.maxWidth >= 800;
Row( final double squareSize = (constraints.maxWidth - 20) / 2;
final double listWidth = isWide
? squareSize * 2 + 20
: constraints.maxWidth;
final Widget addBoxColumn = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( _sectionTitle('신규 학생 계정 추가'),
width: 4, const SizedBox(height: 16),
height: 16, SizedBox(
decoration: BoxDecoration( width: isWide ? squareSize : double.infinity,
color: Colors.orange, height: isWide ? squareSize : null,
borderRadius: BorderRadius.circular(2), child: _buildAddAccountBox(),
),
),
const SizedBox(width: 8),
const Text(
'신규 학생 계정 추가',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
), ),
], ],
), );
const SizedBox(height: 16),
// 📝 학생 추가 컨테이너 폼 final Widget excelBoxColumn = Column(
// 🖥️ 웹 넓은 화면에서 폼이 지나치게 길어지지 않도록 현재 폭의 2/3로 제한한다. crossAxisAlignment: CrossAxisAlignment.start,
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<int>(
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: [ children: [
Container( _sectionTitle('엑셀로 한번에 추가 (신입생 등)'),
width: 4, const SizedBox(height: 16),
height: 16, SizedBox(
decoration: BoxDecoration( width: isWide ? squareSize : double.infinity,
color: Colors.orange, height: isWide ? squareSize : 240,
borderRadius: BorderRadius.circular(2), child: _buildExcelBox(),
),
),
const SizedBox(width: 8),
const Text(
'엑셀로 한번에 추가 (신입생 등)',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
), ),
], ],
), );
const SizedBox(height: 16),
DropTarget( return Column(
onDragEntered: (_) => setState(() => _isDragging = true), crossAxisAlignment: CrossAxisAlignment.start,
onDragExited: (_) => setState(() => _isDragging = false), children: [
onDragDone: (details) async { isWide
setState(() => _isDragging = false); ? Row(
if (details.files.isEmpty) return; crossAxisAlignment: CrossAxisAlignment.start,
final bytes = await details.files.first.readAsBytes(); children: [
if (!mounted) return; addBoxColumn,
await _handleExcelBytes(bytes); const SizedBox(width: 20),
}, excelBoxColumn,
child: InkWell( ],
onTap: _pickExcelFile, )
borderRadius: BorderRadius.circular(24), : Column(
child: Container( crossAxisAlignment: CrossAxisAlignment.start,
width: double.infinity, children: [
padding: const EdgeInsets.symmetric(vertical: 28), addBoxColumn,
decoration: BoxDecoration( const SizedBox(height: 24),
color: _isDragging ? Colors.orange[50] : Colors.white, excelBoxColumn,
borderRadius: BorderRadius.circular(24), ],
border: Border.all( ),
color: _isDragging const SizedBox(height: 36),
? Colors.orange
: Colors.grey.shade300, // 🏷️ 인디케이터 바 (전체 학생 목록) — 위 두 칸을 합친 폭에 맞춘다.
width: _isDragging ? 2 : 1, SizedBox(
), width: listWidth,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Row(
Icons.upload_file_rounded, children: [
size: 40, _sectionTitle('전체 학생 목록', color: Colors.blue),
color: _isDragging const Spacer(),
? Colors.orange IconButton(
: Colors.grey[400], 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), const SizedBox(height: 12),
Text( _controller.isLoadingStudents
_isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택', ? const Padding(
style: TextStyle( padding: EdgeInsets.symmetric(vertical: 40),
fontWeight: FontWeight.bold, child: Center(
color: _isDragging child: CircularProgressIndicator(),
? Colors.orange[800] ),
: Colors.black87, )
), : _controller.students.isEmpty
), ? const Padding(
const SizedBox(height: 4), padding: EdgeInsets.symmetric(vertical: 24),
Text( child: Center(
'.xlsx · 1행은 머리글, A열=학번 B열=이름 C열=학년(선택)', child: Text(
style: TextStyle( '가입된 학생 계정이 없습니다.',
fontSize: 12, style: TextStyle(color: Colors.grey),
color: Colors.grey[500], ),
), ),
textAlign: TextAlign.center, )
), : 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,
),
),
],
),
),
);
},
),
],
), ),
), ),
); );