// πŸ“‹ μ‹€μ‹œκ°„ μΆœμ„ ν˜„ν™© ν™”λ©΄ (UI μ „μš©). μœ„μ ― λΉŒλ“œ/λ ˆμ΄μ•„μ›ƒ/μŠ€νƒ€μΌλ§Œ λ‹΄λ‹Ήν•˜κ³ , // μ„œλ²„ ν†΅μ‹ Β·μƒνƒœΒ·νŒŒμƒ λ‘œμ§μ€ lib/function/teacher_attendance_controller.dartκ°€ λ‹΄λ‹Ήν•œλ‹€. import 'package:flutter/material.dart'; import '../function/teacher_attendance_controller.dart'; import '../theme/app_palette.dart'; import 'launchpad_transition.dart'; // ----------------------------------------------------------------------------- // πŸ“… [μ„œλΈŒ ν™”λ©΄ 1] μ‹€μ‹œκ°„ μΆœμ„ 확인 λž€ (StudentDashboard μΉ΄λ“œ μŠ€νƒ€μΌ λ¦¬μŠ€νŠΈν™”) // ----------------------------------------------------------------------------- class TeacherAttendancePage extends StatefulWidget { const TeacherAttendancePage({super.key}); @override State createState() => _TeacherAttendancePageState(); } class _TeacherAttendancePageState extends State { final TeacherAttendanceController _controller = TeacherAttendanceController(); @override void initState() { super.initState(); _controller.init(); } @override void dispose() { _controller.dispose(); super.dispose(); } /// 컨트둀러 μ•‘μ…˜μ„ μ‹€ν–‰ν•˜κ³ , κ²°κ³Ό λ©”μ‹œμ§€λ₯Ό μŠ€λ‚΅λ°”λ‘œ 보여쀀닀. Future _runAction(Future Function() action) async { final message = await action(); if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } Future _showAttendanceTimeDialog() async { final String? current = _controller.attendanceTime; final TimeOfDay initial = current != null ? TimeOfDay( hour: int.parse(current.split(':')[0]), minute: int.parse(current.split(':')[1]), ) : const TimeOfDay(hour: 19, minute: 0); final TimeOfDay? picked = await showTimePicker( context: context, initialTime: initial, helpText: 'μžμŠ΅μ‹€ μΆœμ„μ‹œκ°„ μ§€μ •', ); if (picked == null) return; final String formatted = '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}'; await _runAction(() => _controller.setAttendanceTime(formatted)); } void _showTestResetConfirmDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('ν…ŒμŠ€νŠΈμš© ν—ˆμš©μ‹œκ°„ μ΄ˆκΈ°ν™”'), content: const Text( 'ν•˜κ΅ μ²˜λ¦¬λ‚˜ 반좜 ν—ˆμš© μ‹œκ°„ μ„€μ •μœΌλ‘œ 켜져 μžˆλŠ” λͺ¨λ“  ν—ˆμš© μ‹œκ°„λŒ€λ₯Ό μ§€κΈˆ μ¦‰μ‹œ ν•΄μ œν•©λ‹ˆλ‹€.\n' '(λ¬΄λ‹¨λ°˜μΆœ 감지 ν…ŒμŠ€νŠΈν•  λ•Œλ§Œ μ‚¬μš©ν•˜μ„Έμš”)', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('μ·¨μ†Œ'), ), ElevatedButton( onPressed: () { Navigator.pop(context); _runAction(() => _controller.resetTestPermissions()); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.grey[700], foregroundColor: Colors.white, ), child: const Text('μ΄ˆκΈ°ν™”'), ), ], ), ); } void _showAllowDialog(String studentId, String studentName) { final controller = TextEditingController(text: "5"); showDialog( context: context, builder: (context) => AlertDialog( title: Text('$studentName 학생 반좜 ν—ˆμš©'), content: TextField( controller: controller, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'ν—ˆμš© μ‹œκ°„ (λΆ„)', border: OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('μ·¨μ†Œ'), ), ElevatedButton( onPressed: () { final minutes = int.tryParse(controller.text.trim()) ?? 5; Navigator.pop(context); _runAction(() => _controller.allowRemoval(studentId, minutes)); }, child: const Text('ν—ˆμš©ν•˜κΈ°'), ), ], ), ); } void _showPermissionWindowDialog() { final controller = TextEditingController(text: "10"); showDialog( context: context, builder: (context) => AlertDialog( title: const Text('전체 학생 반좜 ν—ˆμš© μ‹œκ°„ μ„€μ •'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'μ‰¬λŠ”μ‹œκ°„μ²˜λŸΌ μ§€κΈˆλΆ€ν„° 일정 μ‹œκ°„ λ™μ•ˆ λͺ¨λ“  ν•™μƒμ˜ λ°˜μΆœμ„ μžλ™μœΌλ‘œ ν—ˆμš©ν•©λ‹ˆλ‹€.', style: TextStyle(fontSize: 13, color: Colors.grey), ), const SizedBox(height: 16), TextField( controller: controller, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'μ§€κΈˆλΆ€ν„° ν—ˆμš© μ‹œκ°„ (λΆ„)', border: OutlineInputBorder(), ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('μ·¨μ†Œ'), ), ElevatedButton( onPressed: () { final minutes = int.tryParse(controller.text.trim()) ?? 10; Navigator.pop(context); _runAction(() => _controller.setPermissionWindow(minutes)); }, child: const Text('μ„€μ •ν•˜κΈ°'), ), ], ), ); } /// 🏫 ν•˜κ΅ 처리: μ‹œκ°„ μž…λ ₯ 없이 λ°”λ‘œ 전체 ν•™μƒμ˜ λ°˜μΆœμ„ (사싀상 λ¬΄κΈ°ν•œ) ν—ˆμš©ν•œλ‹€. void _showDismissalConfirmDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('ν•˜κ΅ 처리'), content: const Text( 'μ§€κΈˆλΆ€ν„° λͺ¨λ“  ν•™μƒμ˜ 반좜이 μžλ™μœΌλ‘œ ν—ˆμš©λ˜λ©°, 더 이상 λ¬΄λ‹¨λ°˜μΆœ κ²½κ³ κ°€ λœ¨μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.\nν•˜κ΅ μ²˜λ¦¬ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('μ·¨μ†Œ'), ), ElevatedButton( onPressed: () { Navigator.pop(context); _runAction(() => _controller.dismissAll()); }, child: const Text('ν•˜κ΅ 처리'), ), ], ), ); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: _controller, builder: (context, _) { final all = _controller.combinedStudentStatus; final int totalCount = all.length; final int checkedInCount = all .where((s) => s['isAttendanceComplete'] == true) .length; final int absentCount = totalCount - checkedInCount; return Scaffold( backgroundColor: AppPalette.mist, // πŸͺΆ κ²€μ • λ°°λ„ˆ λŒ€μ‹ , 제λͺ© μ•Œμ•½μ€ "전체 학생 수" λ²„νŠΌ λ°”λ‘œ μœ„(μ‚¬μ΄λ“œλ°”/λͺ¨λ°”일 μš”μ•½ μœ„)에 λ‘”λ‹€. // λ‚˜λ¨Έμ§€ κΈ°λŠ₯은 μ „λΆ€ "μžμŠ΅μ‹€ μ‹œκ°„ μ„€μ • 메뉴"(λŸ°μΉ˜νŒ¨λ“œ μŠ€νƒ€μΌ μ˜€λ²„λ ˆμ΄) ν•˜λ‚˜λ‘œ λͺ¨μ€λ‹€. // ν°μ—μ„œλŠ” μ‚¬μ΄λ“œλ°”κ°€ μ—†μœΌλ‹ˆ μ—¬κΈ° μ•„μ΄μ½˜μœΌλ‘œλ„ 같은 메뉴λ₯Ό μ—΄ 수 있게 λ‘”λ‹€. appBar: AppBar( backgroundColor: Colors.transparent, foregroundColor: AppPalette.ink, elevation: 0, actions: [ IconButton( icon: const Icon(Icons.more_horiz_rounded), tooltip: 'μžμŠ΅μ‹€ μ‹œκ°„ μ„€μ • 메뉴', onPressed: _showTimeSettingsMenu, ), const SizedBox(width: 4), ], ), body: _controller.isLoading ? const Center(child: CircularProgressIndicator()) : LayoutBuilder( builder: (context, constraints) { final bool isWide = constraints.maxWidth >= 800; return isWide ? _buildDesktopBody( totalCount, checkedInCount, absentCount, ) : _buildMobileBody( totalCount, checkedInCount, absentCount, ); }, ), ); }, ); } // πŸͺΆ "μ‹€μ‹œκ°„ μΆœμ„ ν˜„ν™©" 제λͺ© μ•Œμ•½. μ‚¬μ΄λ“œλ°”/λͺ¨λ°”일 μš”μ•½ λ°”λ‘œ μœ„μ— λ‘”λ‹€. Widget _buildTitlePill() { return Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), decoration: BoxDecoration( color: AppPalette.ink, borderRadius: BorderRadius.circular(999), ), child: const Text( 'μ‹€μ‹œκ°„ μΆœμ„ ν˜„ν™©', textAlign: TextAlign.center, style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15, ), ), ); } // ----------------------------------------------------------------------- // πŸ“± λͺ¨λ°”일 λ ˆμ΄μ•„μ›ƒ (κΈ°μ‘΄ μΉ΄λ“œ 리슀트) // ----------------------------------------------------------------------- Widget _buildMobileBody(int total, int checkedIn, int absent) { final groups = _controller.studentsByGrade; return Column( children: [ const SizedBox(height: 12), _buildTitlePill(), const SizedBox(height: 12), _buildSummaryCards(total, checkedIn, absent), _buildPermissionBanner(), _buildFilterChips(), Expanded( child: groups.isEmpty ? const Center( child: Text( 'ν•΄λ‹Ήν•˜λŠ” 학생이 μ—†μŠ΅λ‹ˆλ‹€.', style: TextStyle(color: Colors.grey), ), ) : _buildGradeGroupedList( groups, padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), ), ), ], ); } /// 🏫 ν•™λ…„λ³„λ‘œ μ„Ήμ…˜ 제λͺ©μ„ λΆ™μ—¬ κ·Έλ¦¬λ“œλ₯Ό 이어뢙인 λͺ©λ‘. λͺ¨λ°”일/λ°μŠ€ν¬ν†± 곡용. Widget _buildGradeGroupedList( List>>> groups, { required EdgeInsets padding, }) { return ListView.builder( padding: padding, itemCount: groups.length, itemBuilder: (context, index) { final group = groups[index]; return Padding( padding: EdgeInsets.only(bottom: index == groups.length - 1 ? 0 : 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 4, height: 16, decoration: BoxDecoration( color: AppPalette.ink, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 8), Text( group.key, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: AppPalette.ink, ), ), const SizedBox(width: 6), Text( '${group.value.length}λͺ…', style: TextStyle(fontSize: 13, color: Colors.grey[500]), ), ], ), const SizedBox(height: 10), GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 220, mainAxisSpacing: 12, crossAxisSpacing: 12, mainAxisExtent: 148, ), itemCount: group.value.length, itemBuilder: (context, i) => _buildStudentTile(group.value[i]), ), ], ), ); }, ); } /// πŸ€„ 학생 ν•œ λͺ…을 "ν•œλˆˆμ— 보기" νƒ€μΌλ‘œ κ·Έλ¦°λ‹€. λͺ¨λ°”일/λ°μŠ€ν¬ν†± κ·Έλ¦¬λ“œμ—μ„œ 곡용으둜 μ“΄λ‹€. Widget _buildStudentTile(Map student) { final bool isCheckedIn = student['isCheckedIn']; final bool hasViolation = student['hasActiveViolation'] == true; final bool isPending = student['attendanceStatus'] == 'PENDING'; final bool isComplete = student['attendanceStatus'] == 'COMPLETE'; final Color statusColor = hasViolation ? Colors.red : (isPending ? Colors.orange : (isComplete ? Colors.blue : Colors.red)); final String statusLine = hasViolation ? 'λ¬΄λ‹¨λ°˜μΆœ (${student['violationTime']})' : (isPending ? 'λ―Έμ™„λ£Œ (${student['checkInTime']})' : (isComplete ? '${student['checkInTime']}' : (student['hasEverCheckedIn'] == true ? '미제좜' : '미등둝'))); return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: hasViolation ? Colors.red[50] : AppPalette.paper, borderRadius: BorderRadius.circular(20), border: Border.all( color: hasViolation ? Colors.red[300]! : AppPalette.sage, width: hasViolation ? 1.5 : 1, ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.03), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon( hasViolation ? Icons.warning_amber_rounded : (isPending ? Icons.hourglass_bottom_rounded : (isComplete ? Icons.check_circle_rounded : Icons.error_rounded)), color: statusColor, size: 18, ), ), const Spacer(), if (isCheckedIn && student['pocketNumber'] != null) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 3, ), decoration: BoxDecoration( color: AppPalette.linen, borderRadius: BorderRadius.circular(20), border: Border.all(color: AppPalette.sage), ), child: Text( student['pocketNumber'], style: const TextStyle( fontWeight: FontWeight.bold, color: AppPalette.ink, fontSize: 11, ), ), ), ], ), const SizedBox(height: 8), Text( '${student['studentName']}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), ), Text( 'ν•™λ²ˆ ${student['studentId']}', style: TextStyle(color: Colors.grey[500], fontSize: 11), ), const SizedBox(height: 4), Text( statusLine, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( color: hasViolation ? Colors.red[700] : (isPending ? Colors.orange[800] : (isComplete ? Colors.grey[600] : Colors.red[400])), fontSize: 11.5, fontWeight: hasViolation ? FontWeight.bold : FontWeight.normal, ), ), if (hasViolation) ...[ const Spacer(), SizedBox( width: double.infinity, height: 30, child: ElevatedButton( onPressed: () => _showAllowDialog( student['studentId'], student['studentName'], ), style: ElevatedButton.styleFrom( backgroundColor: Colors.red[600], foregroundColor: Colors.white, padding: EdgeInsets.zero, ), child: const Text('반좜 ν—ˆμš©', style: TextStyle(fontSize: 12)), ), ), ], ], ), ); } // ----------------------------------------------------------------------- // πŸ–₯️ λ°μŠ€ν¬ν†± λ ˆμ΄μ•„μ›ƒ (μ„ μƒλ‹˜μ΄ ꡐ싀 컴퓨터 λΈŒλΌμš°μ €λ‘œ μ ‘μ†ν–ˆμ„ λ•Œ) // μ™Όμͺ½μ— μš”μ•½/ν•„ν„° μ‚¬μ΄λ“œλ°”, 였λ₯Έμͺ½μ— μ’Œμ„ν‘œμ²˜λŸΌ μ΄˜μ΄˜ν•œ 학생 μΉΈ κ·Έλ¦¬λ“œλ₯Ό λ‘¬μ„œ // 80λͺ…이 λ„˜λŠ” 인원도 μŠ€ν¬λ‘€μ„ μ΅œμ†Œν™”ν•˜κ³  ν•œλˆˆμ— λ³Ό 수 있게 ν•œλ‹€. // ----------------------------------------------------------------------- Widget _buildDesktopBody(int total, int checkedIn, int absent) { 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: [ Center(child: _buildTitlePill()), const SizedBox(height: 16), _sidebarStatPill('전체 학생 수', total, 'ALL'), const SizedBox(height: 10), _sidebarStatPill('μΆœμ„ 학생 수', checkedIn, 'CHECKED_IN'), const SizedBox(height: 10), _sidebarStatPill('λ―ΈμΆœμ„ ν•™μƒμˆ˜', absent, 'ABSENT'), const SizedBox(height: 20), _sidebarFilterButton(), const SizedBox(height: 10), _sidebarTimeSettingsButton(), ], ), ); } Widget _sidebarTimeSettingsButton() { return InkWell( onTap: _showTimeSettingsMenu, 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.access_time_rounded, size: 18, color: AppPalette.ink, ), const SizedBox(width: 8), const Flexible( child: Text( 'μžμŠ΅μ‹€ μ‹œκ°„ μ„€μ • 메뉴', overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.bold, color: AppPalette.ink, ), ), ), ], ), ), ); } 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 _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, ), const SizedBox(width: 8), Flexible( child: Text( _gradeFilterLabel(), overflow: TextOverflow.ellipsis, style: const TextStyle( fontWeight: FontWeight.bold, color: AppPalette.ink, ), ), ), ], ), ), ); } 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), ); } // πŸš€ "μžμŠ΅μ‹€ μ‹œκ°„ μ„€μ • 메뉴" β€” μƒˆλ‘œκ³ μΉ¨/μΆœμ„μ‹œκ°„/λ°˜μΆœν—ˆμš©/ν•˜κ΅/ν…ŒμŠ€νŠΈλ¦¬μ…‹μ„ // μ „λΆ€ μ—¬κΈ° ν•˜λ‚˜λ‘œ λͺ¨μ•„μ„œ, 메인 λŒ€μ‹œλ³΄λ“œμ˜ λ‘œκ·Έμ•„μ›ƒ/μ„€μ • 메뉴와 λ˜‘κ°™μ€ // λŸ°μΉ˜νŒ¨λ“œ μŠ€νƒ€μΌ(λ°°κ²½ λΈ”λŸ¬ μœ μ§€) μ˜€λ²„λ ˆμ΄λ‘œ λ„μš΄λ‹€. Future _showTimeSettingsMenu() async { final action = await showLaunchpadMenu( context: context, builder: (context) => Center( child: _TimeSettingsMenuCard( isRefreshing: _controller.isRefreshing, attendanceTimeLabel: _controller.attendanceTime != null ? 'μΆœμ„μ‹œκ°„ ${_controller.attendanceTime}' : 'μžμŠ΅μ‹€ μΆœμ„μ‹œκ°„ μ„€μ •', onPick: (value) => Navigator.pop(context, value), ), ), ); if (!mounted || action == null) return; switch (action) { case 'refresh': _controller.manualRefresh(); break; case 'attendance_time': _showAttendanceTimeDialog(); break; case 'permission_window': _showPermissionWindowDialog(); break; case 'dismissal': _showDismissalConfirmDialog(); break; case 'test_reset': _showTestResetConfirmDialog(); break; } } // πŸͺ‘ μ’Œμ„ν‘œμ²˜λŸΌ μ΄˜μ΄˜ν•˜κ²Œ 학생 ν•œ λͺ…씩을 μž‘μ€ 칸에 λ‹΄λŠ” κ·Έλ¦¬λ“œ. 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( 'λ¬΄λ‹¨λ°˜μΆœ', style: TextStyle( fontSize: 7.5, color: Colors.red[700], fontWeight: FontWeight.bold, ), ), ], ), ), ); } /// πŸ“Š 상단 μš”μ•½ μΉ΄λ“œ λ·° (전체 / μΆœμ„ μ™„λ£Œ / λ―ΈμΆœμ„) Widget _buildSummaryCards(int total, int checkedIn, int absent) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), color: AppPalette.ink, child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _summaryCard("전체", "$totalλͺ…", Colors.white70), _summaryCard("μΆœμ„ μ™„λ£Œ", "$checkedInλͺ…", Colors.greenAccent), _summaryCard("λ―ΈμΆœμ„", "$absentλͺ…", Colors.redAccent), ], ), ); } Widget _summaryCard(String title, String count, Color color) { return Column( children: [ Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)), const SizedBox(height: 4), Text( count, style: TextStyle( color: color, fontSize: 20, fontWeight: FontWeight.bold, ), ), ], ); } /// πŸ”“ ν•˜κ΅(12μ‹œκ°„)/반좜 ν—ˆμš© μ‹œκ°„ μ„€μ •μœΌλ‘œ μ§€κΈˆ 전체 반좜이 ν—ˆμš© 쀑이면 λˆˆμ— λ„κ²Œ λ°°λ„ˆλ‘œ μ•Œλ €μ€€λ‹€. /// (ν—ˆμš© 쀑일 땐 λ¬΄λ‹¨λ°˜μΆœμ„ 감지해도 λŒ€μ‹œλ³΄λ“œμ— λœ¨μ§€ μ•ŠκΈ° λ•Œλ¬Έμ—, μ™œ μ•ˆ λœ¨λŠ”μ§€ ν—·κ°ˆλ¦¬μ§€ μ•Šκ²Œ ν•˜κΈ° μœ„ν•¨) Widget _buildPermissionBanner() { final String? until = _controller.globalPermissionUntil; if (until == null) return const SizedBox.shrink(); return Container( width: double.infinity, margin: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( color: Colors.amber[100], borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.amber[400]!), ), child: Row( children: [ Icon(Icons.lock_open_rounded, color: Colors.amber[800], size: 20), const SizedBox(width: 8), Expanded( child: Text( 'μ§€κΈˆ 전체 반좜 ν—ˆμš© μ€‘μž…λ‹ˆλ‹€ ($until κΉŒμ§€) β€” 이 μ‹œκ°„ λ™μ•ˆμ€ λ¬΄λ‹¨λ°˜μΆœ κ²½κ³ κ°€ λœ¨μ§€ μ•Šμ•„μš”.', style: TextStyle( color: Colors.amber[900], fontWeight: FontWeight.bold, fontSize: 12.5, ), ), ), ], ), ); } /// πŸ”˜ ν•„ν„° μΉ©λ²„νŠΌ (전체 / μΆœμ„μž / λ―ΈμΆœμ„μž) Widget _buildFilterChips() { return Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 8, runSpacing: 8, children: [ FilterChip( label: const Text("전체"), selected: _controller.filterType == "ALL", onSelected: (_) => _controller.setFilter("ALL"), ), FilterChip( label: const Text("μΆœμ„μž"), selected: _controller.filterType == "CHECKED_IN", onSelected: (_) => _controller.setFilter("CHECKED_IN"), ), FilterChip( label: const Text("λ―ΈμΆœμ„μž"), selected: _controller.filterType == "ABSENT", onSelected: (_) => _controller.setFilter("ABSENT"), ), ], ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ ChoiceChip( label: const Text("ν•™λ…„ 전체"), selected: _controller.gradeFilter == "ALL", onSelected: (_) => _controller.setGradeFilter("ALL"), ), ChoiceChip( label: const Text("1ν•™λ…„"), selected: _controller.gradeFilter == "1", onSelected: (_) => _controller.setGradeFilter("1"), ), ChoiceChip( label: const Text("2ν•™λ…„"), selected: _controller.gradeFilter == "2", onSelected: (_) => _controller.setGradeFilter("2"), ), ChoiceChip( label: const Text("3ν•™λ…„"), selected: _controller.gradeFilter == "3", onSelected: (_) => _controller.setGradeFilter("3"), ), ], ), ], ), ); } } // πŸš€ "μžμŠ΅μ‹€ μ‹œκ°„ μ„€μ • 메뉴" μΉ΄λ“œ β€” 메인 λŒ€μ‹œλ³΄λ“œ λ‘œκ·Έμ•„μ›ƒ/μ„€μ • 메뉴와 같은 ν†€μœΌλ‘œ 톡일. class _TimeSettingsMenuCard extends StatelessWidget { final bool isRefreshing; final String attendanceTimeLabel; final ValueChanged onPick; const _TimeSettingsMenuCard({ required this.isRefreshing, required this.attendanceTimeLabel, required this.onPick, }); @override Widget build(BuildContext context) { return Material( color: AppPalette.paper, elevation: 8, shadowColor: Colors.black.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(20), child: IntrinsicWidth( child: ConstrainedBox( constraints: const BoxConstraints(minWidth: 220), child: Column( mainAxisSize: MainAxisSize.min, children: [ _TimeMenuRow( icon: isRefreshing ? null : Icons.refresh_rounded, label: 'μƒˆλ‘œκ³ μΉ¨', onTap: () => onPick('refresh'), ), const Divider(height: 1, color: AppPalette.sage), _TimeMenuRow( icon: Icons.access_time_rounded, label: attendanceTimeLabel, onTap: () => onPick('attendance_time'), ), const Divider(height: 1, color: AppPalette.sage), _TimeMenuRow( icon: Icons.timer_outlined, label: '반좜 ν—ˆμš© μ‹œκ°„ μ„€μ •', onTap: () => onPick('permission_window'), ), const Divider(height: 1, color: AppPalette.sage), _TimeMenuRow( icon: Icons.school_rounded, label: 'ν•˜κ΅ 처리', onTap: () => onPick('dismissal'), ), const Divider(height: 1, color: AppPalette.sage), _TimeMenuRow( icon: Icons.bug_report_outlined, label: 'ν…ŒμŠ€νŠΈμš©: ν—ˆμš©μ‹œκ°„ μ΄ˆκΈ°ν™”', dim: true, onTap: () => onPick('test_reset'), ), ], ), ), ), ); } } class _TimeMenuRow extends StatelessWidget { final IconData? icon; final String label; final VoidCallback onTap; final bool dim; const _TimeMenuRow({ required this.icon, required this.label, required this.onTap, this.dim = false, }); @override Widget build(BuildContext context) { return InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), child: Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 20, height: 20, child: icon == null ? const CircularProgressIndicator(strokeWidth: 2) : Icon( icon, size: 20, color: dim ? Colors.grey : AppPalette.ink, ), ), const SizedBox(width: 12), Flexible( child: Text( label, style: TextStyle( fontWeight: FontWeight.w600, color: dim ? Colors.grey[600] : AppPalette.ink, ), ), ), ], ), ), ); } }