실시간 출석 현황 데스크탑 화면을 좌석표 스타일로 재구성
80명 이상인 자습실 인원을 스크롤 없이 최대한 한눈에 보기 위해, 데스크탑(교실 컴퓨터) 레이아웃을 왼쪽 요약/필터 사이드바 + 오른쪽 촘촘한 좌석 그리드 구조로 바꿈. 학생 한 명당 칸을 92x62 크기로 작게 줄여 한 화면에 훨씬 많은 인원이 들어가게 했고, 사이드바의 인원 수 캡슐(전체/출석/미출석)을 눌러 바로 필터링할 수 있게 함. 학년 필터는 런치패드 스타일 오버레이 메뉴로 이동. 모바일 카드 리스트 레이아웃은 그대로 유지. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user