아이폰 근접센서 주머니 감시, 학생 목록 타일 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
+3
View File
@@ -47,3 +47,6 @@ app.*.map.json
# Gradle build output (실수로 커밋되면 리포트 HTML 등 큰 파일이 섞여 들어옴)
android/**/build/
ios/**/build/
# Firebase CLI 로컬 캐시 (배포 이력 해시 등, 소스 아님)
.firebase/
+4
View File
@@ -11,6 +11,7 @@
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
5FA389C5018E5592CC402E5C /* ProximityMonitorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E653BC1DC10E8731479786F7 /* ProximityMonitorPlugin.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
@@ -49,6 +50,7 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
E653BC1DC10E8731479786F7 /* ProximityMonitorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityMonitorPlugin.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -122,6 +124,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
E653BC1DC10E8731479786F7 /* ProximityMonitorPlugin.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -282,6 +285,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
5FA389C5018E5592CC402E5C /* ProximityMonitorPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
+3
View File
@@ -12,5 +12,8 @@ import UIKit
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
ProximityMonitorPlugin.register(
with: engineBridge.pluginRegistry.registrar(forPlugin: "ProximityMonitorPlugin")
)
}
}
+63
View File
@@ -0,0 +1,63 @@
// 🛡️ iOS 근접센서로 "주머니 감시"를 흉내내기 위한 네이티브 플러그인.
// UIDevice의 근접센서를 켜면 통화 중처럼 화면이 자동으로 꺼지고 켜지는데,
// 그 상태 변화(가까움/멀어짐)를 Dart 쪽 EventChannel로 그대로 흘려보낸다.
import Flutter
import UIKit
class ProximityMonitorPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
private var eventSink: FlutterEventSink?
static func register(with registrar: FlutterPluginRegistrar) {
let instance = ProximityMonitorPlugin()
let methodChannel = FlutterMethodChannel(
name: "proximity_monitor",
binaryMessenger: registrar.messenger()
)
methodChannel.setMethodCallHandler(instance.handle)
let eventChannel = FlutterEventChannel(
name: "proximity_monitor/events",
binaryMessenger: registrar.messenger()
)
eventChannel.setStreamHandler(instance)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "start":
UIDevice.current.isProximityMonitoringEnabled = true
result(nil)
case "stop":
UIDevice.current.isProximityMonitoringEnabled = false
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
eventSink = events
NotificationCenter.default.addObserver(
self,
selector: #selector(proximityStateChanged),
name: UIDevice.proximityStateDidChangeNotification,
object: nil
)
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
NotificationCenter.default.removeObserver(
self,
name: UIDevice.proximityStateDidChangeNotification,
object: nil
)
eventSink = nil
return nil
}
@objc private func proximityStateChanged() {
eventSink?(UIDevice.current.proximityState)
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
// 예: flutter build web --dart-define=BASE_URL=https://다른학교도메인.com
const String baseUrl = String.fromEnvironment(
'BASE_URL',
defaultValue: 'https://backsanhi.mcv.kr',
defaultValue: 'https://api.backsanhi.shop',
);
// 🔑 웹에서 FCM 토큰을 받으려면 필요한 VAPID 키
+152 -5
View File
@@ -5,6 +5,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show ChangeNotifier, kIsWeb;
import 'package:flutter/services.dart' show EventChannel, MethodChannel;
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:http/http.dart' as http;
import 'package:nfc_manager/nfc_manager.dart';
@@ -13,6 +14,12 @@ import 'package:nfc_manager_ndef/nfc_manager_ndef.dart';
import 'package:permission_handler/permission_handler.dart';
import '../config.dart' show baseUrl;
// 🛡️ iOS 근접센서 감시용 네이티브 채널 (ios/Runner/ProximityMonitorPlugin.swift 참고).
// 조도 센서 API가 없는 iOS에서, 통화 중 화면이 꺼지는 것과 같은 원리인
// 근접센서로 "주머니 안/밖" 상태 변화를 감지해 대체한다.
const MethodChannel _proximityMethodChannel = MethodChannel('proximity_monitor');
const EventChannel _proximityEventChannel = EventChannel('proximity_monitor/events');
enum WatchMessageLevel { info, success, warning, error }
class NfcPocketCheckinController extends ChangeNotifier {
@@ -28,22 +35,32 @@ class NfcPocketCheckinController extends ChangeNotifier {
/// 무단반출 감지 시 경고 팝업을 띄우기 위한 콜백.
final void Function(String? pocketNumber) onViolationDetected;
/// iOS 출석 성공 직후, "가이드 접근"을 켜달라고 안내하는 팝업을 띄우기 위한 콜백.
/// (안드로이드는 화면이 꺼져도 백그라운드 서비스가 동작하니 이 안내가 필요 없다.)
final void Function(String pocketNumber) onNeedGuidedAccessSetup;
NfcPocketCheckinController({
required this.studentId,
required this.studentName,
required this.onMessage,
required this.onCheckInSuccess,
required this.onViolationDetected,
required this.onNeedGuidedAccessSetup,
});
bool _isProcessing = false; // 중복 태깅 및 연타 방지 플래그
bool _isWatching = false; // 백그라운드 조도 센서 감시가 진행 중인지 여부
bool _isWatching = false; // 백그라운드 조도 센서(또는 iOS 근접센서) 감시가 진행 중인지 여부
bool _isIosProximityWatch = false; // 지금 진행 중인 감시가 iOS 근접센서 방식인지 여부 (체크아웃 분기용)
String _statusMessage = "주머니의 NFC 스티커에 폰 뒷면을 대어주세요.";
String? _activePocketNumber;
StreamSubscription<Map<String, dynamic>?>? _violationSub;
StreamSubscription<Map<String, dynamic>?>? _checkedOutSub;
StreamSubscription<dynamic>? _proximitySub;
bool? _proximityBaselineNear; // 감시 시작 직후(주머니에 넣은 직후) 기준 상태
bool _proximityViolated = false;
bool get isProcessing => _isProcessing;
bool get isWatching => _isWatching;
String get statusMessage => _statusMessage;
@@ -51,6 +68,10 @@ class NfcPocketCheckinController extends ChangeNotifier {
bool get watchServiceSupported => !kIsWeb && Platform.isAndroid;
/// iOS는 진짜 백그라운드 감시가 불가능해서, "가이드 접근" 모드로 화면을 못 잠그게 한 뒤
/// 근접센서로 흉내내는 방식만 지원한다 (자세한 이유는 사용자와의 대화 참고).
bool get iosProximityWatchSupported => !kIsWeb && Platform.isIOS;
void init() {
_startNfcSession();
if (watchServiceSupported) {
@@ -83,6 +104,12 @@ class NfcPocketCheckinController extends ChangeNotifier {
NfcManager.instance.stopSession();
_violationSub?.cancel();
_checkedOutSub?.cancel();
// iOS 근접센서 감시는 (안드로이드와 달리) 이 컨트롤러 안에서 직접 도는 것이라,
// 화면을 나가면 같이 정리해야 한다 (안 그러면 dispose된 ChangeNotifier에 notifyListeners 호출로 에러남).
_proximitySub?.cancel();
if (_isIosProximityWatch) {
_proximityMethodChannel.invokeMethod('stop').catchError((_) {});
}
super.dispose();
}
@@ -180,7 +207,14 @@ class NfcPocketCheckinController extends ChangeNotifier {
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (result["status"] == "success") {
onCheckInSuccess(pocketNumber);
if (watchServiceSupported) {
await _startBackgroundWatch(pocketNumber);
} else if (iosProximityWatchSupported) {
// iOS는 바로 감시를 못 켜고, 먼저 "가이드 접근"을 켜달라고 안내해야 한다.
onNeedGuidedAccessSetup(pocketNumber);
} else {
onMessage("ℹ️ 이 기기는 백그라운드 감시를 지원하지 않습니다.", WatchMessageLevel.info);
}
} else {
onMessage("⚠️ 출석 실패: ${result['message']}", WatchMessageLevel.warning);
}
@@ -201,10 +235,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
/// NFC 태깅 성공 직후 호출. 배터리 최적화 예외를 한 번 요청한 뒤 백그라운드 감시 서비스를 시작한다.
Future<void> _startBackgroundWatch(String pocketNumber) async {
if (!watchServiceSupported) {
onMessage("ℹ️ 이 기기(iOS)는 백그라운드 감시를 지원하지 않습니다.", WatchMessageLevel.info);
return;
}
if (!watchServiceSupported) return;
// 배터리 최적화 예외 요청 (이미 허용되어 있으면 시스템이 알아서 다이얼로그를 건너뜀)
final batteryStatus = await Permission.ignoreBatteryOptimizations.status;
@@ -230,11 +261,127 @@ class NfcPocketCheckinController extends ChangeNotifier {
/// 감시 중일 때 다시 태깅하면 폰을 회수한 것으로 보고 감시를 종료한다.
Future<void> _checkOut() async {
if (_isIosProximityWatch) {
await _stopIosProximityWatch();
} else {
FlutterBackgroundService().invoke('stop_watch');
}
_isWatching = false;
_activePocketNumber = null;
_statusMessage = "✅ 폰을 회수했습니다. 감시가 종료되었습니다.";
notifyListeners();
onMessage("✅ 감시가 종료되었습니다. 수고하셨습니다!", WatchMessageLevel.success);
}
// -----------------------------------------------------------------------
// 🛡️ iOS 전용: 근접센서 + 가이드 접근으로 흉내내는 감시 모드
//
// iOS는 조도 센서 API가 없고, 앱이 백그라운드로 가면(=화면을 잠그면) 그 즉시
// 서스펜드되어 어떤 센서도 못 읽는다. 그래서 학생이 "가이드 접근"(iOS 키오스크 모드)을
// 직접 켜서 잠금을 막아야 하고, 그 위에서 근접센서로 화면만 통화 중처럼 껐다 켰다
// 하면서(UIDevice.isProximityMonitoringEnabled) 폰이 주머니에서 빠졌는지를 감지한다.
// -----------------------------------------------------------------------
/// UI가 "가이드 접근을 켰다"는 확인을 받은 뒤 호출. 근접센서 감시를 시작한다.
Future<void> beginIosProximityWatch(String pocketNumber) async {
_proximitySub?.cancel();
_proximityBaselineNear = null;
_proximityViolated = false;
_isIosProximityWatch = true;
_activePocketNumber = pocketNumber;
_isWatching = true;
_statusMessage = "📥 주머니에 넣는 중...";
notifyListeners();
try {
await _proximityMethodChannel.invokeMethod('start');
} catch (e) {
onMessage("❌ 근접센서 감시 시작 실패: $e", WatchMessageLevel.error);
_isWatching = false;
_isIosProximityWatch = false;
notifyListeners();
return;
}
// 학생이 폰을 주머니에 넣을 시간을 준 뒤, 그 시점 상태를 기준값으로 삼는다.
await Future.delayed(const Duration(seconds: 4));
_statusMessage = "🛡️ 감시 중입니다. 화면을 잠그지 말고 주머니에 넣어주세요.";
notifyListeners();
_proximitySub = _proximityEventChannel.receiveBroadcastStream().listen((
event,
) {
final bool isNear = event as bool;
if (_proximityBaselineNear == null) {
_proximityBaselineNear = isNear;
return;
}
if (_proximityViolated) return;
if (isNear != _proximityBaselineNear) {
_proximityViolated = true;
_handleIosPhoneRemoved(pocketNumber);
}
});
}
Future<void> _stopIosProximityWatch() async {
await _proximitySub?.cancel();
_proximitySub = null;
_isIosProximityWatch = false;
try {
await _proximityMethodChannel.invokeMethod('stop');
} catch (_) {
// 이미 감시가 꺼져 있어도 상관없으니 조용히 무시.
}
}
/// 하교 처리/반출 허용 시간대라면 조용히 감시만 종료하고, 아니면 위반으로 경고한다.
/// (안드로이드 백그라운드 서비스의 handlePhoneRemoved와 동일한 판정 로직)
Future<void> _handleIosPhoneRemoved(String pocketNumber) async {
await _stopIosProximityWatch();
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; // 네트워크 오류 시엔 안전하게 위반으로 처리
}
_isWatching = false;
if (isPermitted) {
_activePocketNumber = null;
_statusMessage = "✅ 폰을 회수했습니다. 수고하셨습니다!";
notifyListeners();
onMessage("✅ 폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
return;
}
notifyListeners();
onViolationDetected(pocketNumber);
try {
await http.post(
Uri.parse("$baseUrl/attendance/violation"),
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": studentId,
"studentName": studentName,
"pocketNumber": pocketNumber,
"type": "PHONE_REMOVED",
"timestamp": DateTime.now().toIso8601String(),
}),
);
} catch (_) {
// 서버 신고가 실패해도 학생에게는 이미 팝업으로 알렸으니 조용히 무시.
}
}
}
+39 -3
View File
@@ -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),
),
],
),
+83 -219
View File
@@ -303,31 +303,43 @@ 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];
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 bool emphasizeTime =
isComplete && _controller.attendanceTime != null;
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
? Colors.orange
: (isComplete ? Colors.blue : Colors.red));
? '⏳ 미완료 (${student['checkInTime']})'
: (isComplete
? '${student['checkInTime']}'
: (student['hasEverCheckedIn'] == true ? '미제출' : '미등록')));
return Container(
margin: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
padding: const EdgeInsets.all(18),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: hasViolation ? Colors.red[50] : Colors.white,
borderRadius: BorderRadius.circular(20),
@@ -348,7 +360,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
@@ -362,100 +374,79 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
? 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,
),
),
],
size: 18,
),
),
const Spacer(),
if (isCheckedIn && student['pocketNumber'] != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.blue.shade200,
),
border: Border.all(color: Colors.blue.shade200),
),
child: Text(
student['pocketNumber'],
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blueAccent,
fontSize: 12,
fontSize: 11,
),
),
),
],
),
if (hasViolation)
Padding(
padding: const EdgeInsets.only(top: 12),
child: SizedBox(
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,
child: ElevatedButton.icon(
height: 30,
child: ElevatedButton(
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,
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]),
),
),
),