11 Commits
Author SHA1 Message Date
sihoo 5ab1c4c37c 하교 처리 후 무단반출 오탐 버그 수정 + 앱 버전 1.1.3
- 조도 센서가 폰 반출을 감지하면, 위반 경고를 울리기 전에 서버에 반출 허용 상태(하교/허용시간대)를 먼저 확인
- 허용된 상태면 위반 경고 없이 조용히 정상 회수로 처리
2026-08-04 14:36:38 +09:00
sihoo 3a62ebacd8 출석/무단반출 팝업 중복 표시 버그 수정 + 앱 버전 1.1.2
- 새 팝업(출석완료/무단반출)을 띄우기 전 이전에 열려있던 팝업을 먼저 닫도록 수정
- 재태깅이 짧은 간격으로 반복되면 팝업이 여러 개 겹쳐 쌓이던 문제 해결
2026-08-04 14:24:35 +09:00
sihoo 26fb4c32cf 테스트용 허용시간 초기화 디버그 버튼 추가 2026-08-04 14:15:20 +09:00
sihoo 4137a6815c 자습실 출석시간 지정 기능 추가 + 앱 버전 1.1.1
- 대시보드에서 자습실 출석시간(HH:MM) 지정 가능, 하교 시 자동 초기화
- 지정 시간 이전 태깅은 "출석 미완료", 이후는 시간 강조 표시로 구분
2026-08-04 14:01:50 +09:00
sihooandClaude Sonnet 5 4ff063c76d 하교 처리 시 출석 현황 표시도 함께 초기화
하교 버튼을 누르면 반출 허용뿐 아니라, 그 시각을 기준선으로 삼아 대시보드의
"출석 완료" 표시도 미제출로 초기화되도록 함. 실제 출석 기록(attendance 테이블)은
삭제하지 않고, 대시보드가 "오늘 출석"으로 인정하는 기준 시각만 옮기는 방식이라
데이터 손실 없이 표시만 리셋된다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 01:25:20 +09:00
sihooandClaude Sonnet 5 77688170b7 하교 버튼 추가 + 무단반출 후 재태깅 시 재출석 처리 + 테스트용 감시종료 버튼 제거
- 선생님 대시보드에 "하교" 버튼 추가: 시간 입력 없이 바로 전체 학생 반출 허용
  (실수 클릭 방지를 위해 확인 다이얼로그 한 번 거침)
- 무단반출이 감지되면 클라이언트의 감시 상태를 해제해서, 다음 태깅이
  체크아웃이 아니라 새 출석으로 처리되도록 수정
- 주머니 감시 화면에 있던 "폰 회수하고 감시 종료 (테스트용)" 버튼 제거
  (정식 플로우는 재태깅으로만 체크아웃/재출석 처리)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 01:08:48 +09:00
sihooandClaude Sonnet 5 c17a28bad9 새로고침 버튼에 로딩 표시 추가
버튼을 눌러도 아무 반응이 없어 보인다는 피드백 반영 - 클릭 시 아이콘이
잠깐 스피너로 바뀌도록 해서 실제로 새로고침이 진행 중임을 알 수 있게 함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 00:52:26 +09:00
sihooandClaude Sonnet 5 9adaba10cd 교사 대시보드에 새로고침 버튼 추가 + 웹 배포 캐시 무효화 설정
- TeacherAttendancePage 앱바에 수동 새로고침 버튼 추가
- Firebase Hosting에서 index.html/flutter_service_worker.js/version.json을
  no-cache로 설정해서, 재배포 후 브라우저가 예전 버전을 계속 들고 있는 문제 방지

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 00:42:15 +09:00
sihoo 77ef666e16 Merge README update from remote 2026-08-04 00:33:47 +09:00
sihooandClaude Sonnet 5 c8ed8f2fd2 교사 데스크톱 대시보드 + 무단반출 허용 기능 추가
- 실시간 출석 현황 화면(TeacherAttendancePage)에 데스크톱(웹 브라우저 800px+) 전용
  레이아웃 추가: 통계 타일 3개 + 정식 DataTable. 교사 대시보드 메뉴 그리드도
  데스크톱에서 카드가 지나치게 커지지 않도록 최대 너비 제한
- Firebase Hosting에 웹 빌드 배포 설정 추가 (firebase.json hosting 섹션),
  https://school-display-ff28f.web.app 로 선생님이 브라우저에서 바로 접속 가능
- 무단 반출(/api/violations/active) 감지 시 대시보드에 빨간 표시 + "반출 허용" 버튼 추가
- 선생님이 개별 학생에게 N분간 반출 허용(/api/violations/allow), 또는 전체 학생에게
  한 번에 반출 허용 시간대 설정(/api/permissions/window, 쉬는시간 등) 가능하도록 연동

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 00:32:30 +09:00
sihoo 49ab432fe9 README.md 업데이트 2026-08-03 14:15:51 +00:00
7 changed files with 870 additions and 143 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
# school_attendance # school_attendance
A new Flutter project. 학교생활 편의 프로젝트
## Getting Started ## Getting Started
+1 -1
View File
@@ -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"}]}]}}
+34 -16
View File
@@ -34,6 +34,8 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
String? _activePocketNumber; String? _activePocketNumber;
StreamSubscription<Map<String, dynamic>?>? _violationSub; StreamSubscription<Map<String, dynamic>?>? _violationSub;
StreamSubscription<Map<String, dynamic>?>? _checkedOutSub;
bool _isDialogOpen = false; // 출석/위반 팝업이 겹쳐서 뜨는 것을 막기 위한 플래그
bool get _watchServiceSupported => !kIsWeb && Platform.isAndroid; bool get _watchServiceSupported => !kIsWeb && Platform.isAndroid;
@@ -43,10 +45,24 @@ 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());
});
// 🏫 하교 처리 등으로 반출이 허용된 상태에서 폰을 꺼내면, 위반이 아니라 정상 회수로 처리된다.
_checkedOutSub = FlutterBackgroundService().on('checked_out').listen((event) {
if (!mounted) return;
setState(() {
_isWatching = false;
_activePocketNumber = null;
_statusMessage = "✅ 폰을 회수했습니다. 수고하셨습니다!";
});
_showSnackBar("✅ 폰이 정상적으로 회수되었습니다.", Colors.green);
}); });
} }
} }
@@ -56,6 +72,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
// 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다. // 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다.
NfcManager.instance.stopSession(); NfcManager.instance.stopSession();
_violationSub?.cancel(); _violationSub?.cancel();
_checkedOutSub?.cancel();
super.dispose(); super.dispose();
} }
@@ -207,7 +224,18 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
_showSnackBar("✅ 감시가 종료되었습니다. 수고하셨습니다!", Colors.green); _showSnackBar("✅ 감시가 종료되었습니다. 수고하셨습니다!", Colors.green);
} }
/// 이미 떠 있는 팝업(출석 완료/무단반출 감지)이 있으면 새 팝업을 띄우기 전에 먼저 닫는다.
/// (태깅→반출→재태깅이 빠르게 반복되면 팝업이 여러 개 겹쳐 쌓이는 것을 방지)
void _closeAnyOpenDialog() {
if (_isDialogOpen && mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
_isDialogOpen = false;
}
void _showViolationDialog(String? pocketNumber) { void _showViolationDialog(String? pocketNumber) {
_closeAnyOpenDialog();
_isDialogOpen = true;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -226,11 +254,13 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
), ),
], ],
), ),
); ).then((_) => _isDialogOpen = false);
} }
/// 🎊 출석 성공 알림창 /// 🎊 출석 성공 알림창
void _showSuccessDialog(String pocketNumber) { void _showSuccessDialog(String pocketNumber) {
_closeAnyOpenDialog();
_isDialogOpen = true;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -245,7 +275,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
), ),
], ],
), ),
); ).then((_) => _isDialogOpen = false);
} }
void _showSnackBar(String text, Color color) { void _showSnackBar(String text, Color color) {
@@ -354,22 +384,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),
),
),
], ],
), ),
), ),
+25 -1
View File
@@ -112,6 +112,30 @@ void onPocketWatchServiceStart(ServiceInstance service) {
} }
} }
/// 하교 처리/반출 허용 시간대라면 조용히 감시만 종료하고, 아니면 위반으로 경고한다.
Future<void> handlePhoneRemoved() async {
bool isPermitted = false;
try {
final res = await http.get(
Uri.parse("$baseUrl/api/permissions/check?studentId=$studentId"),
);
if (res.statusCode == 200) {
final data = jsonDecode(utf8.decode(res.bodyBytes));
isPermitted = data['permitted'] == true;
}
} catch (_) {
isPermitted = false; // 네트워크 오류 시엔 안전하게 위반으로 처리
}
if (isPermitted) {
updateNotification("✅ 폰 회수 완료", "[$pocketNumber] 주머니에서 정상적으로 회수되었습니다.");
service.invoke('checked_out', {"pocketNumber": pocketNumber});
service.stopSelf();
} else {
reportViolation();
}
}
void onLightReading(int luxValue) { void onLightReading(int luxValue) {
if (violated) return; if (violated) return;
@@ -129,7 +153,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
if (luxValue > baselineLux! + _luxJumpThreshold) { if (luxValue > baselineLux! + _luxJumpThreshold) {
violated = true; violated = true;
lightSub?.cancel(); lightSub?.cancel();
reportViolation(); handlePhoneRemoved();
} }
} }
+790 -112
View File
@@ -23,8 +23,12 @@ 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)
String? _dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시.
String? _attendanceTime; // 선생님이 지정한 자습실 출석시간 "HH:MM" (/api/settings/attendance-time). null이면 미설정.
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 +44,59 @@ 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')),
http.get(Uri.parse('$baseUrl/api/dismissal/latest')),
http.get(Uri.parse('$baseUrl/api/settings/attendance-time')),
]); ]);
final usersRes = results[0]; final usersRes = results[0];
final logsRes = results[1]; final logsRes = results[1];
final violationsRes = results[2];
final dismissalRes = results[3];
final attendanceTimeRes = results[4];
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;
}
}
String? dismissedAt;
if (dismissalRes.statusCode == 200) {
final dismissalData = jsonDecode(utf8.decode(dismissalRes.bodyBytes));
dismissedAt = dismissalData['dismissedAt'];
}
String? attendanceTime;
if (attendanceTimeRes.statusCode == 200) {
final attendanceTimeData =
jsonDecode(utf8.decode(attendanceTimeRes.bodyBytes));
attendanceTime = attendanceTimeData['attendanceTime'];
}
setState(() { setState(() {
_roster = usersData['users'] ?? []; _roster = usersData['users'] ?? [];
_logs = logsData['logs'] ?? []; _logs = logsData['logs'] ?? [];
_activeViolationsByStudentId = activeViolations;
_dismissedAt = dismissedAt;
_attendanceTime = attendanceTime;
_isLoading = false; _isLoading = false;
}); });
} else { } else {
@@ -66,17 +107,272 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
} }
} }
/// 오늘 날짜 기준으로 학번별 "가장 최근 출석 기록"만 남긴 맵을 만든다. /// 🏫 하교 처리: 전체 반출 허용 + 오늘 출석 표시 기준선을 지금 시각으로 옮긴다.
Future<void> _dismissAll() async {
try {
final response = await http.post(Uri.parse('$baseUrl/api/dismiss'));
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> _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')),
);
}
}
/// ⏰ 자습실 출석시간(기준 시각)을 지정한다. 이 시각 이후 태깅한 학생만 "출석 완료"로 강조 표시된다.
Future<void> _setAttendanceTime(String time) async {
try {
final response = await http.post(
Uri.parse('$baseUrl/api/settings/attendance-time'),
headers: {"Content-Type": "application/json"},
body: jsonEncode({"time": time}),
);
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')),
);
}
}
Future<void> _showAttendanceTimeDialog() async {
final TimeOfDay initial = _attendanceTime != null
? TimeOfDay(
hour: int.parse(_attendanceTime!.split(':')[0]),
minute: int.parse(_attendanceTime!.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')}';
_setAttendanceTime(formatted);
}
/// 🧪 테스트용: 하교(12시간)/반출 허용 시간 설정 등으로 켜져 있는 허용 시간대를 즉시 해제한다.
Future<void> _resetTestPermissions() async {
try {
final response = await http.post(Uri.parse('$baseUrl/api/debug/reset-permissions'));
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 _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);
_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);
_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);
_dismissAll();
},
child: const Text('하교 처리'),
),
],
),
);
}
/// "오늘 출석"의 기준선. 하교 처리를 했다면 그 시각 이후, 안 했다면 오늘 자정부터.
Map<String, Map<String, String>> get _todaysCheckInsByStudentId { Map<String, Map<String, String>> get _todaysCheckInsByStudentId {
final String todayPrefix = DateTime.now().toIso8601String().substring( // "YYYY-MM-DD HH:MM:SS" 형태의 문자열끼리는 그대로 비교해도 시간 순서가 맞는다.
0, final String cutoff =
10, _dismissedAt ??
); // "YYYY-MM-DD" '${DateTime.now().toIso8601String().substring(0, 10)} 00:00:00';
final Map<String, Map<String, String>> result = {}; final Map<String, Map<String, String>> result = {};
for (final log in _logs) { for (final log in _logs) {
final String time = log['time']?.toString() ?? ''; final String time = log['time']?.toString() ?? '';
if (!time.startsWith(todayPrefix)) continue; // 오늘 기록이 아니면 무시 if (time.isEmpty || time.compareTo(cutoff) <= 0) {
continue; // 하교 처리 시각(또는 오늘 자정) 이전 기록이면 무시
}
final String studentId = log['student_id']?.toString() ?? ''; final String studentId = log['student_id']?.toString() ?? '';
if (studentId.isEmpty) continue; if (studentId.isEmpty) continue;
@@ -95,17 +391,37 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
return result; return result;
} }
/// ⏰ 태깅 시간과 지정된 자습실 출석시간을 비교해 'NONE' / 'PENDING' / 'COMPLETE'를 반환한다.
/// - NONE: 아직 태깅 안 함
/// - PENDING: 태깅은 했지만 지정된 출석시간 이전이라 아직 "출석 완료"로 안 침
/// - COMPLETE: 출석시간 미지정이거나, 지정된 출석시간 이후에 태깅함
String _computeAttendanceStatus(String? checkInTime) {
if (checkInTime == null) return 'NONE';
if (_attendanceTime == null) return 'COMPLETE';
final String todayStr = DateTime.now().toIso8601String().substring(0, 10);
final String cutoff = '$todayStr $_attendanceTime:00';
return checkInTime.compareTo(cutoff) >= 0 ? 'COMPLETE' : 'PENDING';
}
List<Map<String, dynamic>> get _combinedStudentStatus { List<Map<String, dynamic>> get _combinedStudentStatus {
final checkIns = _todaysCheckInsByStudentId; final checkIns = _todaysCheckInsByStudentId;
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];
final String attendanceStatus = _computeAttendanceStatus(checkIn?['time']);
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'],
'attendanceStatus': attendanceStatus,
'isAttendanceComplete': attendanceStatus == 'COMPLETE',
'hasActiveViolation': violation != null,
'violationPocket': violation?['pocket_number'],
'violationTime': violation?['time'],
}; };
}).toList(); }).toList();
} }
@@ -113,9 +429,9 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
List<Map<String, dynamic>> get _filteredStudents { List<Map<String, dynamic>> get _filteredStudents {
final all = _combinedStudentStatus; final all = _combinedStudentStatus;
if (_filterType == "CHECKED_IN") { if (_filterType == "CHECKED_IN") {
return all.where((s) => s['isCheckedIn'] == true).toList(); return all.where((s) => s['isAttendanceComplete'] == true).toList();
} else if (_filterType == "ABSENT") { } else if (_filterType == "ABSENT") {
return all.where((s) => s['isCheckedIn'] == false).toList(); return all.where((s) => s['isAttendanceComplete'] == false).toList();
} }
return all; return all;
} }
@@ -124,7 +440,8 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final all = _combinedStudentStatus; final all = _combinedStudentStatus;
final int totalCount = all.length; final int totalCount = all.length;
final int checkedInCount = all.where((s) => s['isCheckedIn'] == true).length; final int checkedInCount =
all.where((s) => s['isAttendanceComplete'] == true).length;
final int absentCount = totalCount - checkedInCount; final int absentCount = totalCount - checkedInCount;
return Scaffold( return Scaffold(
@@ -137,14 +454,289 @@ 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: _showAttendanceTimeDialog,
icon: const Icon(Icons.access_time_rounded, color: Colors.white),
label: Text(
_attendanceTime != null ? '출석시간 $_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: _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;
final bool isPending = student['attendanceStatus'] == 'PENDING';
final bool isComplete = student['attendanceStatus'] == 'COMPLETE';
final bool emphasizeTime = isComplete && _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['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) {
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 +744,195 @@ 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), final bool isPending =
blurRadius: 12, student['attendanceStatus'] == 'PENDING';
offset: const Offset(0, 4), final bool isComplete =
student['attendanceStatus'] == 'COMPLETE';
final bool emphasizeTime =
isComplete && _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']}'
: '미제출')),
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('-'),
), ),
], ],
), );
child: Row( }).toList(),
children: [ ),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: (isCheckedIn
? Colors.blue
: Colors.red)
.withValues(alpha: 0.1),
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),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
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 &&
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,
),
),
),
],
),
);
},
), ),
), ),
], ),
],
),
),
),
);
}
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,
),
),
],
),
],
),
); );
} }
+17 -11
View File
@@ -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),
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1 version: 1.1.3+4
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2