아이폰 근접센서 주머니 감시, 학생 목록 타일 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:
2026-08-27 21:50:38 +09:00
co-authored by Claude Sonnet 5
parent f9fa05f817
commit 9354e2998a
8 changed files with 419 additions and 299 deletions
+152 -288
View File
@@ -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]),
),
),
),