// πŸ“² μžμŠ΅μ‹€ 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_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; 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; NfcPocketCheckinController({ required this.studentId, required this.studentName, required this.onMessage, required this.onCheckInSuccess, required this.onViolationDetected, }); bool _isProcessing = false; // 쀑볡 νƒœκΉ… 및 연타 λ°©μ§€ ν”Œλž˜κ·Έ bool _isWatching = false; // λ°±κ·ΈλΌμš΄λ“œ 쑰도 μ„Όμ„œ κ°μ‹œκ°€ μ§„ν–‰ 쀑인지 μ—¬λΆ€ String _statusMessage = "μ£Όλ¨Έλ‹ˆμ˜ NFC μŠ€ν‹°μ»€μ— 폰 뒷면을 λŒ€μ–΄μ£Όμ„Έμš”."; String? _activePocketNumber; StreamSubscription?>? _violationSub; StreamSubscription?>? _checkedOutSub; bool get isProcessing => _isProcessing; bool get isWatching => _isWatching; String get statusMessage => _statusMessage; String? get activePocketNumber => _activePocketNumber; bool get watchServiceSupported => !kIsWeb && Platform.isAndroid; 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(); 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 _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); await _startBackgroundWatch(pocketNumber); } else { onMessage("⚠️ μΆœμ„ μ‹€νŒ¨: ${result['message']}", WatchMessageLevel.warning); } } else { onMessage( "🚨 μ„œλ²„ μ—λŸ¬ (μ½”λ“œ: ${response.statusCode})", WatchMessageLevel.error, ); } } catch (e) { onMessage("❌ μ„œλ²„ μ—°κ²° μ‹€νŒ¨: $e", WatchMessageLevel.error); } } // ----------------------------------------------------------------------- // πŸ›‘οΈ μ£Όλ¨Έλ‹ˆ 제좜 λͺ¨λ“œ: 화면이 꺼져도 λ°±κ·ΈλΌμš΄λ“œ μ„œλΉ„μŠ€κ°€ 쑰도 μ„Όμ„œλ‘œ κ°μ‹œ // ----------------------------------------------------------------------- /// NFC νƒœκΉ… 성곡 직후 호좜. 배터리 μ΅œμ ν™” μ˜ˆμ™Έλ₯Ό ν•œ 번 μš”μ²­ν•œ λ’€ λ°±κ·ΈλΌμš΄λ“œ κ°μ‹œ μ„œλΉ„μŠ€λ₯Ό μ‹œμž‘ν•œλ‹€. Future _startBackgroundWatch(String pocketNumber) async { if (!watchServiceSupported) { onMessage("ℹ️ 이 κΈ°κΈ°(iOS)λŠ” λ°±κ·ΈλΌμš΄λ“œ κ°μ‹œλ₯Ό μ§€μ›ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", WatchMessageLevel.info); 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 _checkOut() async { FlutterBackgroundService().invoke('stop_watch'); _isWatching = false; _activePocketNumber = null; _statusMessage = "βœ… 폰을 νšŒμˆ˜ν–ˆμŠ΅λ‹ˆλ‹€. κ°μ‹œκ°€ μ’…λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."; notifyListeners(); onMessage("βœ… κ°μ‹œκ°€ μ’…λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. μˆ˜κ³ ν•˜μ…¨μŠ΅λ‹ˆλ‹€!", WatchMessageLevel.success); } }