아이폰 근접센서 주머니 감시, 학생 목록 타일 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:
@@ -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);
|
||||
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<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 {
|
||||
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<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 (_) {
|
||||
// 서버 신고가 실패해도 학생에게는 이미 팝업으로 알렸으니 조용히 무시.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user