From 9354e2998a4772cc9a8d65118446fc602fb26b64 Mon Sep 17 00:00:00 2001 From: sihoo Date: Thu, 27 Aug 2026 21:50:38 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=84=EC=9D=B4=ED=8F=B0=20=EA=B7=BC?= =?UTF-8?q?=EC=A0=91=EC=84=BC=EC=84=9C=20=EC=A3=BC=EB=A8=B8=EB=8B=88=20?= =?UTF-8?q?=EA=B0=90=EC=8B=9C,=20=ED=95=99=EC=83=9D=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=ED=83=80=EC=9D=BC=20UI,=20=ED=95=99=EA=B5=90=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=A3=BC=EC=86=8C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iOS는 조도 센서/백그라운드 서비스가 불가능해서 근접센서(UIDevice proximityMonitoring) + 가이드 접근 안내로 대체 구현 (Core NFC와 마찬가지로 네이티브 플러그인 필요해서 ProximityMonitorPlugin.swift 추가) - 교사 대시보드 실시간 출석 현황: 모바일 리스트/데스크톱 표를 타일 그리드로 통일해서 한눈에 보기 쉽게 변경 - 서버를 학교 자체 서버(Cloudflare Tunnel, api.backsanhi.shop)로 이전하면서 기본 BASE_URL 갱신 - Firebase CLI 로컬 캐시(.firebase/)는 소스가 아니라 gitignore 처리 Co-Authored-By: Claude Sonnet 5 --- .gitignore | 3 + ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 3 + ios/Runner/ProximityMonitorPlugin.swift | 63 +++ lib/config.dart | 2 +- .../nfc_pocket_checkin_controller.dart | 161 ++++++- lib/ui/nfc_poccket_checkin_screen.dart | 42 +- lib/ui/teacher_attendance_page.dart | 440 ++++++------------ 8 files changed, 419 insertions(+), 299 deletions(-) create mode 100644 ios/Runner/ProximityMonitorPlugin.swift diff --git a/.gitignore b/.gitignore index d4749c5..bbeed7a 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ app.*.map.json # Gradle build output (실수로 커밋되면 리포트 HTML 등 큰 파일이 섞여 들어옴) android/**/build/ ios/**/build/ + +# Firebase CLI 로컬 캐시 (배포 이력 해시 등, 소스 아님) +.firebase/ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ac61e60..54028f6 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + E653BC1DC10E8731479786F7 /* ProximityMonitorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityMonitorPlugin.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -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 */, ); diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..5256039 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -12,5 +12,8 @@ import UIKit func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + ProximityMonitorPlugin.register( + with: engineBridge.pluginRegistry.registrar(forPlugin: "ProximityMonitorPlugin") + ) } } diff --git a/ios/Runner/ProximityMonitorPlugin.swift b/ios/Runner/ProximityMonitorPlugin.swift new file mode 100644 index 0000000..bb82fc9 --- /dev/null +++ b/ios/Runner/ProximityMonitorPlugin.swift @@ -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) + } +} diff --git a/lib/config.dart b/lib/config.dart index 80c8de9..209e4f8 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -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 키 diff --git a/lib/function/nfc_pocket_checkin_controller.dart b/lib/function/nfc_pocket_checkin_controller.dart index 56eef73..06407c0 100644 --- a/lib/function/nfc_pocket_checkin_controller.dart +++ b/lib/function/nfc_pocket_checkin_controller.dart @@ -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?>? _violationSub; StreamSubscription?>? _checkedOutSub; + StreamSubscription? _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); - await _startBackgroundWatch(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 _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 _checkOut() async { - FlutterBackgroundService().invoke('stop_watch'); + 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 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 _stopIosProximityWatch() async { + await _proximitySub?.cancel(); + _proximitySub = null; + _isIosProximityWatch = false; + try { + await _proximityMethodChannel.invokeMethod('stop'); + } catch (_) { + // 이미 감시가 꺼져 있어도 상관없으니 조용히 무시. + } + } + + /// 하교 처리/반출 허용 시간대라면 조용히 감시만 종료하고, 아니면 위반으로 경고한다. + /// (안드로이드 백그라운드 서비스의 handlePhoneRemoved와 동일한 판정 로직) + Future _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 (_) { + // 서버 신고가 실패해도 학생에게는 이미 팝업으로 알렸으니 조용히 무시. + } + } } diff --git a/lib/ui/nfc_poccket_checkin_screen.dart b/lib/ui/nfc_poccket_checkin_screen.dart index 20c042f..645e039 100644 --- a/lib/ui/nfc_poccket_checkin_screen.dart +++ b/lib/ui/nfc_poccket_checkin_screen.dart @@ -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 { onMessage: _handleMessage, onCheckInSuccess: _showSuccessDialog, onViolationDetected: _showViolationDialog, + onNeedGuidedAccessSetup: _showGuidedAccessDialog, ); _controller.init(); } @@ -85,6 +88,37 @@ class _NfcPocketCheckInScreenState extends State { ).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 { ), ), 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), ), ], ), diff --git a/lib/ui/teacher_attendance_page.dart b/lib/ui/teacher_attendance_page.dart index 06ae154..df9bdf2 100644 --- a/lib/ui/teacher_attendance_page.dart +++ b/lib/ui/teacher_attendance_page.dart @@ -303,162 +303,153 @@ class _TeacherAttendancePageState extends State { 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 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 { 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]), ), ), ),