// πŸ“‹ μ‹€μ‹œκ°„ μΆœμ„ ν˜„ν™© ν™”λ©΄ (UI μ „μš©). μœ„μ ― λΉŒλ“œ/λ ˆμ΄μ•„μ›ƒ/μŠ€νƒ€μΌλ§Œ λ‹΄λ‹Ήν•˜κ³ , // μ„œλ²„ ν†΅μ‹ Β·μƒνƒœΒ·νŒŒμƒ λ‘œμ§μ€ lib/function/teacher_attendance_controller.dartκ°€ λ‹΄λ‹Ήν•œλ‹€. import 'package:flutter/material.dart'; import '../function/teacher_attendance_controller.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: Colors.grey[100], appBar: AppBar( title: const Text( 'πŸ“‹ μ‹€μ‹œκ°„ μΆœμ„ ν˜„ν™©', style: TextStyle(fontWeight: FontWeight.bold), ), backgroundColor: Colors.blue, foregroundColor: Colors.white, elevation: 0, actions: [ IconButton( onPressed: (_controller.isLoading || _controller.isRefreshing) ? null : _controller.manualRefresh, icon: _controller.isRefreshing ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Icon(Icons.refresh_rounded), tooltip: 'μƒˆλ‘œκ³ μΉ¨', ), TextButton.icon( onPressed: _showAttendanceTimeDialog, icon: const Icon(Icons.access_time_rounded, color: Colors.white), label: Text( _controller.attendanceTime != null ? 'μΆœμ„μ‹œκ°„ ${_controller.attendanceTime}' : 'μžμŠ΅μ‹€ μΆœμ„μ‹œκ°„ μ„€μ •', style: const TextStyle(color: Colors.white), ), ), TextButton.icon( onPressed: _showPermissionWindowDialog, icon: const Icon(Icons.timer_outlined, color: Colors.white), label: const Text( '반좜 ν—ˆμš© μ‹œκ°„ μ„€μ •', style: TextStyle(color: Colors.white), ), ), TextButton.icon( onPressed: _showDismissalConfirmDialog, icon: const Icon(Icons.school_rounded, color: Colors.white), label: const Text('ν•˜κ΅', style: TextStyle(color: Colors.white)), ), IconButton( onPressed: _showTestResetConfirmDialog, icon: const Icon( Icons.bug_report_outlined, color: Colors.white70, ), tooltip: 'πŸ§ͺ ν…ŒμŠ€νŠΈμš©: ν—ˆμš©μ‹œκ°„ μ΄ˆκΈ°ν™”', ), const SizedBox(width: 8), ], ), 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 _buildMobileBody(int total, int checkedIn, int absent) { final filteredStudents = _controller.filteredStudents; return Column( children: [ _buildSummaryCards(total, checkedIn, absent), _buildPermissionBanner(), _buildFilterChips(), Expanded( child: filteredStudents.isEmpty ? const Center( child: Text( 'ν•΄λ‹Ήν•˜λŠ” 학생이 μ—†μŠ΅λ‹ˆλ‹€.', style: TextStyle(color: Colors.grey), ), ) : GridView.builder( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 220, mainAxisSpacing: 12, crossAxisSpacing: 12, mainAxisExtent: 148, ), itemCount: filteredStudents.length, itemBuilder: (context, index) => _buildStudentTile(filteredStudents[index]), ), ), ], ); } /// πŸ€„ 학생 ν•œ λͺ…을 "ν•œλˆˆμ— 보기" νƒ€μΌλ‘œ κ·Έλ¦°λ‹€. λͺ¨λ°”일/λ°μŠ€ν¬ν†± κ·Έλ¦¬λ“œμ—μ„œ 곡용으둜 μ“΄λ‹€. 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] : Colors.white, borderRadius: BorderRadius.circular(20), border: hasViolation ? Border.all(color: Colors.red[300]!, width: 1.5) : null, 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: Colors.blue.shade50, borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.blue.shade200), ), child: Text( student['pocketNumber'], style: const TextStyle( fontWeight: FontWeight.bold, color: Colors.blueAccent, 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)), ), ), ], ], ), ); } // ----------------------------------------------------------------------- // πŸ–₯️ λ°μŠ€ν¬ν†± λ ˆμ΄μ•„μ›ƒ (μ„ μƒλ‹˜μ΄ ꡐ싀 컴퓨터 λΈŒλΌμš°μ €λ‘œ μ ‘μ†ν–ˆμ„ λ•Œ) // ----------------------------------------------------------------------- Widget _buildDesktopBody(int total, int checkedIn, int absent) { final filteredStudents = _controller.filteredStudents; 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: filteredStudents.isEmpty ? const Center( child: Text( 'ν•΄λ‹Ήν•˜λŠ” 학생이 μ—†μŠ΅λ‹ˆλ‹€.', style: TextStyle(color: Colors.grey), ), ) : GridView.builder( padding: const EdgeInsets.all(20), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 220, mainAxisSpacing: 16, crossAxisSpacing: 16, mainAxisExtent: 148, ), itemCount: filteredStudents.length, itemBuilder: (context, index) => _buildStudentTile(filteredStudents[index]), ), ), ), ], ), ), ), ); } Widget _statTile(String label, String value, IconData icon, Color color) { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, 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), ), 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(height: 2), Text( value, style: TextStyle( color: color, fontSize: 22, fontWeight: FontWeight.bold, ), ), ], ), ], ), ); } /// πŸ“Š 상단 μš”μ•½ μΉ΄λ“œ λ·° (전체 / μΆœμ„ μ™„λ£Œ / λ―ΈμΆœμ„) Widget _buildSummaryCards(int total, int checkedIn, int absent) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), color: const Color(0xFF1E293B), 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: Row( children: [ FilterChip( label: const Text("전체"), selected: _controller.filterType == "ALL", onSelected: (_) => _controller.setFilter("ALL"), ), const SizedBox(width: 8), FilterChip( label: const Text("🟒 μΆœμ„μž"), selected: _controller.filterType == "CHECKED_IN", onSelected: (_) => _controller.setFilter("CHECKED_IN"), ), const SizedBox(width: 8), FilterChip( label: const Text("πŸ”΄ λ―ΈμΆœμ„μž"), selected: _controller.filterType == "ABSENT", onSelected: (_) => _controller.setFilter("ABSENT"), ), ], ), ); } }