아이폰 근접센서 주머니 감시, 학생 목록 타일 UI, 학교 서버 주소 반영
- iOS는 조도 센서/백그라운드 서비스가 불가능해서 근접센서(UIDevice proximityMonitoring) + 가이드 접근 안내로 대체 구현 (Core NFC와 마찬가지로 네이티브 플러그인 필요해서 ProximityMonitorPlugin.swift 추가) - 교사 대시보드 실시간 출석 현황: 모바일 리스트/데스크톱 표를 타일 그리드로 통일해서 한눈에 보기 쉽게 변경 - 서버를 학교 자체 서버(Cloudflare Tunnel, api.backsanhi.shop)로 이전하면서 기본 BASE_URL 갱신 - Firebase CLI 로컬 캐시(.firebase/)는 소스가 아니라 gitignore 처리 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
// 📲 자습실 NFC 출석체크 화면 (UI 전용). 태깅 상태 화면/팝업/스낵바만 그린다.
|
||||
// NFC 세션, 서버 통신, 백그라운드 감시 연동은
|
||||
// lib/function/nfc_pocket_checkin_controller.dart가 담당한다.
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/nfc_pocket_checkin_controller.dart';
|
||||
|
||||
@@ -31,6 +33,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
onMessage: _handleMessage,
|
||||
onCheckInSuccess: _showSuccessDialog,
|
||||
onViolationDetected: _showViolationDialog,
|
||||
onNeedGuidedAccessSetup: _showGuidedAccessDialog,
|
||||
);
|
||||
_controller.init();
|
||||
}
|
||||
@@ -85,6 +88,37 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
).then((_) => _isDialogOpen = false);
|
||||
}
|
||||
|
||||
/// 🛡️ iOS 전용: 근접센서 감시가 화면 잠금으로 끊기지 않도록, "가이드 접근"을
|
||||
/// 직접 켜달라고 안내하는 팝업. 학생이 켰다고 확인해야 실제 감시가 시작된다.
|
||||
void _showGuidedAccessDialog(String pocketNumber) {
|
||||
if (!mounted) return;
|
||||
_closeAnyOpenDialog();
|
||||
_isDialogOpen = true;
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("🛡️ 감시 모드 켜기 전에"),
|
||||
content: const Text(
|
||||
"iOS는 화면을 잠그면 감시가 끊겨요. 아래 순서로 '가이드 접근'을 먼저 켜주세요.\n\n"
|
||||
"1. 사이드(또는 홈) 버튼 3번 빠르게 누르기\n"
|
||||
"2. 목록에서 '가이드 접근' 선택 → 화면 오른쪽 아래 '시작' 누르기\n\n"
|
||||
"다 켜셨으면 아래 버튼을 눌러 감시를 시작하세요.\n"
|
||||
"(감시를 끝낼 땐 먼저 사이드 버튼 3번으로 가이드 접근을 끈 다음 다시 태깅하세요.)",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_controller.beginIosProximityWatch(pocketNumber);
|
||||
},
|
||||
child: const Text("가이드 접근 켰어요"),
|
||||
),
|
||||
],
|
||||
),
|
||||
).then((_) => _isDialogOpen = false);
|
||||
}
|
||||
|
||||
/// 🎊 출석 성공 알림창
|
||||
void _showSuccessDialog(String pocketNumber) {
|
||||
if (!mounted) return;
|
||||
@@ -222,10 +256,12 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
|
||||
Text(
|
||||
!kIsWeb && Platform.isIOS
|
||||
? "🔒 가이드 접근을 켠 채로 화면만 잠기도록 두세요.\n(임의로 앱을 나가면 감시가 끊깁니다)\n감시를 끝내려면 가이드 접근을 먼저 끄고 다시 태깅하세요."
|
||||
: "📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white38, fontSize: 13),
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+152
-288
@@ -303,162 +303,153 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 220,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisExtent: 148,
|
||||
),
|
||||
itemCount: filteredStudents.length,
|
||||
itemBuilder: (context, index) {
|
||||
final student = filteredStudents[index];
|
||||
final bool isCheckedIn = student['isCheckedIn'];
|
||||
final bool hasViolation =
|
||||
student['hasActiveViolation'] == true;
|
||||
final bool isPending =
|
||||
student['attendanceStatus'] == 'PENDING';
|
||||
final bool isComplete =
|
||||
student['attendanceStatus'] == 'COMPLETE';
|
||||
final bool emphasizeTime =
|
||||
isComplete && _controller.attendanceTime != null;
|
||||
final Color statusColor = hasViolation
|
||||
? Colors.red
|
||||
: (isPending
|
||||
? Colors.orange
|
||||
: (isComplete ? Colors.blue : Colors.red));
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: hasViolation ? Colors.red[50] : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: hasViolation
|
||||
? Border.all(color: Colors.red[300]!, width: 1.5)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
hasViolation
|
||||
? Icons.warning_amber_rounded
|
||||
: (isPending
|
||||
? Icons.hourglass_bottom_rounded
|
||||
: (isComplete
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.error_rounded)),
|
||||
color: statusColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${student['studentName']} 학생',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
hasViolation
|
||||
? '🚨 무단반출 감지! (${student['violationTime']})'
|
||||
: (isPending
|
||||
? '학번: ${student['studentId']} | ⏳ 출석 미완료 (제출: ${student['checkInTime']})'
|
||||
: (isComplete
|
||||
? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}'
|
||||
: (student['hasEverCheckedIn'] ==
|
||||
true
|
||||
? '학번: ${student['studentId']} | 미제출'
|
||||
: '학번: ${student['studentId']} | 미등록'))),
|
||||
style: TextStyle(
|
||||
color: hasViolation
|
||||
? Colors.red[700]
|
||||
: (isPending
|
||||
? Colors.orange[800]
|
||||
: (isComplete
|
||||
? Colors.grey[600]
|
||||
: Colors.red[400])),
|
||||
fontSize: emphasizeTime ? 14 : 12,
|
||||
fontWeight: (hasViolation || emphasizeTime)
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isCheckedIn && student['pocketNumber'] != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.blue.shade200,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
student['pocketNumber'],
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blueAccent,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasViolation)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _showAllowDialog(
|
||||
student['studentId'],
|
||||
student['studentName'],
|
||||
),
|
||||
icon: const Icon(Icons.check, size: 18),
|
||||
label: const Text('반출 허용'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[600],
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
itemBuilder: (context, index) =>
|
||||
_buildStudentTile(filteredStudents[index]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 🀄 학생 한 명을 "한눈에 보기" 타일로 그린다. 모바일/데스크톱 그리드에서 공용으로 쓴다.
|
||||
Widget _buildStudentTile(Map<String, dynamic> student) {
|
||||
final bool isCheckedIn = student['isCheckedIn'];
|
||||
final bool hasViolation = student['hasActiveViolation'] == true;
|
||||
final bool isPending = student['attendanceStatus'] == 'PENDING';
|
||||
final bool isComplete = student['attendanceStatus'] == 'COMPLETE';
|
||||
final Color statusColor = hasViolation
|
||||
? Colors.red
|
||||
: (isPending ? Colors.orange : (isComplete ? Colors.blue : Colors.red));
|
||||
final String statusLine = hasViolation
|
||||
? '🚨 무단반출 (${student['violationTime']})'
|
||||
: (isPending
|
||||
? '⏳ 미완료 (${student['checkInTime']})'
|
||||
: (isComplete
|
||||
? '${student['checkInTime']}'
|
||||
: (student['hasEverCheckedIn'] == true ? '미제출' : '미등록')));
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
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(8),
|
||||
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: 18,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isCheckedIn && student['pocketNumber'] != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
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: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${student['studentName']}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
Text(
|
||||
'학번 ${student['studentId']}',
|
||||
style: TextStyle(color: Colors.grey[500], fontSize: 11),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
statusLine,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: hasViolation
|
||||
? Colors.red[700]
|
||||
: (isPending
|
||||
? Colors.orange[800]
|
||||
: (isComplete ? Colors.grey[600] : Colors.red[400])),
|
||||
fontSize: 11.5,
|
||||
fontWeight: hasViolation ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
if (hasViolation) ...[
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 30,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _showAllowDialog(
|
||||
student['studentId'],
|
||||
student['studentName'],
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[600],
|
||||
foregroundColor: Colors.white,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: const Text('반출 허용', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -527,145 +518,18 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: DataTable(
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
Colors.grey[50],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('상태')),
|
||||
DataColumn(label: Text('학번')),
|
||||
DataColumn(label: Text('이름')),
|
||||
DataColumn(label: Text('제출 시간')),
|
||||
DataColumn(label: Text('주머니 번호')),
|
||||
DataColumn(label: Text('작업')),
|
||||
],
|
||||
rows: filteredStudents.map((student) {
|
||||
final bool isCheckedIn = student['isCheckedIn'];
|
||||
final bool hasViolation =
|
||||
student['hasActiveViolation'] == true;
|
||||
final bool isPending =
|
||||
student['attendanceStatus'] == 'PENDING';
|
||||
final bool isComplete =
|
||||
student['attendanceStatus'] == 'COMPLETE';
|
||||
final bool emphasizeTime =
|
||||
isComplete && _controller.attendanceTime != null;
|
||||
final Color statusColor = hasViolation
|
||||
? Colors.red
|
||||
: (isPending
|
||||
? Colors.orange
|
||||
: (isComplete ? Colors.blue : Colors.red));
|
||||
return DataRow(
|
||||
color: hasViolation
|
||||
? WidgetStateProperty.all(Colors.red[50])
|
||||
: (isPending
|
||||
? WidgetStateProperty.all(
|
||||
Colors.orange[50],
|
||||
)
|
||||
: null),
|
||||
cells: [
|
||||
DataCell(
|
||||
Icon(
|
||||
hasViolation
|
||||
? Icons.warning_amber_rounded
|
||||
: (isPending
|
||||
? Icons.hourglass_bottom_rounded
|
||||
: (isComplete
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.error_rounded)),
|
||||
color: statusColor,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
DataCell(Text('${student['studentId']}')),
|
||||
DataCell(
|
||||
Text(
|
||||
'${student['studentName']}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
hasViolation
|
||||
? '🚨 무단반출 (${student['violationTime']})'
|
||||
: (isPending
|
||||
? '⏳ 출석 미완료 (제출: ${student['checkInTime']})'
|
||||
: (isComplete
|
||||
? '${student['checkInTime']}'
|
||||
: (student['hasEverCheckedIn'] ==
|
||||
true
|
||||
? '미제출'
|
||||
: '미등록'))),
|
||||
style: TextStyle(
|
||||
color: hasViolation
|
||||
? Colors.red[700]
|
||||
: (isPending
|
||||
? Colors.orange[800]
|
||||
: (isComplete
|
||||
? Colors.grey[700]
|
||||
: Colors.red[400])),
|
||||
fontSize: emphasizeTime ? 15 : 14,
|
||||
fontWeight: (hasViolation || emphasizeTime)
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
isCheckedIn && student['pocketNumber'] != null
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius:
|
||||
BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.blue.shade200,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
student['pocketNumber'],
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blueAccent,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text('-'),
|
||||
),
|
||||
DataCell(
|
||||
hasViolation
|
||||
? ElevatedButton.icon(
|
||||
onPressed: () => _showAllowDialog(
|
||||
student['studentId'],
|
||||
student['studentName'],
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
),
|
||||
label: const Text('반출 허용'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[600],
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text('-'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.all(20),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 220,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisExtent: 148,
|
||||
),
|
||||
itemCount: filteredStudents.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildStudentTile(filteredStudents[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user