아이폰 근접센서 주머니 감시, 학생 목록 타일 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:
@@ -47,3 +47,6 @@ app.*.map.json
|
||||
# Gradle build output (실수로 커밋되면 리포트 HTML 등 큰 파일이 섞여 들어옴)
|
||||
android/**/build/
|
||||
ios/**/build/
|
||||
|
||||
# Firebase CLI 로컬 캐시 (배포 이력 해시 등, 소스 아님)
|
||||
.firebase/
|
||||
|
||||
@@ -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 */,
|
||||
);
|
||||
|
||||
@@ -12,5 +12,8 @@ import UIKit
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
ProximityMonitorPlugin.register(
|
||||
with: engineBridge.pluginRegistry.registrar(forPlugin: "ProximityMonitorPlugin")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 키
|
||||
|
||||
@@ -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 (_) {
|
||||
// 서버 신고가 실패해도 학생에게는 이미 팝업으로 알렸으니 조용히 무시.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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]),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user