실시간 출석 현황 데스크탑 화면을 좌석표 스타일로 재구성

80명 이상인 자습실 인원을 스크롤 없이 최대한 한눈에 보기 위해,
데스크탑(교실 컴퓨터) 레이아웃을 왼쪽 요약/필터 사이드바 + 오른쪽
촘촘한 좌석 그리드 구조로 바꿈. 학생 한 명당 칸을 92x62 크기로 작게
줄여 한 화면에 훨씬 많은 인원이 들어가게 했고, 사이드바의 인원 수
캡슐(전체/출석/미출석)을 눌러 바로 필터링할 수 있게 함. 학년 필터는
런치패드 스타일 오버레이 메뉴로 이동. 모바일 카드 리스트 레이아웃은
그대로 유지.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 09:01:51 +09:00
co-authored by Claude Sonnet 5
parent 2fb24f5e46
commit 2d0768da73
+224 -73
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart';
import '../function/teacher_attendance_controller.dart';
import '../theme/app_palette.dart';
import 'launchpad_transition.dart';
// -----------------------------------------------------------------------------
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
@@ -509,51 +510,24 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
// -----------------------------------------------------------------------
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
// 왼쪽에 요약/필터 사이드바, 오른쪽에 좌석표처럼 촘촘한 학생 칸 그리드를 둬서
// 80명이 넘는 인원도 스크롤을 최소화하고 한눈에 볼 수 있게 한다.
// -----------------------------------------------------------------------
Widget _buildDesktopBody(int total, int checkedIn, int absent) {
final groups = _controller.studentsByGrade;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100),
child: Padding(
final students = _controller.filteredStudents;
return Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _statTile(
"전체 학생",
"$total명",
Icons.groups_rounded,
Colors.blueGrey,
),
),
const SizedBox(width: 16),
Expanded(
child: _statTile(
"출석 완료",
"$checkedIn명",
Icons.check_circle_rounded,
Colors.green,
),
),
const SizedBox(width: 16),
Expanded(
child: _statTile(
"미출석",
"$absent명",
Icons.error_rounded,
Colors.red,
),
),
],
),
const SizedBox(height: 20),
_buildPermissionBanner(),
_buildFilterChips(),
const SizedBox(height: 12),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDesktopSidebar(total, checkedIn, absent),
const SizedBox(width: 20),
Expanded(
child: Container(
width: double.infinity,
@@ -568,70 +542,247 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
),
],
),
child: groups.isEmpty
? const Center(
child: Text(
'해당하는 학생이 없습니다.',
style: TextStyle(color: Colors.grey),
),
)
: _buildGradeGroupedList(
groups,
padding: const EdgeInsets.all(20),
),
child: _buildDenseSeatGrid(students),
),
),
],
),
),
],
),
);
}
// 🧾 왼쪽 요약/필터 사이드바. 인원 수 캡슐을 누르면 그 필터가 바로 적용된다.
Widget _buildDesktopSidebar(int total, int checkedIn, int absent) {
return SizedBox(
width: 200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_sidebarStatPill('전체 학생 수', total, 'ALL'),
const SizedBox(height: 10),
_sidebarStatPill('출석 학생 수', checkedIn, 'CHECKED_IN'),
const SizedBox(height: 10),
_sidebarStatPill('미출석 학생수', absent, 'ABSENT'),
const SizedBox(height: 20),
_sidebarFilterButton(),
],
),
);
}
Widget _sidebarStatPill(String label, int count, String filterValue) {
final bool selected = _controller.filterType == filterValue;
return InkWell(
onTap: () => _controller.setFilter(filterValue),
borderRadius: BorderRadius.circular(28),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
decoration: BoxDecoration(
color: selected ? AppPalette.ink : AppPalette.paper,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: selected ? AppPalette.ink : AppPalette.sage,
),
),
child: Column(
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: selected ? Colors.white70 : Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
'$count명',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: selected ? Colors.white : AppPalette.ink,
),
),
],
),
),
);
}
Widget _statTile(String label, String value, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.all(20),
Widget _sidebarFilterButton() {
return InkWell(
onTap: _showGradeFilterMenu,
borderRadius: BorderRadius.circular(28),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
decoration: BoxDecoration(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 4),
borderRadius: BorderRadius.circular(28),
border: Border.all(color: AppPalette.sage),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.filter_list_rounded,
size: 18,
color: AppPalette.ink,
),
const SizedBox(width: 8),
Flexible(
child: Text(
_gradeFilterLabel(),
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppPalette.ink,
),
),
),
],
),
child: Row(
),
);
}
String _gradeFilterLabel() {
switch (_controller.gradeFilter) {
case '1':
return '1학년만';
case '2':
return '2학년만';
case '3':
return '3학년만';
default:
return '학생 나눠보기';
}
}
// 🚀 "학생 나눠보기" 버튼 — 학년 필터를 런치패드 스타일 메뉴로 고른다.
Future<void> _showGradeFilterMenu() async {
final grade = await showLaunchpadMenu<String>(
context: context,
builder: (context) => Center(
child: Material(
color: AppPalette.paper,
borderRadius: BorderRadius.circular(20),
elevation: 8,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(12),
const Text(
'학년 나눠보기',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_gradeChoice(context, 'ALL', '전체'),
_gradeChoice(context, '1', '1학년'),
_gradeChoice(context, '2', '2학년'),
_gradeChoice(context, '3', '3학년'),
],
),
],
),
),
),
),
);
if (grade != null) _controller.setGradeFilter(grade);
}
Widget _gradeChoice(BuildContext dialogContext, String value, String label) {
return ChoiceChip(
label: Text(label),
selected: _controller.gradeFilter == value,
onSelected: (_) => Navigator.pop(dialogContext, value),
);
}
// 🪑 좌석표처럼 촘촘하게 학생 한 명씩을 작은 칸에 담는 그리드.
Widget _buildDenseSeatGrid(List<Map<String, dynamic>> students) {
if (students.isEmpty) {
return const Center(
child: Text('해당하는 학생이 없습니다.', style: TextStyle(color: Colors.grey)),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 92,
mainAxisExtent: 62,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
),
itemCount: students.length,
itemBuilder: (context, index) => _buildSeatCell(students[index], index),
);
}
Widget _buildSeatCell(Map<String, dynamic> student, int index) {
final bool hasViolation = student['hasActiveViolation'] == true;
final bool isPending = student['attendanceStatus'] == 'PENDING';
final bool isComplete = student['attendanceStatus'] == 'COMPLETE';
final Color background = hasViolation
? Colors.red[50]!
: isComplete
? Colors.blue[50]!
: isPending
? Colors.orange[50]!
: AppPalette.paper;
final Color border = hasViolation
? Colors.red[300]!
: isComplete
? Colors.blue[200]!
: isPending
? Colors.orange[200]!
: AppPalette.sage;
return InkWell(
onTap: hasViolation
? () => _showAllowDialog(student['studentId'], student['studentName'])
: null,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
color: background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: border),
),
child: Icon(icon, color: color, size: 26),
),
const SizedBox(width: 14),
Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
style: TextStyle(color: Colors.grey[600], fontSize: 13),
'[${index + 1}] ${student['studentId']}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 8, color: Colors.grey[500]),
),
const SizedBox(height: 2),
Text(
value,
'${student['studentName']}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
if (hasViolation)
Text(
'무단반출',
style: TextStyle(
color: color,
fontSize: 22,
fontSize: 7.5,
color: Colors.red[700],
fontWeight: FontWeight.bold,
),
),
],
),
],
),
);
}