From 2d0768da73214d86cf9ca10743ba8d8c7872bcb7 Mon Sep 17 00:00:00 2001 From: sihoo Date: Tue, 8 Sep 2026 09:01:51 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=B6=9C?= =?UTF-8?q?=EC=84=9D=20=ED=98=84=ED=99=A9=20=EB=8D=B0=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=ED=83=91=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=A2=8C=EC=84=9D?= =?UTF-8?q?=ED=91=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 80명 이상인 자습실 인원을 스크롤 없이 최대한 한눈에 보기 위해, 데스크탑(교실 컴퓨터) 레이아웃을 왼쪽 요약/필터 사이드바 + 오른쪽 촘촘한 좌석 그리드 구조로 바꿈. 학생 한 명당 칸을 92x62 크기로 작게 줄여 한 화면에 훨씬 많은 인원이 들어가게 했고, 사이드바의 인원 수 캡슐(전체/출석/미출석)을 눌러 바로 필터링할 수 있게 함. 학년 필터는 런치패드 스타일 오버레이 메뉴로 이동. 모바일 카드 리스트 레이아웃은 그대로 유지. Co-Authored-By: Claude Sonnet 5 --- lib/ui/teacher_attendance_page.dart | 363 ++++++++++++++++++++-------- 1 file changed, 257 insertions(+), 106 deletions(-) diff --git a/lib/ui/teacher_attendance_page.dart b/lib/ui/teacher_attendance_page.dart index 23c2e7f..81375c4 100644 --- a/lib/ui/teacher_attendance_page.dart +++ b/lib/ui/teacher_attendance_page.dart @@ -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,129 +510,279 @@ class _TeacherAttendancePageState extends State { // ----------------------------------------------------------------------- // 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때) + // 왼쪽에 요약/필터 사이드바, 오른쪽에 좌석표처럼 촘촘한 학생 칸 그리드를 둬서 + // 80명이 넘는 인원도 스크롤을 최소화하고 한눈에 볼 수 있게 한다. // ----------------------------------------------------------------------- Widget _buildDesktopBody(int total, int checkedIn, int absent) { - final groups = _controller.studentsByGrade; - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1100), - child: 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: Container( - width: double.infinity, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 16, - offset: const Offset(0, 4), - ), - ], - ), - child: groups.isEmpty - ? const Center( - child: Text( - '해당하는 학생이 없습니다.', - style: TextStyle(color: Colors.grey), - ), - ) - : _buildGradeGroupedList( - groups, - padding: const EdgeInsets.all(20), + final students = _controller.filteredStudents; + return Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPermissionBanner(), + 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, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 16, + offset: const Offset(0, 4), ), + ], + ), + 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), - 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), - ), - ], - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), + 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(28), + border: Border.all(color: AppPalette.sage), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.filter_list_rounded, + size: 18, + color: AppPalette.ink, ), - child: Icon(icon, color: color, size: 26), - ), - const SizedBox(width: 14), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle(color: Colors.grey[600], fontSize: 13), + const SizedBox(width: 8), + Flexible( + child: Text( + _gradeFilterLabel(), + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), ), - const SizedBox(height: 2), + ), + ], + ), + ), + ); + } + + String _gradeFilterLabel() { + switch (_controller.gradeFilter) { + case '1': + return '1학년만'; + case '2': + return '2학년만'; + case '3': + return '3학년만'; + default: + return '학생 나눠보기'; + } + } + + // 🚀 "학생 나눠보기" 버튼 — 학년 필터를 런치패드 스타일 메뉴로 고른다. + Future _showGradeFilterMenu() async { + final grade = await showLaunchpadMenu( + 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: [ + 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> 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 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: background, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '[${index + 1}] ${student['studentId']}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 8, color: Colors.grey[500]), + ), + Text( + '${student['studentName']}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + ), + if (hasViolation) Text( - value, + '무단반출', style: TextStyle( - color: color, - fontSize: 22, + fontSize: 7.5, + color: Colors.red[700], fontWeight: FontWeight.bold, ), ), - ], - ), - ], + ], + ), ), ); }