실시간 출석 현황 데스크탑 화면을 좌석표 스타일로 재구성
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 'package:flutter/material.dart';
|
||||||
import '../function/teacher_attendance_controller.dart';
|
import '../function/teacher_attendance_controller.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
|
import 'launchpad_transition.dart';
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
|
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
|
||||||
@@ -509,51 +510,24 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
|
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
|
||||||
|
// 왼쪽에 요약/필터 사이드바, 오른쪽에 좌석표처럼 촘촘한 학생 칸 그리드를 둬서
|
||||||
|
// 80명이 넘는 인원도 스크롤을 최소화하고 한눈에 볼 수 있게 한다.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
Widget _buildDesktopBody(int total, int checkedIn, int absent) {
|
Widget _buildDesktopBody(int total, int checkedIn, int absent) {
|
||||||
final groups = _controller.studentsByGrade;
|
final students = _controller.filteredStudents;
|
||||||
return Center(
|
return Padding(
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 1100),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(),
|
_buildPermissionBanner(),
|
||||||
_buildFilterChips(),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildDesktopSidebar(total, checkedIn, absent),
|
||||||
|
const SizedBox(width: 20),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -568,70 +542,247 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: groups.isEmpty
|
child: _buildDenseSeatGrid(students),
|
||||||
? const Center(
|
|
||||||
child: Text(
|
|
||||||
'해당하는 학생이 없습니다.',
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: _buildGradeGroupedList(
|
|
||||||
groups,
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧾 왼쪽 요약/필터 사이드바. 인원 수 캡슐을 누르면 그 필터가 바로 적용된다.
|
||||||
|
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) {
|
Widget _sidebarFilterButton() {
|
||||||
return Container(
|
return InkWell(
|
||||||
padding: const EdgeInsets.all(20),
|
onTap: _showGradeFilterMenu,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppPalette.paper,
|
color: AppPalette.paper,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(28),
|
||||||
boxShadow: [
|
border: Border.all(color: AppPalette.sage),
|
||||||
BoxShadow(
|
),
|
||||||
color: Colors.black.withValues(alpha: 0.04),
|
child: Row(
|
||||||
blurRadius: 12,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
offset: const Offset(0, 4),
|
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: [
|
children: [
|
||||||
Container(
|
const Text(
|
||||||
padding: const EdgeInsets.all(12),
|
'학년 나눠보기',
|
||||||
|
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(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.1),
|
color: background,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: border),
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: color, size: 26),
|
child: Column(
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
'[${index + 1}] ${student['studentId']}',
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 13),
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 8, color: Colors.grey[500]),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
Text(
|
||||||
value,
|
'${student['studentName']}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
if (hasViolation)
|
||||||
|
Text(
|
||||||
|
'무단반출',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: color,
|
fontSize: 7.5,
|
||||||
fontSize: 22,
|
color: Colors.red[700],
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user