화면 타이틀, 스낵바/다이얼로그 메시지, 백그라운드 알림 문구 등 사용자에게 노출되는 텍스트에서 이모티콘을 전부 제거 (코드 주석은 대상 아님). 아이콘 위젯은 그대로 유지. main_dashboard.dart의 "계정 강제 삭제" 타일 아이콘 색상도 빨간색 대신 다른 타일과 동일한 검정(AppPalette.ink)으로 통일. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
392 lines
16 KiB
Dart
392 lines
16 KiB
Dart
// 📲 자습실 NFC 출석체크 화면의 기능(NFC 세션/서버 통신/백그라운드 감시 연동) 담당 컨트롤러.
|
|
// 다이얼로그/스낵바 등 실제 위젯 표시는 UI 파일(lib/nfc_poccket_checkin_screen.dart)의
|
|
// 콜백을 통해서만 이루어진다 — 이 파일엔 위젯 코드가 없다.
|
|
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';
|
|
import 'package:nfc_manager/nfc_manager_android.dart';
|
|
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 {
|
|
final String studentId;
|
|
final String studentName;
|
|
|
|
/// 스낵바로 보여줄 일반 메시지 이벤트. UI가 레벨에 맞는 색상을 정해서 띄운다.
|
|
final void Function(String text, WatchMessageLevel level) onMessage;
|
|
|
|
/// 출석(태깅) 성공 시 축하 팝업을 띄우기 위한 콜백.
|
|
final void Function(String pocketNumber) onCheckInSuccess;
|
|
|
|
/// 무단반출 감지 시 경고 팝업을 띄우기 위한 콜백.
|
|
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; // 백그라운드 조도 센서(또는 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;
|
|
String? get activePocketNumber => _activePocketNumber;
|
|
|
|
bool get watchServiceSupported => !kIsWeb && Platform.isAndroid;
|
|
|
|
/// iOS는 진짜 백그라운드 감시가 불가능해서, "가이드 접근" 모드로 화면을 못 잠그게 한 뒤
|
|
/// 근접센서로 흉내내는 방식만 지원한다 (자세한 이유는 사용자와의 대화 참고).
|
|
bool get iosProximityWatchSupported => !kIsWeb && Platform.isIOS;
|
|
|
|
void init() {
|
|
_startNfcSession();
|
|
if (watchServiceSupported) {
|
|
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
|
|
// 무단 반출이 한 번 감지되면 "신뢰된 감시 세션"은 끝난 것으로 보고,
|
|
// 다음 태깅은 체크아웃이 아니라 새 출석(재출석)으로 처리되도록 감시 상태를 해제한다.
|
|
_violationSub = FlutterBackgroundService()
|
|
.on('violation_detected')
|
|
.listen((event) {
|
|
_isWatching = false;
|
|
notifyListeners();
|
|
onViolationDetected(event?['pocketNumber']?.toString());
|
|
});
|
|
// 🏫 하교 처리 등으로 반출이 허용된 상태에서 폰을 꺼내면, 위반이 아니라 정상 회수로 처리된다.
|
|
_checkedOutSub = FlutterBackgroundService().on('checked_out').listen((
|
|
event,
|
|
) {
|
|
_isWatching = false;
|
|
_activePocketNumber = null;
|
|
_statusMessage = "폰을 회수했습니다. 수고하셨습니다!";
|
|
notifyListeners();
|
|
onMessage("폰이 정상적으로 회수되었습니다.", WatchMessageLevel.success);
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// 화면을 나갈 때 NFC 감지만 종료. 백그라운드 감시 서비스는 화면과 무관하게 계속 동작해야 하므로 건드리지 않는다.
|
|
NfcManager.instance.stopSession();
|
|
_violationSub?.cancel();
|
|
_checkedOutSub?.cancel();
|
|
// iOS 근접센서 감시는 (안드로이드와 달리) 이 컨트롤러 안에서 직접 도는 것이라,
|
|
// 화면을 나가면 같이 정리해야 한다 (안 그러면 dispose된 ChangeNotifier에 notifyListeners 호출로 에러남).
|
|
_proximitySub?.cancel();
|
|
if (_isIosProximityWatch) {
|
|
_proximityMethodChannel.invokeMethod('stop').catchError((_) {});
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
/// 📡 NFC 감지 세션 시작
|
|
void _startNfcSession() async {
|
|
bool isAvailable = await NfcManager.instance.isAvailable();
|
|
if (!isAvailable) {
|
|
_statusMessage = "이 스마트폰은 NFC 기능이 꺼져있거나 지원되지 않습니다.";
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
NfcManager.instance.startSession(
|
|
pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693},
|
|
onDiscovered: (NfcTag tag) async {
|
|
if (_isProcessing) return; // 이미 처리 중이면 연속 태그 무시 (쿨다운)
|
|
|
|
// 이미 감시 중일 때 다시 태깅하면 "폰 회수(체크아웃)"로 처리한다.
|
|
if (_isWatching) {
|
|
await _checkOut();
|
|
return;
|
|
}
|
|
|
|
_isProcessing = true;
|
|
_statusMessage = "태그 인식 완료! 서버에 출석 전송 중...";
|
|
notifyListeners();
|
|
|
|
try {
|
|
// 1. NFC 태그에서 주머니 번호 데이터 읽기
|
|
Ndef? ndef = Ndef.from(tag);
|
|
String rawPocketData = "";
|
|
|
|
if (ndef != null && ndef.cachedMessage != null) {
|
|
for (var record in ndef.cachedMessage!.records) {
|
|
rawPocketData += String.fromCharCodes(record.payload);
|
|
}
|
|
}
|
|
|
|
// 텍스트 헤더 정리 (예: "\x02enPOCKET_17" -> "POCKET_17")
|
|
String cleanPocketNumber = "POCKET_UNKNOWN";
|
|
if (rawPocketData.contains("POCKET_")) {
|
|
int index = rawPocketData.indexOf("POCKET_");
|
|
cleanPocketNumber = rawPocketData.substring(index).trim();
|
|
} else if (rawPocketData.isNotEmpty) {
|
|
cleanPocketNumber = rawPocketData.trim();
|
|
}
|
|
|
|
// 2. 파이썬 서버로 출석 정보 보내기 (복제 방지용 태그 UID도 함께 전송)
|
|
await _sendAttendanceToBackend(
|
|
cleanPocketNumber,
|
|
tagUid: _getTagUid(tag),
|
|
);
|
|
} catch (e) {
|
|
onMessage("태그 읽기 실패: $e", WatchMessageLevel.error);
|
|
} finally {
|
|
// 3초 후 연타 방지 해제 (쿨다운)
|
|
await Future.delayed(const Duration(seconds: 3));
|
|
_isProcessing = false;
|
|
notifyListeners();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 태그의 공장 각인 하드웨어 UID를 16진수 문자열로 반환한다 (쓰기로 바꿀 수 없어 복제 방지 기준으로 씀).
|
|
String? _getTagUid(NfcTag tag) {
|
|
final id = NfcTagAndroid.from(tag)?.id;
|
|
if (id == null || id.isEmpty) return null;
|
|
return id
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
.join()
|
|
.toUpperCase();
|
|
}
|
|
|
|
/// 🚚 파이썬 백엔드로 출석 데이터 HTTP POST 전송
|
|
Future<void> _sendAttendanceToBackend(
|
|
String pocketNumber, {
|
|
String? tagUid,
|
|
}) async {
|
|
final url = Uri.parse("$baseUrl/attendance");
|
|
|
|
try {
|
|
final response = await http.post(
|
|
url,
|
|
headers: {"Content-Type": "application/json"},
|
|
body: jsonEncode({
|
|
"studentId": studentId,
|
|
"studentName": studentName,
|
|
"pocketNumber": pocketNumber, // 주머니 번호 전달
|
|
"tagUid": tagUid, // 🔒 서버가 태그 진위를 대조할 하드웨어 UID
|
|
}),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
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);
|
|
}
|
|
} else {
|
|
onMessage(
|
|
"서버 에러 (코드: ${response.statusCode})",
|
|
WatchMessageLevel.error,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
onMessage("서버 연결 실패: $e", WatchMessageLevel.error);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 🛡️ 주머니 제출 모드: 화면이 꺼져도 백그라운드 서비스가 조도 센서로 감시
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// NFC 태깅 성공 직후 호출. 배터리 최적화 예외를 한 번 요청한 뒤 백그라운드 감시 서비스를 시작한다.
|
|
Future<void> _startBackgroundWatch(String pocketNumber) async {
|
|
if (!watchServiceSupported) return;
|
|
|
|
// 배터리 최적화 예외 요청 (이미 허용되어 있으면 시스템이 알아서 다이얼로그를 건너뜀)
|
|
final batteryStatus = await Permission.ignoreBatteryOptimizations.status;
|
|
if (!batteryStatus.isGranted) {
|
|
await Permission.ignoreBatteryOptimizations.request();
|
|
}
|
|
|
|
final service = FlutterBackgroundService();
|
|
if (!await service.isRunning()) {
|
|
await service.startService();
|
|
}
|
|
service.invoke('start_watch', {
|
|
"studentId": studentId,
|
|
"studentName": studentName,
|
|
"pocketNumber": pocketNumber,
|
|
});
|
|
|
|
_isWatching = true;
|
|
_activePocketNumber = pocketNumber;
|
|
_statusMessage = "백그라운드에서 감시 중입니다. 화면을 꺼도 계속 감시돼요.";
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 감시 중일 때 다시 태깅하면 폰을 회수한 것으로 보고 감시를 종료한다.
|
|
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 (_) {
|
|
// 서버 신고가 실패해도 학생에게는 이미 팝업으로 알렸으니 조용히 무시.
|
|
}
|
|
}
|
|
}
|