// ๐Ÿ›ก๏ธ ํ™”๋ฉด์ด ๊บผ์ ธ๋„ ๋™์ž‘ํ•˜๋Š” ํฌ๊ทธ๋ผ์šด๋“œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์„œ๋น„์Šค. // ์กฐ๋„ ์„ผ์„œ๋กœ ์ฃผ๋จธ๋‹ˆ ์† ๋ฐ๊ธฐ๋ฅผ ์ธก์ •ํ•ด ๋ฌด๋‹จ ๋ฐ˜์ถœ์„ ๊ฐ์ง€ํ•˜๊ณ , ์ง„๋™+์•Œ๋ฆผ+์„œ๋ฒ„ ์‹ ๊ณ ๋ฅผ ์ฒ˜๋ฆฌํ•œ๋‹ค. import 'dart:async'; import 'dart:convert'; import 'dart:ui'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:http/http.dart' as http; import 'package:light/light.dart'; import 'package:vibration/vibration.dart'; import 'config.dart' show baseUrl; // ๐Ÿ”ฆ ์กฐ๋„ ์„ผ์„œ ํŒ์ • ๊ด€๋ จ ํŠœ๋‹ ๊ฐ’ (nfc_poccket_checkin_screen.dart์™€ ๋™์ผํ•œ ๊ธฐ์ค€) const int _calibrationSampleCount = 3; const Duration _placePhoneDelay = Duration(seconds: 4); const int _luxJumpThreshold = 40; const String pocketWatchNotificationChannelId = 'pocket_watch_channel'; const int pocketWatchNotificationId = 9001; final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin(); /// ์•ฑ ์‹œ์ž‘(main()) ์‹œ ํ•œ ๋ฒˆ๋งŒ ํ˜ธ์ถœ. ์•Œ๋ฆผ ์ฑ„๋„ ์ƒ์„ฑ + ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์„œ๋น„์Šค ์„ค์ •. Future initializePocketWatchService() async { const AndroidNotificationChannel channel = AndroidNotificationChannel( pocketWatchNotificationChannelId, '์ฃผ๋จธ๋‹ˆ ๊ฐ์‹œ ์„œ๋น„์Šค', description: 'ํœด๋Œ€ํฐ ๋ฌด๋‹จ ๋ฐ˜์ถœ ๊ฐ์‹œ ์ค‘ ์ƒํƒœ๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.', importance: Importance.low, // ์กฐ์šฉํ•œ ์•Œ๋ฆผ (์†Œ๋ฆฌ/์ง„๋™ ์—†์Œ) ); await _localNotifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >() ?.createNotificationChannel(channel); final service = FlutterBackgroundService(); await service.configure( androidConfiguration: AndroidConfiguration( onStart: onPocketWatchServiceStart, autoStart: false, // NFC ํƒœ๊น…์œผ๋กœ ์ถœ์„ ์„ฑ๊ณตํ–ˆ์„ ๋•Œ๋งŒ ์ˆ˜๋™์œผ๋กœ ์‹œ์ž‘ autoStartOnBoot: false, isForegroundMode: true, notificationChannelId: pocketWatchNotificationChannelId, initialNotificationTitle: '์ฃผ๋จธ๋‹ˆ ๊ฐ์‹œ ๋Œ€๊ธฐ ์ค‘', initialNotificationContent: '์ถœ์„ ํƒœ๊น…์„ ๊ธฐ๋‹ค๋ฆฌ๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.', foregroundServiceNotificationId: pocketWatchNotificationId, foregroundServiceTypes: [AndroidForegroundType.specialUse], ), iosConfiguration: IosConfiguration(autoStart: false), ); } @pragma('vm:entry-point') void onPocketWatchServiceStart(ServiceInstance service) { DartPluginRegistrant.ensureInitialized(); StreamSubscription? lightSub; final List calibrationSamples = []; int? baselineLux; String? studentId; String? studentName; String? pocketNumber; bool violated = false; void updateNotification(String title, String content) { if (service is AndroidServiceInstance) { _localNotifications.show( id: pocketWatchNotificationId, title: title, body: content, notificationDetails: const NotificationDetails( android: AndroidNotificationDetails( pocketWatchNotificationChannelId, '์ฃผ๋จธ๋‹ˆ ๊ฐ์‹œ ์„œ๋น„์Šค', icon: 'ic_bg_service_small', ongoing: true, importance: Importance.low, priority: Priority.low, ), ), ); } } Future reportViolation() async { // ์ง„๋™ 3์—ฐํƒ€๋กœ ๊ฐ•ํ•˜๊ฒŒ ๊ฒฝ๊ณ  (๋ฐฑ๊ทธ๋ผ์šด๋“œ ์„œ๋น„์Šค์—์„œ๋Š” HapticFeedback์ด ์•„๋‹Œ vibration ํŒจํ‚ค์ง€๋ฅผ ์จ์•ผ ๋™์ž‘ํ•จ) Vibration.vibrate(pattern: [0, 400, 150, 400, 150, 400]); updateNotification("๋ฌด๋‹จ ๋ฐ˜์ถœ ๊ฐ์ง€!", "[$pocketNumber] ์ฃผ๋จธ๋‹ˆ์—์„œ ํฐ์ด ๊ฐ์ง€๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); service.invoke('violation_detected', { "pocketNumber": pocketNumber, "timestamp": DateTime.now().toIso8601String(), }); 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 (_) { // ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์„œ๋น„์Šค์—์„œ๋Š” UI๋กœ ์—๋Ÿฌ๋ฅผ ๋„์šธ ์ˆ˜ ์—†์œผ๋‹ˆ ์กฐ์šฉํžˆ ๋ฌด์‹œ. // (๊ธฐ๋ก ์ž์ฒด๋Š” ์ด๋ฏธ ๋กœ์ปฌ ์•Œ๋ฆผ+haptic์œผ๋กœ ํ•™์ƒ์—๊ฒŒ ์ „๋‹ฌ๋จ) } } /// ํ•˜๊ต ์ฒ˜๋ฆฌ/๋ฐ˜์ถœ ํ—ˆ์šฉ ์‹œ๊ฐ„๋Œ€๋ผ๋ฉด ์กฐ์šฉํžˆ ๊ฐ์‹œ๋งŒ ์ข…๋ฃŒํ•˜๊ณ , ์•„๋‹ˆ๋ฉด ์œ„๋ฐ˜์œผ๋กœ ๊ฒฝ๊ณ ํ•œ๋‹ค. Future handlePhoneRemoved() async { 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; // ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜ ์‹œ์—” ์•ˆ์ „ํ•˜๊ฒŒ ์œ„๋ฐ˜์œผ๋กœ ์ฒ˜๋ฆฌ } if (isPermitted) { updateNotification("ํฐ ํšŒ์ˆ˜ ์™„๋ฃŒ", "[$pocketNumber] ์ฃผ๋จธ๋‹ˆ์—์„œ ์ •์ƒ์ ์œผ๋กœ ํšŒ์ˆ˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); service.invoke('checked_out', {"pocketNumber": pocketNumber}); service.stopSelf(); } else { reportViolation(); } } void onLightReading(int luxValue) { if (violated) return; if (baselineLux == null) { calibrationSamples.add(luxValue); if (calibrationSamples.length >= _calibrationSampleCount) { baselineLux = calibrationSamples.reduce((a, b) => a + b) ~/ calibrationSamples.length; updateNotification("๊ฐ์‹œ ์ค‘", "[$pocketNumber] ํฐ์ด ์ฃผ๋จธ๋‹ˆ ์•ˆ์— ์žˆ๋Š”์ง€ ๊ฐ์‹œ ์ค‘"); } return; } if (luxValue > baselineLux! + _luxJumpThreshold) { violated = true; lightSub?.cancel(); handlePhoneRemoved(); } } Future startWatching() async { lightSub?.cancel(); calibrationSamples.clear(); baselineLux = null; violated = false; updateNotification("์ฃผ๋จธ๋‹ˆ์— ๋„ฃ๋Š” ์ค‘...", "์ž ์‹œ ํ›„ ๊ฐ์‹œ๊ฐ€ ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค."); await Future.delayed(_placePhoneDelay); updateNotification("๋ฐ๊ธฐ ์ธก์ • ์ค‘...", "์ฃผ๋จธ๋‹ˆ ์† ๋ฐ๊ธฐ๋ฅผ ๊ธฐ์ค€๊ฐ’์œผ๋กœ ์„ค์ •ํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค."); try { await Light().requestAuthorization(); lightSub = Light().lightSensorStream.listen( onLightReading, onError: (Object error) { updateNotification("์กฐ๋„ ์„ผ์„œ ์˜ค๋ฅ˜", "$error"); }, ); } catch (e) { updateNotification("์กฐ๋„ ์„ผ์„œ ์‹œ์ž‘ ์‹คํŒจ", "$e"); } } service.on('start_watch').listen((event) { studentId = event?['studentId']; studentName = event?['studentName']; pocketNumber = event?['pocketNumber']; startWatching(); }); service.on('stop_watch').listen((event) { lightSub?.cancel(); service.stopSelf(); }); }