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, ), ), - ], - ), - ], + ], + ), ), ); }