아이폰 근접센서 주머니 감시, 학생 목록 타일 UI, 학교 서버 주소 반영

- iOS는 조도 센서/백그라운드 서비스가 불가능해서 근접센서(UIDevice
  proximityMonitoring) + 가이드 접근 안내로 대체 구현 (Core NFC와 마찬가지로
  네이티브 플러그인 필요해서 ProximityMonitorPlugin.swift 추가)
- 교사 대시보드 실시간 출석 현황: 모바일 리스트/데스크톱 표를 타일 그리드로
  통일해서 한눈에 보기 쉽게 변경
- 서버를 학교 자체 서버(Cloudflare Tunnel, api.backsanhi.shop)로 이전하면서
  기본 BASE_URL 갱신
- Firebase CLI 로컬 캐시(.firebase/)는 소스가 아니라 gitignore 처리

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 21:50:38 +09:00
co-authored by Claude Sonnet 5
parent f9fa05f817
commit 9354e2998a
8 changed files with 419 additions and 299 deletions
+154 -7
View File
@@ -5,6 +5,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show ChangeNotifier, kIsWeb;
import 'package:flutter/services.dart' show EventChannel, MethodChannel;
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:http/http.dart' as http;
import 'package:nfc_manager/nfc_manager.dart';
@@ -13,6 +14,12 @@ import 'package:nfc_manager_ndef/nfc_manager_ndef.dart';
import 'package:permission_handler/permission_handler.dart';
import '../config.dart' show baseUrl;
// 🛡️ iOS 근접센서 감시용 네이티브 채널 (ios/Runner/ProximityMonitorPlugin.swift 참고).
// 조도 센서 API가 없는 iOS에서, 통화 중 화면이 꺼지는 것과 같은 원리인
// 근접센서로 "주머니 안/밖" 상태 변화를 감지해 대체한다.
const MethodChannel _proximityMethodChannel = MethodChannel('proximity_monitor');
const EventChannel _proximityEventChannel = EventChannel('proximity_monitor/events');
enum WatchMessageLevel { info, success, warning, error }
class NfcPocketCheckinController extends ChangeNotifier {
@@ -28,22 +35,32 @@ class NfcPocketCheckinController extends ChangeNotifier {
/// 무단반출 감지 시 경고 팝업을 띄우기 위한 콜백.
final void Function(String? pocketNumber) onViolationDetected;
/// iOS 출석 성공 직후, "가이드 접근"을 켜달라고 안내하는 팝업을 띄우기 위한 콜백.
/// (안드로이드는 화면이 꺼져도 백그라운드 서비스가 동작하니 이 안내가 필요 없다.)
final void Function(String pocketNumber) onNeedGuidedAccessSetup;
NfcPocketCheckinController({
required this.studentId,
required this.studentName,
required this.onMessage,
required this.onCheckInSuccess,
required this.onViolationDetected,
required this.onNeedGuidedAccessSetup,
});
bool _isProcessing = false; // 중복 태깅 및 연타 방지 플래그
bool _isWatching = false; // 백그라운드 조도 센서 감시가 진행 중인지 여부
bool _isWatching = false; // 백그라운드 조도 센서(또는 iOS 근접센서) 감시가 진행 중인지 여부
bool _isIosProximityWatch = false; // 지금 진행 중인 감시가 iOS 근접센서 방식인지 여부 (체크아웃 분기용)
String _statusMessage = "주머니의 NFC 스티커에 폰 뒷면을 대어주세요.";
String? _activePocketNumber;
StreamSubscription<Map<String, dynamic>?>? _violationSub;
StreamSubscription<Map<String, dynamic>?>? _checkedOutSub;
StreamSubscription<dynamic>? _proximitySub;
bool? _proximityBaselineNear; // 감시 시작 직후(주머니에 넣은 직후) 기준 상태
bool _proximityViolated = false;
bool get isProcessing => _isProcessing;
bool get isWatching => _isWatching;
String get statusMessage => _statusMessage;
@@ -51,6 +68,10 @@ class NfcPocketCheckinController extends ChangeNotifier {
bool get watchServiceSupported => !kIsWeb && Platform.isAndroid;
/// iOS는 진짜 백그라운드 감시가 불가능해서, "가이드 접근" 모드로 화면을 못 잠그게 한 뒤
/// 근접센서로 흉내내는 방식만 지원한다 (자세한 이유는 사용자와의 대화 참고).
bool get iosProximityWatchSupported => !kIsWeb && Platform.isIOS;
void init() {
_startNfcSession();
if (watchServiceSupported) {
@@ -83,6 +104,12 @@ class NfcPocketCheckinController extends ChangeNotifier {
NfcManager.instance.stopSession();
_violationSub?.cancel();
_checkedOutSub?.cancel();
// iOS 근접센서 감시는 (안드로이드와 달리) 이 컨트롤러 안에서 직접 도는 것이라,
// 화면을 나가면 같이 정리해야 한다 (안 그러면 dispose된 ChangeNotifier에 notifyListeners 호출로 에러남).
_proximitySub?.cancel();
if (_isIosProximityWatch) {
_proximityMethodChannel.invokeMethod('stop').catchError((_) {});
}
super.dispose();
}
@@ -180,7 +207,14 @@ class NfcPocketCheckinController extends ChangeNotifier {
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (result["status"] == "success") {
onCheckInSuccess(pocketNumber);
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 (_) {
// 서버 신고가 실패해도 학생에게는 이미 팝업으로 알렸으니 조용히 무시.
}
}
}