// πŸ“² μžμŠ΅μ‹€ NFC μΆœμ„μ²΄ν¬ ν™”λ©΄. νƒœκ·Έ μΈμ‹β†’μΆœμ„ 전솑 ν›„, 배터리 μ΅œμ ν™” μ˜ˆμ™Έλ₯Ό μš”μ²­ν•˜κ³  // pocket_watch_service의 λ°±κ·ΈλΌμš΄λ“œ κ°μ‹œλ₯Ό μ‹œμž‘/μ’…λ£Œ(μž¬νƒœκΉ… μ‹œ 체크아웃)ν•˜λŠ” 역할을 ν•œλ‹€. import 'dart:async'; import 'dart:convert'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; 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; class NfcPocketCheckInScreen extends StatefulWidget { final String studentId; // 둜그인된 학생 ν•™λ²ˆ (예: "2061") final String studentName; // 둜그인된 학생 이름 (예: "μ‹œν›„") const NfcPocketCheckInScreen({ super.key, required this.studentId, required this.studentName, }); @override State createState() => _NfcPocketCheckInScreenState(); } class _NfcPocketCheckInScreenState extends State { bool _isProcessing = false; // 쀑볡 νƒœκΉ… 및 연타 λ°©μ§€ ν”Œλž˜κ·Έ bool _isWatching = false; // λ°±κ·ΈλΌμš΄λ“œ 쑰도 μ„Όμ„œ κ°μ‹œκ°€ μ§„ν–‰ 쀑인지 μ—¬λΆ€ String _statusMessage = "μ£Όλ¨Έλ‹ˆμ˜ NFC μŠ€ν‹°μ»€μ— 폰 뒷면을 λŒ€μ–΄μ£Όμ„Έμš”."; String? _activePocketNumber; StreamSubscription?>? _violationSub; bool get _watchServiceSupported => !kIsWeb && Platform.isAndroid; @override void initState() { super.initState(); _startNfcSession(); if (_watchServiceSupported) { // 화면이 μ—΄λ €μžˆλŠ” λ™μ•ˆμ€ μ‹€μ‹œκ°„μœΌλ‘œ μœ„λ°˜ μ•Œλ¦Όμ„ λ°›μ•„ νŒμ—…μ„ λ„μš΄λ‹€. _violationSub = FlutterBackgroundService().on('violation_detected').listen(( event, ) { if (mounted) _showViolationDialog(event?['pocketNumber']?.toString()); }); } } @override void dispose() { // 화면을 λ‚˜κ°ˆ λ•Œ NFC κ°μ§€λ§Œ μ’…λ£Œ. λ°±κ·ΈλΌμš΄λ“œ κ°μ‹œ μ„œλΉ„μŠ€λŠ” ν™”λ©΄κ³Ό λ¬΄κ΄€ν•˜κ²Œ 계속 λ™μž‘ν•΄μ•Ό ν•˜λ―€λ‘œ κ±΄λ“œλ¦¬μ§€ μ•ŠλŠ”λ‹€. NfcManager.instance.stopSession(); _violationSub?.cancel(); super.dispose(); } /// πŸ“‘ NFC 감지 μ„Έμ…˜ μ‹œμž‘ void _startNfcSession() async { bool isAvailable = await NfcManager.instance.isAvailable(); if (!isAvailable) { setState(() { _statusMessage = "❌ 이 μŠ€λ§ˆνŠΈν°μ€ NFC κΈ°λŠ₯이 κΊΌμ Έμžˆκ±°λ‚˜ μ§€μ›λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."; }); return; } NfcManager.instance.startSession( pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693}, onDiscovered: (NfcTag tag) async { if (_isProcessing) return; // 이미 처리 쀑이면 연속 νƒœκ·Έ λ¬΄μ‹œ (μΏ¨λ‹€μš΄) // 이미 κ°μ‹œ 쀑일 λ•Œ λ‹€μ‹œ νƒœκΉ…ν•˜λ©΄ "폰 회수(체크아웃)"둜 μ²˜λ¦¬ν•œλ‹€. if (_isWatching) { await _checkOut(); return; } setState(() { _isProcessing = true; _statusMessage = "⏳ νƒœκ·Έ 인식 μ™„λ£Œ! μ„œλ²„μ— μΆœμ„ 전솑 쀑..."; }); 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) { _showSnackBar("❌ νƒœκ·Έ 읽기 μ‹€νŒ¨: $e", Colors.red); } finally { // 3초 ν›„ 연타 λ°©μ§€ ν•΄μ œ (μΏ¨λ‹€μš΄) await Future.delayed(const Duration(seconds: 3)); if (mounted) { setState(() => _isProcessing = false); } } }, ); } /// νƒœκ·Έμ˜ 곡μž₯ 각인 ν•˜λ“œμ›¨μ–΄ 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": widget.studentId, "studentName": widget.studentName, "pocketNumber": pocketNumber, // μ£Όλ¨Έλ‹ˆ 번호 전달 "tagUid": tagUid, // πŸ”’ μ„œλ²„κ°€ νƒœκ·Έ μ§„μœ„λ₯Ό λŒ€μ‘°ν•  ν•˜λ“œμ›¨μ–΄ UID }), ); if (response.statusCode == 200) { final result = jsonDecode(utf8.decode(response.bodyBytes)); if (result["status"] == "success") { _showSuccessDialog(pocketNumber); await _startBackgroundWatch(pocketNumber); } else { _showSnackBar("⚠️ μΆœμ„ μ‹€νŒ¨: ${result['message']}", Colors.orange); } } else { _showSnackBar("🚨 μ„œλ²„ μ—λŸ¬ (μ½”λ“œ: ${response.statusCode})", Colors.red); } } catch (e) { _showSnackBar("❌ μ„œλ²„ μ—°κ²° μ‹€νŒ¨: $e", Colors.red); } } // ----------------------------------------------------------------------- // πŸ›‘οΈ μ£Όλ¨Έλ‹ˆ 제좜 λͺ¨λ“œ: 화면이 꺼져도 λ°±κ·ΈλΌμš΄λ“œ μ„œλΉ„μŠ€κ°€ 쑰도 μ„Όμ„œλ‘œ κ°μ‹œ // ----------------------------------------------------------------------- /// NFC νƒœκΉ… 성곡 직후 호좜. 배터리 μ΅œμ ν™” μ˜ˆμ™Έλ₯Ό ν•œ 번 μš”μ²­ν•œ λ’€ λ°±κ·ΈλΌμš΄λ“œ κ°μ‹œ μ„œλΉ„μŠ€λ₯Ό μ‹œμž‘ν•œλ‹€. Future _startBackgroundWatch(String pocketNumber) async { if (!_watchServiceSupported) { _showSnackBar("ℹ️ 이 κΈ°κΈ°(iOS)λŠ” λ°±κ·ΈλΌμš΄λ“œ κ°μ‹œλ₯Ό μ§€μ›ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", Colors.blueGrey); 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": widget.studentId, "studentName": widget.studentName, "pocketNumber": pocketNumber, }); setState(() { _isWatching = true; _activePocketNumber = pocketNumber; _statusMessage = "πŸ›‘οΈ λ°±κ·ΈλΌμš΄λ“œμ—μ„œ κ°μ‹œ μ€‘μž…λ‹ˆλ‹€. 화면을 꺼도 계속 κ°μ‹œλΌμš”."; }); } /// κ°μ‹œ 쀑일 λ•Œ λ‹€μ‹œ νƒœκΉ…ν•˜λ©΄ 폰을 νšŒμˆ˜ν•œ κ²ƒμœΌλ‘œ 보고 κ°μ‹œλ₯Ό μ’…λ£Œν•œλ‹€. Future _checkOut() async { FlutterBackgroundService().invoke('stop_watch'); setState(() { _isWatching = false; _activePocketNumber = null; _statusMessage = "βœ… 폰을 νšŒμˆ˜ν–ˆμŠ΅λ‹ˆλ‹€. κ°μ‹œκ°€ μ’…λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."; }); _showSnackBar("βœ… κ°μ‹œκ°€ μ’…λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. μˆ˜κ³ ν•˜μ…¨μŠ΅λ‹ˆλ‹€!", Colors.green); } void _showViolationDialog(String? pocketNumber) { showDialog( context: context, builder: (context) => AlertDialog( backgroundColor: Colors.red[50], title: const Text( "🚨 무단 반좜 감지", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold), ), content: Text( "[${pocketNumber ?? _activePocketNumber}] μ£Όλ¨Έλ‹ˆμ—μ„œ νœ΄λŒ€ν°μ΄ κΊΌλ‚΄μ§„ κ²ƒμœΌλ‘œ κ°μ§€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.\nλ‹΄λ‹Ή μ„ μƒλ‹˜κ»˜ μ•Œλ¦Όμ΄ μ „μ†‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text("확인"), ), ], ), ); } /// 🎊 μΆœμ„ 성곡 μ•Œλ¦Όμ°½ void _showSuccessDialog(String pocketNumber) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text("πŸŽ‰ μžμŠ΅μ‹€ μΆœμ„ μ™„λ£Œ!"), content: Text( "${widget.studentName} 학생!\n[$pocketNumber] μ£Όλ¨Έλ‹ˆ μΆœμ„μ΄ ν™•μΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€.\n\n폰을 μ£Όλ¨Έλ‹ˆμ— 쏙 λ„£κ³  μžμŠ΅μ— 집쀑해 μ£Όμ„Έμš”! ✏️", ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text("확인"), ), ], ), ); } void _showSnackBar(String text, Color color) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(text), backgroundColor: color)); } @override Widget build(BuildContext context) { if (_isWatching) { return _buildPocketWatchScreen(); } return Scaffold( appBar: AppBar( title: const Text( "μžμŠ΅μ‹€ NFC μΆœμ„μ²΄ν¬", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ), backgroundColor: const Color.fromARGB(255, 48, 48, 52), ), body: Center( child: Padding( padding: const EdgeInsets.all(24.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // NFC μ• λ‹ˆλ©”μ΄μ…˜ μ•„μ΄μ½˜ Icon( _isProcessing ? Icons.sync : Icons.nfc, size: 100, color: _isProcessing ? Colors.orange : Colors.blueAccent, ), const SizedBox(height: 30), // μƒνƒœ λ©”μ‹œμ§€ ν‘œμ‹œ Text( _statusMessage, textAlign: TextAlign.center, style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: _isProcessing ? Colors.orange : Colors.black87, ), ), const SizedBox(height: 15), const Text( "자기 μ£Όλ¨Έλ‹ˆ 번호 μˆ«μžμ— 폰 뒷면을 '톑' λŒ€λ©΄\nμžλ™μœΌλ‘œ μΆœμ„ μ²˜λ¦¬λ©λ‹ˆλ‹€.", textAlign: TextAlign.center, style: TextStyle(color: Colors.grey, fontSize: 14), ), ], ), ), ), ); } /// πŸ’³ μ‚Όμ„±νŽ˜μ΄ 결제창 λŠλ‚Œμ˜ 전체화면 "μ£Όλ¨Έλ‹ˆ 제좜 λͺ¨λ“œ" μ•ˆλ‚΄ UI /// (μ‹€μ œ κ°μ‹œ λ‘œμ§μ€ pocket_watch_service.dart의 λ°±κ·ΈλΌμš΄λ“œ μ„œλΉ„μŠ€κ°€ λ‹΄λ‹Ήν•˜λ―€λ‘œ, /// 이 화면은 μƒνƒœλ₯Ό 보여주고 화면을 꺼도 λœλ‹€λŠ” κ²ƒλ§Œ μ•ˆλ‚΄ν•œλ‹€.) Widget _buildPocketWatchScreen() { return Scaffold( backgroundColor: const Color(0xFF0B1120), body: SafeArea( child: Padding( padding: const EdgeInsets.all(32.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 160, height: 160, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.greenAccent.withValues(alpha: 0.15), border: Border.all(color: Colors.greenAccent, width: 3), ), child: const Icon( Icons.shield_rounded, size: 72, color: Colors.greenAccent, ), ), const SizedBox(height: 40), Text( _activePocketNumber ?? "", style: const TextStyle( color: Colors.white54, fontSize: 14, letterSpacing: 2, ), ), const SizedBox(height: 12), Text( _statusMessage, textAlign: TextAlign.center, style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, height: 1.4, ), ), const SizedBox(height: 8), const Text( "πŸ“΄ 화면을 꺼도 κ°μ‹œλŠ” κ³„μ†λ©λ‹ˆλ‹€.\nμ•Œλ¦Όλ°”μ—μ„œ μƒνƒœλ₯Ό 확인할 수 μžˆμ–΄μš”.", textAlign: TextAlign.center, style: TextStyle(color: Colors.white38, fontSize: 13), ), const SizedBox(height: 40), OutlinedButton.icon( onPressed: _checkOut, icon: const Icon(Icons.logout_rounded, color: Colors.white70), label: const Text( "폰 νšŒμˆ˜ν•˜κ³  κ°μ‹œ μ’…λ£Œ (ν…ŒμŠ€νŠΈμš©)", style: TextStyle(color: Colors.white70), ), style: OutlinedButton.styleFrom( side: const BorderSide(color: Colors.white30), ), ), ], ), ), ), ); } }