5 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
4 changed files with 284 additions and 48 deletions
+28 -2
View File
@@ -34,6 +34,8 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
String? _activePocketNumber;
StreamSubscription<Map<String, dynamic>?>? _violationSub;
StreamSubscription<Map<String, dynamic>?>? _checkedOutSub;
bool _isDialogOpen = false; // 출석/위반 팝업이 겹쳐서 뜨는 것을 막기 위한 플래그
bool get _watchServiceSupported => !kIsWeb && Platform.isAndroid;
@@ -52,6 +54,16 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
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);
});
}
}
@@ -60,6 +72,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
// 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다.
NfcManager.instance.stopSession();
_violationSub?.cancel();
_checkedOutSub?.cancel();
super.dispose();
}
@@ -211,7 +224,18 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
_showSnackBar("✅ 감시가 종료되었습니다. 수고하셨습니다!", Colors.green);
}
/// 이미 떠 있는 팝업(출석 완료/무단반출 감지)이 있으면 새 팝업을 띄우기 전에 먼저 닫는다.
/// (태깅→반출→재태깅이 빠르게 반복되면 팝업이 여러 개 겹쳐 쌓이는 것을 방지)
void _closeAnyOpenDialog() {
if (_isDialogOpen && mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
_isDialogOpen = false;
}
void _showViolationDialog(String? pocketNumber) {
_closeAnyOpenDialog();
_isDialogOpen = true;
showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -230,11 +254,13 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
),
],
),
);
).then((_) => _isDialogOpen = false);
}
/// 🎊 출석 성공 알림창
void _showSuccessDialog(String pocketNumber) {
_closeAnyOpenDialog();
_isDialogOpen = true;
showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -249,7 +275,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
),
],
),
);
).then((_) => _isDialogOpen = false);
}
void _showSnackBar(String text, Color color) {
+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) {
if (violated) return;
@@ -129,7 +153,7 @@ void onPocketWatchServiceStart(ServiceInstance service) {
if (luxValue > baselineLux! + _luxJumpThreshold) {
violated = true;
lightSub?.cancel();
reportViolation();
handlePhoneRemoved();
}
}
+224 -38
View File
@@ -24,6 +24,8 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
List<dynamic> _roster = []; // 전체 학생 명단 (/api/users)
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;
bool _isLoading = true;
bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용
@@ -54,11 +56,15 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
http.get(Uri.parse('$baseUrl/api/users')),
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 logsRes = results[1];
final violationsRes = results[2];
final dismissalRes = results[3];
final attendanceTimeRes = results[4];
if (usersRes.statusCode == 200 && logsRes.statusCode == 200) {
final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes));
@@ -72,10 +78,25 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
}
}
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(() {
_roster = usersData['users'] ?? [];
_logs = logsData['logs'] ?? [];
_activeViolationsByStudentId = activeViolations;
_dismissedAt = dismissedAt;
_attendanceTime = attendanceTime;
_isLoading = false;
});
} else {
@@ -86,6 +107,24 @@ 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 {
@@ -130,6 +169,96 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
}
}
/// ⏰ 자습실 출석시간(기준 시각)을 지정한다. 이 시각 이후 태깅한 학생만 "출석 완료"로 강조 표시된다.
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(
@@ -222,7 +351,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
ElevatedButton(
onPressed: () {
Navigator.pop(context);
_setPermissionWindow(720); // 12시간 (사실상 하교~다음날까지 허용)
_dismissAll();
},
child: const Text('하교 처리'),
),
@@ -231,17 +360,19 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
);
}
/// 오늘 날짜 기준으로 학번별 "가장 최근 출석 기록"만 남긴 맵을 만든다.
/// "오늘 출석"의 기준선. 하교 처리를 했다면 그 시각 이후, 안 했다면 오늘 자정부터.
Map<String, Map<String, String>> get _todaysCheckInsByStudentId {
final String todayPrefix = DateTime.now().toIso8601String().substring(
0,
10,
); // "YYYY-MM-DD"
// "YYYY-MM-DD HH:MM:SS" 형태의 문자열끼리는 그대로 비교해도 시간 순서가 맞는다.
final String cutoff =
_dismissedAt ??
'${DateTime.now().toIso8601String().substring(0, 10)} 00:00:00';
final Map<String, Map<String, String>> result = {};
for (final log in _logs) {
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() ?? '';
if (studentId.isEmpty) continue;
@@ -260,18 +391,34 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
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 {
final checkIns = _todaysCheckInsByStudentId;
return _roster.map((u) {
final String id = u['id']?.toString() ?? '';
final checkIn = checkIns[id];
final violation = _activeViolationsByStudentId[id];
final String attendanceStatus = _computeAttendanceStatus(checkIn?['time']);
return {
'studentId': id,
'studentName': u['name']?.toString() ?? '',
'isCheckedIn': checkIn != null,
'pocketNumber': checkIn?['pocketNumber'],
'checkInTime': checkIn?['time'],
'attendanceStatus': attendanceStatus,
'isAttendanceComplete': attendanceStatus == 'COMPLETE',
'hasActiveViolation': violation != null,
'violationPocket': violation?['pocket_number'],
'violationTime': violation?['time'],
@@ -282,9 +429,9 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
List<Map<String, dynamic>> get _filteredStudents {
final all = _combinedStudentStatus;
if (_filterType == "CHECKED_IN") {
return all.where((s) => s['isCheckedIn'] == true).toList();
return all.where((s) => s['isAttendanceComplete'] == true).toList();
} else if (_filterType == "ABSENT") {
return all.where((s) => s['isCheckedIn'] == false).toList();
return all.where((s) => s['isAttendanceComplete'] == false).toList();
}
return all;
}
@@ -293,7 +440,8 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
Widget build(BuildContext context) {
final all = _combinedStudentStatus;
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;
return Scaffold(
@@ -321,6 +469,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
: 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),
@@ -337,6 +493,11 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
style: TextStyle(color: Colors.white),
),
),
IconButton(
onPressed: _showTestResetConfirmDialog,
icon: const Icon(Icons.bug_report_outlined, color: Colors.white70),
tooltip: '🧪 테스트용: 허용시간 초기화',
),
const SizedBox(width: 8),
],
),
@@ -376,6 +537,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
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,
@@ -404,21 +573,18 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: (hasViolation
? Colors.red
: (isCheckedIn ? Colors.blue : Colors.red))
.withValues(alpha: 0.1),
color: statusColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
hasViolation
? Icons.warning_amber_rounded
: (isCheckedIn
: (isPending
? Icons.hourglass_bottom_rounded
: (isComplete
? Icons.check_circle_rounded
: Icons.error_rounded),
color: hasViolation
? Colors.red
: (isCheckedIn ? Colors.blue : Colors.red),
: Icons.error_rounded)),
color: statusColor,
size: 24,
),
),
@@ -438,17 +604,21 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
Text(
hasViolation
? '🚨 무단반출 감지! (${student['violationTime']})'
: (isCheckedIn
: (isPending
? '학번: ${student['studentId']} | ⏳ 출석 미완료 (제출: ${student['checkInTime']})'
: (isComplete
? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}'
: '학번: ${student['studentId']} | 미제출'),
: '학번: ${student['studentId']} | 미제출')),
style: TextStyle(
color: hasViolation
? Colors.red[700]
: (isCheckedIn
: (isPending
? Colors.orange[800]
: (isComplete
? Colors.grey[600]
: Colors.red[400]),
fontSize: 12,
fontWeight: hasViolation
: Colors.red[400])),
fontSize: emphasizeTime ? 14 : 12,
fontWeight: (hasViolation || emphasizeTime)
? FontWeight.bold
: FontWeight.normal,
),
@@ -591,23 +761,34 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
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 DataRow(
color: hasViolation
? WidgetStateProperty.all(Colors.red[50])
: null,
: (isPending
? WidgetStateProperty.all(Colors.orange[50])
: null),
cells: [
DataCell(
Icon(
hasViolation
? Icons.warning_amber_rounded
: (isCheckedIn
: (isPending
? Icons.hourglass_bottom_rounded
: (isComplete
? Icons.check_circle_rounded
: Icons.error_rounded),
color: hasViolation
? Colors.red
: (isCheckedIn
? Colors.blue
: Colors.red),
: Icons.error_rounded)),
color: statusColor,
size: 20,
),
),
@@ -624,16 +805,21 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
Text(
hasViolation
? '🚨 무단반출 (${student['violationTime']})'
: (isCheckedIn
: (isPending
? '⏳ 출석 미완료 (제출: ${student['checkInTime']})'
: (isComplete
? '${student['checkInTime']}'
: '미제출'),
: '미제출')),
style: TextStyle(
color: hasViolation
? Colors.red[700]
: (isCheckedIn
: (isPending
? Colors.orange[800]
: (isComplete
? Colors.grey[700]
: Colors.red[400]),
fontWeight: hasViolation
: Colors.red[400])),
fontSize: emphasizeTime ? 15 : 14,
fontWeight: (hasViolation || emphasizeTime)
? FontWeight.bold
: FontWeight.normal,
),
+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
# 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.
version: 1.0.0+1
version: 1.1.3+4
environment:
sdk: ^3.12.2