아이폰 근접센서 주머니 감시, 학생 목록 타일 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 등 큰 파일이 섞여 들어옴)
|
# Gradle build output (실수로 커밋되면 리포트 HTML 등 큰 파일이 섞여 들어옴)
|
||||||
android/**/build/
|
android/**/build/
|
||||||
ios/**/build/
|
ios/**/build/
|
||||||
|
|
||||||
|
# Firebase CLI 로컬 캐시 (배포 이력 해시 등, 소스 아님)
|
||||||
|
.firebase/
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
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 */; };
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -122,6 +124,7 @@
|
|||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
E653BC1DC10E8731479786F7 /* ProximityMonitorPlugin.swift */,
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
);
|
);
|
||||||
@@ -282,6 +285,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
5FA389C5018E5592CC402E5C /* ProximityMonitorPlugin.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,5 +12,8 @@ import UIKit
|
|||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
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
|
// 예: flutter build web --dart-define=BASE_URL=https://다른학교도메인.com
|
||||||
const String baseUrl = String.fromEnvironment(
|
const String baseUrl = String.fromEnvironment(
|
||||||
'BASE_URL',
|
'BASE_URL',
|
||||||
defaultValue: 'https://backsanhi.mcv.kr',
|
defaultValue: 'https://api.backsanhi.shop',
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🔑 웹에서 FCM 토큰을 받으려면 필요한 VAPID 키
|
// 🔑 웹에서 FCM 토큰을 받으려면 필요한 VAPID 키
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'package:flutter/foundation.dart' show ChangeNotifier, kIsWeb;
|
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:flutter_background_service/flutter_background_service.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:nfc_manager/nfc_manager.dart';
|
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 'package:permission_handler/permission_handler.dart';
|
||||||
import '../config.dart' show baseUrl;
|
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 }
|
enum WatchMessageLevel { info, success, warning, error }
|
||||||
|
|
||||||
class NfcPocketCheckinController extends ChangeNotifier {
|
class NfcPocketCheckinController extends ChangeNotifier {
|
||||||
@@ -28,22 +35,32 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
/// 무단반출 감지 시 경고 팝업을 띄우기 위한 콜백.
|
/// 무단반출 감지 시 경고 팝업을 띄우기 위한 콜백.
|
||||||
final void Function(String? pocketNumber) onViolationDetected;
|
final void Function(String? pocketNumber) onViolationDetected;
|
||||||
|
|
||||||
|
/// iOS 출석 성공 직후, "가이드 접근"을 켜달라고 안내하는 팝업을 띄우기 위한 콜백.
|
||||||
|
/// (안드로이드는 화면이 꺼져도 백그라운드 서비스가 동작하니 이 안내가 필요 없다.)
|
||||||
|
final void Function(String pocketNumber) onNeedGuidedAccessSetup;
|
||||||
|
|
||||||
NfcPocketCheckinController({
|
NfcPocketCheckinController({
|
||||||
required this.studentId,
|
required this.studentId,
|
||||||
required this.studentName,
|
required this.studentName,
|
||||||
required this.onMessage,
|
required this.onMessage,
|
||||||
required this.onCheckInSuccess,
|
required this.onCheckInSuccess,
|
||||||
required this.onViolationDetected,
|
required this.onViolationDetected,
|
||||||
|
required this.onNeedGuidedAccessSetup,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool _isProcessing = false; // 중복 태깅 및 연타 방지 플래그
|
bool _isProcessing = false; // 중복 태깅 및 연타 방지 플래그
|
||||||
bool _isWatching = false; // 백그라운드 조도 센서 감시가 진행 중인지 여부
|
bool _isWatching = false; // 백그라운드 조도 센서(또는 iOS 근접센서) 감시가 진행 중인지 여부
|
||||||
|
bool _isIosProximityWatch = false; // 지금 진행 중인 감시가 iOS 근접센서 방식인지 여부 (체크아웃 분기용)
|
||||||
String _statusMessage = "주머니의 NFC 스티커에 폰 뒷면을 대어주세요.";
|
String _statusMessage = "주머니의 NFC 스티커에 폰 뒷면을 대어주세요.";
|
||||||
String? _activePocketNumber;
|
String? _activePocketNumber;
|
||||||
|
|
||||||
StreamSubscription<Map<String, dynamic>?>? _violationSub;
|
StreamSubscription<Map<String, dynamic>?>? _violationSub;
|
||||||
StreamSubscription<Map<String, dynamic>?>? _checkedOutSub;
|
StreamSubscription<Map<String, dynamic>?>? _checkedOutSub;
|
||||||
|
|
||||||
|
StreamSubscription<dynamic>? _proximitySub;
|
||||||
|
bool? _proximityBaselineNear; // 감시 시작 직후(주머니에 넣은 직후) 기준 상태
|
||||||
|
bool _proximityViolated = false;
|
||||||
|
|
||||||
bool get isProcessing => _isProcessing;
|
bool get isProcessing => _isProcessing;
|
||||||
bool get isWatching => _isWatching;
|
bool get isWatching => _isWatching;
|
||||||
String get statusMessage => _statusMessage;
|
String get statusMessage => _statusMessage;
|
||||||
@@ -51,6 +68,10 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
|
|
||||||
bool get watchServiceSupported => !kIsWeb && Platform.isAndroid;
|
bool get watchServiceSupported => !kIsWeb && Platform.isAndroid;
|
||||||
|
|
||||||
|
/// iOS는 진짜 백그라운드 감시가 불가능해서, "가이드 접근" 모드로 화면을 못 잠그게 한 뒤
|
||||||
|
/// 근접센서로 흉내내는 방식만 지원한다 (자세한 이유는 사용자와의 대화 참고).
|
||||||
|
bool get iosProximityWatchSupported => !kIsWeb && Platform.isIOS;
|
||||||
|
|
||||||
void init() {
|
void init() {
|
||||||
_startNfcSession();
|
_startNfcSession();
|
||||||
if (watchServiceSupported) {
|
if (watchServiceSupported) {
|
||||||
@@ -83,6 +104,12 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
NfcManager.instance.stopSession();
|
NfcManager.instance.stopSession();
|
||||||
_violationSub?.cancel();
|
_violationSub?.cancel();
|
||||||
_checkedOutSub?.cancel();
|
_checkedOutSub?.cancel();
|
||||||
|
// iOS 근접센서 감시는 (안드로이드와 달리) 이 컨트롤러 안에서 직접 도는 것이라,
|
||||||
|
// 화면을 나가면 같이 정리해야 한다 (안 그러면 dispose된 ChangeNotifier에 notifyListeners 호출로 에러남).
|
||||||
|
_proximitySub?.cancel();
|
||||||
|
if (_isIosProximityWatch) {
|
||||||
|
_proximityMethodChannel.invokeMethod('stop').catchError((_) {});
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +207,14 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
if (result["status"] == "success") {
|
if (result["status"] == "success") {
|
||||||
onCheckInSuccess(pocketNumber);
|
onCheckInSuccess(pocketNumber);
|
||||||
await _startBackgroundWatch(pocketNumber);
|
if (watchServiceSupported) {
|
||||||
|
await _startBackgroundWatch(pocketNumber);
|
||||||
|
} else if (iosProximityWatchSupported) {
|
||||||
|
// iOS는 바로 감시를 못 켜고, 먼저 "가이드 접근"을 켜달라고 안내해야 한다.
|
||||||
|
onNeedGuidedAccessSetup(pocketNumber);
|
||||||
|
} else {
|
||||||
|
onMessage("ℹ️ 이 기기는 백그라운드 감시를 지원하지 않습니다.", WatchMessageLevel.info);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
onMessage("⚠️ 출석 실패: ${result['message']}", WatchMessageLevel.warning);
|
onMessage("⚠️ 출석 실패: ${result['message']}", WatchMessageLevel.warning);
|
||||||
}
|
}
|
||||||
@@ -201,10 +235,7 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// NFC 태깅 성공 직후 호출. 배터리 최적화 예외를 한 번 요청한 뒤 백그라운드 감시 서비스를 시작한다.
|
/// NFC 태깅 성공 직후 호출. 배터리 최적화 예외를 한 번 요청한 뒤 백그라운드 감시 서비스를 시작한다.
|
||||||
Future<void> _startBackgroundWatch(String pocketNumber) async {
|
Future<void> _startBackgroundWatch(String pocketNumber) async {
|
||||||
if (!watchServiceSupported) {
|
if (!watchServiceSupported) return;
|
||||||
onMessage("ℹ️ 이 기기(iOS)는 백그라운드 감시를 지원하지 않습니다.", WatchMessageLevel.info);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 배터리 최적화 예외 요청 (이미 허용되어 있으면 시스템이 알아서 다이얼로그를 건너뜀)
|
// 배터리 최적화 예외 요청 (이미 허용되어 있으면 시스템이 알아서 다이얼로그를 건너뜀)
|
||||||
final batteryStatus = await Permission.ignoreBatteryOptimizations.status;
|
final batteryStatus = await Permission.ignoreBatteryOptimizations.status;
|
||||||
@@ -230,11 +261,127 @@ class NfcPocketCheckinController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// 감시 중일 때 다시 태깅하면 폰을 회수한 것으로 보고 감시를 종료한다.
|
/// 감시 중일 때 다시 태깅하면 폰을 회수한 것으로 보고 감시를 종료한다.
|
||||||
Future<void> _checkOut() async {
|
Future<void> _checkOut() async {
|
||||||
FlutterBackgroundService().invoke('stop_watch');
|
if (_isIosProximityWatch) {
|
||||||
|
await _stopIosProximityWatch();
|
||||||
|
} else {
|
||||||
|
FlutterBackgroundService().invoke('stop_watch');
|
||||||
|
}
|
||||||
_isWatching = false;
|
_isWatching = false;
|
||||||
_activePocketNumber = null;
|
_activePocketNumber = null;
|
||||||
_statusMessage = "✅ 폰을 회수했습니다. 감시가 종료되었습니다.";
|
_statusMessage = "✅ 폰을 회수했습니다. 감시가 종료되었습니다.";
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
onMessage("✅ 감시가 종료되었습니다. 수고하셨습니다!", WatchMessageLevel.success);
|
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 출석체크 화면 (UI 전용). 태깅 상태 화면/팝업/스낵바만 그린다.
|
||||||
// NFC 세션, 서버 통신, 백그라운드 감시 연동은
|
// NFC 세션, 서버 통신, 백그라운드 감시 연동은
|
||||||
// lib/function/nfc_pocket_checkin_controller.dart가 담당한다.
|
// 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 'package:flutter/material.dart';
|
||||||
import '../function/nfc_pocket_checkin_controller.dart';
|
import '../function/nfc_pocket_checkin_controller.dart';
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
onMessage: _handleMessage,
|
onMessage: _handleMessage,
|
||||||
onCheckInSuccess: _showSuccessDialog,
|
onCheckInSuccess: _showSuccessDialog,
|
||||||
onViolationDetected: _showViolationDialog,
|
onViolationDetected: _showViolationDialog,
|
||||||
|
onNeedGuidedAccessSetup: _showGuidedAccessDialog,
|
||||||
);
|
);
|
||||||
_controller.init();
|
_controller.init();
|
||||||
}
|
}
|
||||||
@@ -85,6 +88,37 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
).then((_) => _isDialogOpen = false);
|
).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) {
|
void _showSuccessDialog(String pocketNumber) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -222,10 +256,12 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
Text(
|
||||||
"📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
|
!kIsWeb && Platform.isIOS
|
||||||
|
? "🔒 가이드 접근을 켠 채로 화면만 잠기도록 두세요.\n(임의로 앱을 나가면 감시가 끊깁니다)\n감시를 끝내려면 가이드 접근을 먼저 끄고 다시 태깅하세요."
|
||||||
|
: "📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.white38, fontSize: 13),
|
style: const TextStyle(color: Colors.white38, fontSize: 13),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
+152
-288
@@ -303,162 +303,153 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
|||||||
style: TextStyle(color: Colors.grey),
|
style: TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: GridView.builder(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 220,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisExtent: 148,
|
||||||
|
),
|
||||||
itemCount: filteredStudents.length,
|
itemCount: filteredStudents.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) =>
|
||||||
final student = filteredStudents[index];
|
_buildStudentTile(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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 🀄 학생 한 명을 "한눈에 보기" 타일로 그린다. 모바일/데스크톱 그리드에서 공용으로 쓴다.
|
||||||
|
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),
|
style: TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: SingleChildScrollView(
|
: GridView.builder(
|
||||||
child: DataTable(
|
padding: const EdgeInsets.all(20),
|
||||||
headingRowColor: WidgetStateProperty.all(
|
gridDelegate:
|
||||||
Colors.grey[50],
|
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
),
|
maxCrossAxisExtent: 220,
|
||||||
columns: const [
|
mainAxisSpacing: 16,
|
||||||
DataColumn(label: Text('상태')),
|
crossAxisSpacing: 16,
|
||||||
DataColumn(label: Text('학번')),
|
mainAxisExtent: 148,
|
||||||
DataColumn(label: Text('이름')),
|
),
|
||||||
DataColumn(label: Text('제출 시간')),
|
itemCount: filteredStudents.length,
|
||||||
DataColumn(label: Text('주머니 번호')),
|
itemBuilder: (context, index) =>
|
||||||
DataColumn(label: Text('작업')),
|
_buildStudentTile(filteredStudents[index]),
|
||||||
],
|
|
||||||
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(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user