Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77688170b7 | ||
|
|
c17a28bad9 | ||
|
|
9adaba10cd | ||
|
|
77ef666e16 | ||
|
|
c8ed8f2fd2 | ||
|
|
49ab432fe9 |
@@ -1,6 +1,7 @@
|
|||||||
# school_attendance
|
# school_attendance
|
||||||
|
|
||||||
A new Flutter project.
|
학교생활 편의 프로젝트
|
||||||
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"flutter":{"platforms":{"android":{"default":{"projectId":"school-display-ff28f","appId":"1:137322214849:android:2595999513206bdeb16b40","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"school-display-ff28f","configurations":{"android":"1:137322214849:android:2595999513206bdeb16b40","web":"1:137322214849:web:0423e5c788d95761b16b40"}}}}}}
|
{"flutter":{"platforms":{"android":{"default":{"projectId":"school-display-ff28f","appId":"1:137322214849:android:2595999513206bdeb16b40","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"school-display-ff28f","configurations":{"android":"1:137322214849:android:2595999513206bdeb16b40","web":"1:137322214849:web:0423e5c788d95761b16b40"}}}}},"hosting":{"public":"build/web","ignore":["firebase.json","**/.*"],"rewrites":[{"source":"**","destination":"/index.html"}],"headers":[{"source":"/index.html","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]},{"source":"/flutter_service_worker.js","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]},{"source":"/version.json","headers":[{"key":"Cache-Control","value":"no-cache, no-store, must-revalidate"}]}]}}
|
||||||
@@ -43,10 +43,14 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
_startNfcSession();
|
_startNfcSession();
|
||||||
if (_watchServiceSupported) {
|
if (_watchServiceSupported) {
|
||||||
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
|
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
|
||||||
|
// 무단 반출이 한 번 감지되면 "신뢰된 감시 세션"은 끝난 것으로 보고,
|
||||||
|
// 다음 태깅은 체크아웃이 아니라 새 출석(재출석)으로 처리되도록 감시 상태를 해제한다.
|
||||||
_violationSub = FlutterBackgroundService().on('violation_detected').listen((
|
_violationSub = FlutterBackgroundService().on('violation_detected').listen((
|
||||||
event,
|
event,
|
||||||
) {
|
) {
|
||||||
if (mounted) _showViolationDialog(event?['pocketNumber']?.toString());
|
if (!mounted) return;
|
||||||
|
setState(() => _isWatching = false);
|
||||||
|
_showViolationDialog(event?['pocketNumber']?.toString());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,22 +358,10 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
const Text(
|
||||||
"📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.",
|
"📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.white38, fontSize: 13),
|
style: TextStyle(color: Colors.white38, fontSize: 13),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _checkOut,
|
|
||||||
icon: const Icon(Icons.logout_rounded, color: Colors.white70),
|
|
||||||
label: const Text(
|
|
||||||
"폰 회수하고 감시 종료 (테스트용)",
|
|
||||||
style: TextStyle(color: Colors.white70),
|
|
||||||
),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
side: const BorderSide(color: Colors.white30),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$');
|
|||||||
class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||||
List<dynamic> _roster = []; // 전체 학생 명단 (/api/users)
|
List<dynamic> _roster = []; // 전체 학생 명단 (/api/users)
|
||||||
List<dynamic> _logs = []; // 출석 로그 (/api/logs)
|
List<dynamic> _logs = []; // 출석 로그 (/api/logs)
|
||||||
|
Map<String, dynamic> _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active)
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용
|
||||||
String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT"
|
String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT"
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -40,22 +42,40 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _manualRefresh() async {
|
||||||
|
setState(() => _isRefreshing = true);
|
||||||
|
await _fetchAll();
|
||||||
|
if (mounted) setState(() => _isRefreshing = false);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _fetchAll() async {
|
Future<void> _fetchAll() async {
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
http.get(Uri.parse('$baseUrl/api/users')),
|
http.get(Uri.parse('$baseUrl/api/users')),
|
||||||
http.get(Uri.parse('$baseUrl/api/logs')),
|
http.get(Uri.parse('$baseUrl/api/logs')),
|
||||||
|
http.get(Uri.parse('$baseUrl/api/violations/active')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final usersRes = results[0];
|
final usersRes = results[0];
|
||||||
final logsRes = results[1];
|
final logsRes = results[1];
|
||||||
|
final violationsRes = results[2];
|
||||||
|
|
||||||
if (usersRes.statusCode == 200 && logsRes.statusCode == 200) {
|
if (usersRes.statusCode == 200 && logsRes.statusCode == 200) {
|
||||||
final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes));
|
final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes));
|
||||||
final logsData = jsonDecode(utf8.decode(logsRes.bodyBytes));
|
final logsData = jsonDecode(utf8.decode(logsRes.bodyBytes));
|
||||||
|
|
||||||
|
Map<String, dynamic> activeViolations = {};
|
||||||
|
if (violationsRes.statusCode == 200) {
|
||||||
|
final violationsData = jsonDecode(utf8.decode(violationsRes.bodyBytes));
|
||||||
|
for (final v in (violationsData['violations'] ?? [])) {
|
||||||
|
activeViolations[v['student_id'].toString()] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_roster = usersData['users'] ?? [];
|
_roster = usersData['users'] ?? [];
|
||||||
_logs = logsData['logs'] ?? [];
|
_logs = logsData['logs'] ?? [];
|
||||||
|
_activeViolationsByStudentId = activeViolations;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -66,6 +86,151 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 🚨 선생님이 특정 학생에게 지금부터 N분간 반출을 허용한다.
|
||||||
|
Future<void> _allowRemoval(String studentId, int minutes) async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/violations/allow'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"studentId": studentId, "minutes": minutes}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(result['message'] ?? '처리되었습니다.')),
|
||||||
|
);
|
||||||
|
_fetchAll();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('❌ 반출 허용 실패: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⏰ 지금부터 N분간 전체 학생의 반출을 자동으로 허용한다 (쉬는시간 등).
|
||||||
|
Future<void> _setPermissionWindow(int minutes) async {
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$baseUrl/api/permissions/window'),
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: jsonEncode({"minutes": minutes}),
|
||||||
|
);
|
||||||
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(result['message'] ?? '처리되었습니다.')),
|
||||||
|
);
|
||||||
|
_fetchAll();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('❌ 설정 실패: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
_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);
|
||||||
|
_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);
|
||||||
|
_setPermissionWindow(720); // 12시간 (사실상 하교~다음날까지 허용)
|
||||||
|
},
|
||||||
|
child: const Text('하교 처리'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 오늘 날짜 기준으로 학번별 "가장 최근 출석 기록"만 남긴 맵을 만든다.
|
/// 오늘 날짜 기준으로 학번별 "가장 최근 출석 기록"만 남긴 맵을 만든다.
|
||||||
Map<String, Map<String, String>> get _todaysCheckInsByStudentId {
|
Map<String, Map<String, String>> get _todaysCheckInsByStudentId {
|
||||||
final String todayPrefix = DateTime.now().toIso8601String().substring(
|
final String todayPrefix = DateTime.now().toIso8601String().substring(
|
||||||
@@ -100,12 +265,16 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
return _roster.map((u) {
|
return _roster.map((u) {
|
||||||
final String id = u['id']?.toString() ?? '';
|
final String id = u['id']?.toString() ?? '';
|
||||||
final checkIn = checkIns[id];
|
final checkIn = checkIns[id];
|
||||||
|
final violation = _activeViolationsByStudentId[id];
|
||||||
return {
|
return {
|
||||||
'studentId': id,
|
'studentId': id,
|
||||||
'studentName': u['name']?.toString() ?? '',
|
'studentName': u['name']?.toString() ?? '',
|
||||||
'isCheckedIn': checkIn != null,
|
'isCheckedIn': checkIn != null,
|
||||||
'pocketNumber': checkIn?['pocketNumber'],
|
'pocketNumber': checkIn?['pocketNumber'],
|
||||||
'checkInTime': checkIn?['time'],
|
'checkInTime': checkIn?['time'],
|
||||||
|
'hasActiveViolation': violation != null,
|
||||||
|
'violationPocket': violation?['pocket_number'],
|
||||||
|
'violationTime': violation?['time'],
|
||||||
};
|
};
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
@@ -137,14 +306,267 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
backgroundColor: Colors.blue,
|
backgroundColor: Colors.blue,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: (_isLoading || _isRefreshing) ? null : _manualRefresh,
|
||||||
|
icon: _isRefreshing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh_rounded),
|
||||||
|
tooltip: '새로고침',
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: Column(
|
: LayoutBuilder(
|
||||||
children: [
|
builder: (context, constraints) {
|
||||||
_buildSummaryCards(totalCount, checkedInCount, absentCount),
|
final bool isWide = constraints.maxWidth >= 800;
|
||||||
_buildFilterChips(),
|
return isWide
|
||||||
Expanded(
|
? _buildDesktopBody(totalCount, checkedInCount, absentCount)
|
||||||
|
: _buildMobileBody(totalCount, checkedInCount, absentCount);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 📱 모바일 레이아웃 (기존 카드 리스트)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
Widget _buildMobileBody(int total, int checkedIn, int absent) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildSummaryCards(total, checkedIn, absent),
|
||||||
|
_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;
|
||||||
|
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: (hasViolation
|
||||||
|
? Colors.red
|
||||||
|
: (isCheckedIn ? Colors.blue : Colors.red))
|
||||||
|
.withValues(alpha: 0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
hasViolation
|
||||||
|
? Icons.warning_amber_rounded
|
||||||
|
: (isCheckedIn
|
||||||
|
? Icons.check_circle_rounded
|
||||||
|
: Icons.error_rounded),
|
||||||
|
color: hasViolation
|
||||||
|
? Colors.red
|
||||||
|
: (isCheckedIn ? Colors.blue : Colors.red),
|
||||||
|
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']})'
|
||||||
|
: (isCheckedIn
|
||||||
|
? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}'
|
||||||
|
: '학번: ${student['studentId']} | 미제출'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: hasViolation
|
||||||
|
? Colors.red[700]
|
||||||
|
: (isCheckedIn
|
||||||
|
? Colors.grey[600]
|
||||||
|
: Colors.red[400]),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: hasViolation
|
||||||
|
? 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) {
|
||||||
|
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),
|
||||||
|
_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
|
child: _filteredStudents.isEmpty
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -152,109 +574,179 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
style: TextStyle(color: Colors.grey),
|
style: TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
child: DataTable(
|
||||||
itemCount: _filteredStudents.length,
|
headingRowColor: WidgetStateProperty.all(
|
||||||
itemBuilder: (context, index) {
|
Colors.grey[50],
|
||||||
final student = _filteredStudents[index];
|
),
|
||||||
final bool isCheckedIn = student['isCheckedIn'];
|
columns: const [
|
||||||
return Container(
|
DataColumn(label: Text('상태')),
|
||||||
margin: const EdgeInsets.symmetric(
|
DataColumn(label: Text('학번')),
|
||||||
horizontal: 24,
|
DataColumn(label: Text('이름')),
|
||||||
vertical: 8,
|
DataColumn(label: Text('제출 시간')),
|
||||||
),
|
DataColumn(label: Text('주머니 번호')),
|
||||||
padding: const EdgeInsets.all(18),
|
DataColumn(label: Text('작업')),
|
||||||
decoration: BoxDecoration(
|
],
|
||||||
color: Colors.white,
|
rows: _filteredStudents.map((student) {
|
||||||
borderRadius: BorderRadius.circular(20),
|
final bool isCheckedIn = student['isCheckedIn'];
|
||||||
boxShadow: [
|
final bool hasViolation =
|
||||||
BoxShadow(
|
student['hasActiveViolation'] == true;
|
||||||
color: Colors.black.withValues(alpha: 0.03),
|
return DataRow(
|
||||||
blurRadius: 12,
|
color: hasViolation
|
||||||
offset: const Offset(0, 4),
|
? WidgetStateProperty.all(Colors.red[50])
|
||||||
),
|
: null,
|
||||||
],
|
cells: [
|
||||||
),
|
DataCell(
|
||||||
child: Row(
|
Icon(
|
||||||
children: [
|
hasViolation
|
||||||
Container(
|
? Icons.warning_amber_rounded
|
||||||
padding: const EdgeInsets.all(10),
|
: (isCheckedIn
|
||||||
decoration: BoxDecoration(
|
? Icons.check_circle_rounded
|
||||||
color: (isCheckedIn
|
: Icons.error_rounded),
|
||||||
|
color: hasViolation
|
||||||
|
? Colors.red
|
||||||
|
: (isCheckedIn
|
||||||
? Colors.blue
|
? Colors.blue
|
||||||
: Colors.red)
|
: Colors.red),
|
||||||
.withValues(alpha: 0.1),
|
size: 20,
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
isCheckedIn
|
|
||||||
? Icons.check_circle_rounded
|
|
||||||
: Icons.error_rounded,
|
|
||||||
color: isCheckedIn
|
|
||||||
? Colors.blue
|
|
||||||
: Colors.red,
|
|
||||||
size: 24,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
DataCell(Text('${student['studentId']}')),
|
||||||
Expanded(
|
DataCell(
|
||||||
child: Column(
|
Text(
|
||||||
crossAxisAlignment:
|
'${student['studentName']}',
|
||||||
CrossAxisAlignment.start,
|
style: const TextStyle(
|
||||||
children: [
|
fontWeight: FontWeight.bold,
|
||||||
Text(
|
),
|
||||||
'${student['studentName']} 학생',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
isCheckedIn
|
|
||||||
? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}'
|
|
||||||
: '학번: ${student['studentId']} | 미제출',
|
|
||||||
style: TextStyle(
|
|
||||||
color: isCheckedIn
|
|
||||||
? Colors.grey[600]
|
|
||||||
: Colors.red[400],
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isCheckedIn &&
|
DataCell(
|
||||||
student['pocketNumber'] != null)
|
Text(
|
||||||
Container(
|
hasViolation
|
||||||
padding: const EdgeInsets.symmetric(
|
? '🚨 무단반출 (${student['violationTime']})'
|
||||||
horizontal: 12,
|
: (isCheckedIn
|
||||||
vertical: 6,
|
? '${student['checkInTime']}'
|
||||||
),
|
: '미제출'),
|
||||||
decoration: BoxDecoration(
|
style: TextStyle(
|
||||||
color: Colors.blue.shade50,
|
color: hasViolation
|
||||||
borderRadius: BorderRadius.circular(20),
|
? Colors.red[700]
|
||||||
border: Border.all(
|
: (isCheckedIn
|
||||||
color: Colors.blue.shade200,
|
? Colors.grey[700]
|
||||||
),
|
: Colors.red[400]),
|
||||||
),
|
fontWeight: hasViolation
|
||||||
child: Text(
|
? FontWeight.bold
|
||||||
student['pocketNumber'],
|
: FontWeight.normal,
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.blueAccent,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,16 +135,20 @@ class _TeacherDashboardState extends State<TeacherDashboard> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 📊 [그리드 레이아웃 메뉴] 시후의 카드 컴포넌트 스타일 적용
|
// 📊 [그리드 레이아웃 메뉴] 시후의 카드 컴포넌트 스타일 적용
|
||||||
Padding(
|
// 🖥️ 데스크톱 브라우저에서 카드가 지나치게 커지지 않도록 최대 너비를 제한한다.
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
Center(
|
||||||
child: GridView.count(
|
child: ConstrainedBox(
|
||||||
shrinkWrap: true,
|
constraints: const BoxConstraints(maxWidth: 700),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
child: Padding(
|
||||||
crossAxisCount: 2,
|
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||||
crossAxisSpacing: 16,
|
child: GridView.count(
|
||||||
mainAxisSpacing: 16,
|
shrinkWrap: true,
|
||||||
childAspectRatio: 0.95,
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: [
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
childAspectRatio: 0.95,
|
||||||
|
children: [
|
||||||
_buildModernCard(
|
_buildModernCard(
|
||||||
icon: Icons.assignment_turned_in_rounded,
|
icon: Icons.assignment_turned_in_rounded,
|
||||||
title: '실시간 출석 확인',
|
title: '실시간 출석 확인',
|
||||||
@@ -170,7 +174,9 @@ class _TeacherDashboardState extends State<TeacherDashboard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|||||||
Reference in New Issue
Block a user