Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e12b279e2 | ||
|
|
d864927957 | ||
|
|
61aedac312 | ||
|
|
5ab1c4c37c |
@@ -34,6 +34,7 @@ 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;
|
||||
@@ -53,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +72,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
// 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다.
|
||||
NfcManager.instance.stopSession();
|
||||
_violationSub?.cancel();
|
||||
_checkedOutSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -308,7 +308,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
if (role == 'teacher') {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const TeacherDashboard()),
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
TeacherDashboard(teacherId: studentId, teacherName: name),
|
||||
),
|
||||
);
|
||||
} else if (role == 'admin') {
|
||||
Navigator.pushReplacement(
|
||||
|
||||
@@ -26,6 +26,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
Map<String, dynamic> _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active)
|
||||
String? _dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시.
|
||||
String? _attendanceTime; // 선생님이 지정한 자습실 출석시간 "HH:MM" (/api/settings/attendance-time). null이면 미설정.
|
||||
String? _globalPermissionUntil; // 하교(12시간)/반출허용시간설정으로 전체 반출이 허용된 경우 그 만료 시각 (/api/permissions/status). null이면 비활성.
|
||||
Timer? _timer;
|
||||
bool _isLoading = true;
|
||||
bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용
|
||||
@@ -58,6 +59,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
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')),
|
||||
http.get(Uri.parse('$baseUrl/api/permissions/status')),
|
||||
]);
|
||||
|
||||
final usersRes = results[0];
|
||||
@@ -65,6 +67,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
final violationsRes = results[2];
|
||||
final dismissalRes = results[3];
|
||||
final attendanceTimeRes = results[4];
|
||||
final permissionStatusRes = results[5];
|
||||
|
||||
if (usersRes.statusCode == 200 && logsRes.statusCode == 200) {
|
||||
final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes));
|
||||
@@ -91,12 +94,22 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
attendanceTime = attendanceTimeData['attendanceTime'];
|
||||
}
|
||||
|
||||
String? globalPermissionUntil;
|
||||
if (permissionStatusRes.statusCode == 200) {
|
||||
final permissionStatusData =
|
||||
jsonDecode(utf8.decode(permissionStatusRes.bodyBytes));
|
||||
if (permissionStatusData['active'] == true) {
|
||||
globalPermissionUntil = permissionStatusData['permittedUntil'];
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_roster = usersData['users'] ?? [];
|
||||
_logs = logsData['logs'] ?? [];
|
||||
_activeViolationsByStudentId = activeViolations;
|
||||
_dismissedAt = dismissedAt;
|
||||
_attendanceTime = attendanceTime;
|
||||
_globalPermissionUntil = globalPermissionUntil;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
@@ -391,6 +404,17 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 지금까지(오늘 이전 포함) 단 한 번이라도 태깅한 적 있는 학생 학번 집합.
|
||||
/// (오늘 미제출인 학생 중에서도 "한 번도 태깅 안 해본 애"를 구분하기 위함)
|
||||
Set<String> get _everCheckedInStudentIds {
|
||||
final Set<String> ids = {};
|
||||
for (final log in _logs) {
|
||||
final String studentId = log['student_id']?.toString() ?? '';
|
||||
if (studentId.isNotEmpty) ids.add(studentId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// ⏰ 태깅 시간과 지정된 자습실 출석시간을 비교해 'NONE' / 'PENDING' / 'COMPLETE'를 반환한다.
|
||||
/// - NONE: 아직 태깅 안 함
|
||||
/// - PENDING: 태깅은 했지만 지정된 출석시간 이전이라 아직 "출석 완료"로 안 침
|
||||
@@ -406,6 +430,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
|
||||
List<Map<String, dynamic>> get _combinedStudentStatus {
|
||||
final checkIns = _todaysCheckInsByStudentId;
|
||||
final everCheckedIn = _everCheckedInStudentIds;
|
||||
return _roster.map((u) {
|
||||
final String id = u['id']?.toString() ?? '';
|
||||
final checkIn = checkIns[id];
|
||||
@@ -419,6 +444,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
'checkInTime': checkIn?['time'],
|
||||
'attendanceStatus': attendanceStatus,
|
||||
'isAttendanceComplete': attendanceStatus == 'COMPLETE',
|
||||
'hasEverCheckedIn': everCheckedIn.contains(id),
|
||||
'hasActiveViolation': violation != null,
|
||||
'violationPocket': violation?['pocket_number'],
|
||||
'violationTime': violation?['time'],
|
||||
@@ -521,6 +547,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
return Column(
|
||||
children: [
|
||||
_buildSummaryCards(total, checkedIn, absent),
|
||||
_buildPermissionBanner(),
|
||||
_buildFilterChips(),
|
||||
Expanded(
|
||||
child: _filteredStudents.isEmpty
|
||||
@@ -608,7 +635,9 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
? '학번: ${student['studentId']} | ⏳ 출석 미완료 (제출: ${student['checkInTime']})'
|
||||
: (isComplete
|
||||
? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}'
|
||||
: '학번: ${student['studentId']} | 미제출')),
|
||||
: (student['hasEverCheckedIn'] == true
|
||||
? '학번: ${student['studentId']} | 미제출'
|
||||
: '학번: ${student['studentId']} | 미등록'))),
|
||||
style: TextStyle(
|
||||
color: hasViolation
|
||||
? Colors.red[700]
|
||||
@@ -721,6 +750,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPermissionBanner(),
|
||||
_buildFilterChips(),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
@@ -809,7 +839,9 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
? '⏳ 출석 미완료 (제출: ${student['checkInTime']})'
|
||||
: (isComplete
|
||||
? '${student['checkInTime']}'
|
||||
: '미제출')),
|
||||
: (student['hasEverCheckedIn'] == true
|
||||
? '미제출'
|
||||
: '미등록'))),
|
||||
style: TextStyle(
|
||||
color: hasViolation
|
||||
? Colors.red[700]
|
||||
@@ -971,6 +1003,39 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
}
|
||||
|
||||
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
|
||||
/// 🔓 하교(12시간)/반출 허용 시간 설정으로 지금 전체 반출이 허용 중이면 눈에 띄게 배너로 알려준다.
|
||||
/// (허용 중일 땐 무단반출을 감지해도 대시보드에 뜨지 않기 때문에, 왜 안 뜨는지 헷갈리지 않게 하기 위함)
|
||||
Widget _buildPermissionBanner() {
|
||||
if (_globalPermissionUntil == 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(
|
||||
'🔓 지금 전체 반출 허용 중입니다 ($_globalPermissionUntil 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
|
||||
style: TextStyle(
|
||||
color: Colors.amber[900],
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChips() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
|
||||
+1
-1
@@ -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.1.2+3
|
||||
version: 1.1.4+5
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
Reference in New Issue
Block a user