// ๐Ÿ“ฒ ์ž์Šต์‹ค 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?>? _violationSub; StreamSubscription?>? _checkedOutSub; StreamSubscription? _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 _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 _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 _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 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 _stopIosProximityWatch() async { await _proximitySub?.cancel(); _proximitySub = null; _isIosProximityWatch = false; try { await _proximityMethodChannel.invokeMethod('stop'); } catch (_) { // ์ด๋ฏธ ๊ฐ์‹œ๊ฐ€ ๊บผ์ ธ ์žˆ์–ด๋„ ์ƒ๊ด€์—†์œผ๋‹ˆ ์กฐ์šฉํžˆ ๋ฌด์‹œ. } } /// ํ•˜๊ต ์ฒ˜๋ฆฌ/๋ฐ˜์ถœ ํ—ˆ์šฉ ์‹œ๊ฐ„๋Œ€๋ผ๋ฉด ์กฐ์šฉํžˆ ๊ฐ์‹œ๋งŒ ์ข…๋ฃŒํ•˜๊ณ , ์•„๋‹ˆ๋ฉด ์œ„๋ฐ˜์œผ๋กœ ๊ฒฝ๊ณ ํ•œ๋‹ค. /// (์•ˆ๋“œ๋กœ์ด๋“œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์„œ๋น„์Šค์˜ handlePhoneRemoved์™€ ๋™์ผํ•œ ํŒ์ • ๋กœ์ง) Future _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 (_) { // ์„œ๋ฒ„ ์‹ ๊ณ ๊ฐ€ ์‹คํŒจํ•ด๋„ ํ•™์ƒ์—๊ฒŒ๋Š” ์ด๋ฏธ ํŒ์—…์œผ๋กœ ์•Œ๋ ธ์œผ๋‹ˆ ์กฐ์šฉํžˆ ๋ฌด์‹œ. } } }