lib/ 코드를 UI(lib/ui/)와 기능(lib/function/)으로 분리
- 화면마다 위젯/스타일만 담당하는 UI 파일과, 서버통신/상태/파생로직만 담당하는 ChangeNotifier 컨트롤러 파일로 1:1 분리 (lib/screens/ -> lib/ui/ + lib/function/) - UI는 ListenableBuilder로 컨트롤러를 구독해서 재렌더링, 버튼은 컨트롤러 메서드만 호출 - 다이얼로그/스낵바 등 위젯 코드는 전부 UI 파일에 남기고, 컨트롤러는 결과값(성공여부+메시지) 또는 콜백으로만 UI와 통신 (BuildContext/Widget 의존성 없음) - 디자인/레이아웃은 기존과 완전히 동일하게 유지 (순수 코드 재배치) - change_password_screen.dart, debug_pocket_main.dart(미사용 파일)는 깨지지 않게 import 경로만 갱신하고 리팩터링은 보류
This commit is contained in:
@@ -0,0 +1,822 @@
|
||||
// 📋 실시간 출석 현황 화면 (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<TeacherAttendancePage> createState() => _TeacherAttendancePageState();
|
||||
}
|
||||
|
||||
class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
final TeacherAttendanceController _controller = TeacherAttendanceController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 컨트롤러 액션을 실행하고, 결과 메시지를 스낵바로 보여준다.
|
||||
Future<void> _runAction(Future<String> Function() action) async {
|
||||
final message = await action();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
Future<void> _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),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemCount: filteredStudents.length,
|
||||
itemBuilder: (context, index) {
|
||||
final student = filteredStudents[index];
|
||||
final bool isCheckedIn = student['isCheckedIn'];
|
||||
final bool hasViolation =
|
||||
student['hasActiveViolation'] == true;
|
||||
final bool isPending =
|
||||
student['attendanceStatus'] == 'PENDING';
|
||||
final bool isComplete =
|
||||
student['attendanceStatus'] == 'COMPLETE';
|
||||
final bool emphasizeTime =
|
||||
isComplete && _controller.attendanceTime != null;
|
||||
final Color statusColor = hasViolation
|
||||
? Colors.red
|
||||
: (isPending
|
||||
? Colors.orange
|
||||
: (isComplete ? Colors.blue : Colors.red));
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
padding: const EdgeInsets.all(18),
|
||||
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(10),
|
||||
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: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${student['studentName']} 학생',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
hasViolation
|
||||
? '🚨 무단반출 감지! (${student['violationTime']})'
|
||||
: (isPending
|
||||
? '학번: ${student['studentId']} | ⏳ 출석 미완료 (제출: ${student['checkInTime']})'
|
||||
: (isComplete
|
||||
? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}'
|
||||
: (student['hasEverCheckedIn'] ==
|
||||
true
|
||||
? '학번: ${student['studentId']} | 미제출'
|
||||
: '학번: ${student['studentId']} | 미등록'))),
|
||||
style: TextStyle(
|
||||
color: hasViolation
|
||||
? Colors.red[700]
|
||||
: (isPending
|
||||
? Colors.orange[800]
|
||||
: (isComplete
|
||||
? Colors.grey[600]
|
||||
: Colors.red[400])),
|
||||
fontSize: emphasizeTime ? 14 : 12,
|
||||
fontWeight: (hasViolation || emphasizeTime)
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isCheckedIn && student['pocketNumber'] != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
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: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasViolation)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _showAllowDialog(
|
||||
student['studentId'],
|
||||
student['studentName'],
|
||||
),
|
||||
icon: const Icon(Icons.check, size: 18),
|
||||
label: const Text('반출 허용'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[600],
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
|
||||
// -----------------------------------------------------------------------
|
||||
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),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: DataTable(
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
Colors.grey[50],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('상태')),
|
||||
DataColumn(label: Text('학번')),
|
||||
DataColumn(label: Text('이름')),
|
||||
DataColumn(label: Text('제출 시간')),
|
||||
DataColumn(label: Text('주머니 번호')),
|
||||
DataColumn(label: Text('작업')),
|
||||
],
|
||||
rows: filteredStudents.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 bool emphasizeTime =
|
||||
isComplete && _controller.attendanceTime != null;
|
||||
final Color statusColor = hasViolation
|
||||
? Colors.red
|
||||
: (isPending
|
||||
? Colors.orange
|
||||
: (isComplete ? Colors.blue : Colors.red));
|
||||
return DataRow(
|
||||
color: hasViolation
|
||||
? WidgetStateProperty.all(Colors.red[50])
|
||||
: (isPending
|
||||
? WidgetStateProperty.all(
|
||||
Colors.orange[50],
|
||||
)
|
||||
: null),
|
||||
cells: [
|
||||
DataCell(
|
||||
Icon(
|
||||
hasViolation
|
||||
? Icons.warning_amber_rounded
|
||||
: (isPending
|
||||
? Icons.hourglass_bottom_rounded
|
||||
: (isComplete
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.error_rounded)),
|
||||
color: statusColor,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
DataCell(Text('${student['studentId']}')),
|
||||
DataCell(
|
||||
Text(
|
||||
'${student['studentName']}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
hasViolation
|
||||
? '🚨 무단반출 (${student['violationTime']})'
|
||||
: (isPending
|
||||
? '⏳ 출석 미완료 (제출: ${student['checkInTime']})'
|
||||
: (isComplete
|
||||
? '${student['checkInTime']}'
|
||||
: (student['hasEverCheckedIn'] ==
|
||||
true
|
||||
? '미제출'
|
||||
: '미등록'))),
|
||||
style: TextStyle(
|
||||
color: hasViolation
|
||||
? Colors.red[700]
|
||||
: (isPending
|
||||
? Colors.orange[800]
|
||||
: (isComplete
|
||||
? Colors.grey[700]
|
||||
: Colors.red[400])),
|
||||
fontSize: emphasizeTime ? 15 : 14,
|
||||
fontWeight: (hasViolation || emphasizeTime)
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
isCheckedIn && student['pocketNumber'] != null
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
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: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text('-'),
|
||||
),
|
||||
DataCell(
|
||||
hasViolation
|
||||
? ElevatedButton.icon(
|
||||
onPressed: () => _showAllowDialog(
|
||||
student['studentId'],
|
||||
student['studentName'],
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
),
|
||||
label: const Text('반출 허용'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[600],
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text('-'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user