// πŸ“² μžμŠ΅μ‹€ NFC μΆœμ„μ²΄ν¬ ν™”λ©΄ (UI μ „μš©). νƒœκΉ… μƒνƒœ ν™”λ©΄/νŒμ—…/μŠ€λ‚΅λ°”λ§Œ κ·Έλ¦°λ‹€. // NFC μ„Έμ…˜, μ„œλ²„ 톡신, λ°±κ·ΈλΌμš΄λ“œ κ°μ‹œ 연동은 // lib/function/nfc_pocket_checkin_controller.dartκ°€ λ‹΄λ‹Ήν•œλ‹€. import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../function/nfc_pocket_checkin_controller.dart'; 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 { late final NfcPocketCheckinController _controller; bool _isDialogOpen = false; // μΆœμ„/μœ„λ°˜ νŒμ—…μ΄ κ²Ήμ³μ„œ λœ¨λŠ” 것을 막기 μœ„ν•œ ν”Œλž˜κ·Έ @override void initState() { super.initState(); _controller = NfcPocketCheckinController( studentId: widget.studentId, studentName: widget.studentName, onMessage: _handleMessage, onCheckInSuccess: _showSuccessDialog, onViolationDetected: _showViolationDialog, onNeedGuidedAccessSetup: _showGuidedAccessDialog, ); _controller.init(); } @override void dispose() { _controller.dispose(); super.dispose(); } void _handleMessage(String text, WatchMessageLevel level) { final Color color = switch (level) { WatchMessageLevel.info => Colors.blueGrey, WatchMessageLevel.success => Colors.green, WatchMessageLevel.warning => Colors.orange, WatchMessageLevel.error => Colors.red, }; _showSnackBar(text, color); } /// 이미 λ–  μžˆλŠ” νŒμ—…(μΆœμ„ μ™„λ£Œ/λ¬΄λ‹¨λ°˜μΆœ 감지)이 있으면 μƒˆ νŒμ—…μ„ λ„μš°κΈ° 전에 λ¨Όμ € λ‹«λŠ”λ‹€. /// (νƒœκΉ…β†’λ°˜μΆœβ†’μž¬νƒœκΉ…μ΄ λΉ λ₯΄κ²Œ 반볡되면 νŒμ—…μ΄ μ—¬λŸ¬ 개 겹쳐 μŒ“μ΄λŠ” 것을 λ°©μ§€) void _closeAnyOpenDialog() { if (_isDialogOpen && mounted) { Navigator.of(context, rootNavigator: true).pop(); } _isDialogOpen = false; } void _showViolationDialog(String? pocketNumber) { if (!mounted) return; _closeAnyOpenDialog(); _isDialogOpen = true; showDialog( context: context, builder: (context) => AlertDialog( backgroundColor: Colors.red[50], title: const Text( "무단 반좜 감지", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold), ), content: Text( "[${pocketNumber ?? _controller.activePocketNumber}] μ£Όλ¨Έλ‹ˆμ—μ„œ νœ΄λŒ€ν°μ΄ κΊΌλ‚΄μ§„ κ²ƒμœΌλ‘œ κ°μ§€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.\nλ‹΄λ‹Ή μ„ μƒλ‹˜κ»˜ μ•Œλ¦Όμ΄ μ „μ†‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€.", ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text("확인"), ), ], ), ).then((_) => _isDialogOpen = false); } /// πŸ›‘οΈ iOS μ „μš©: κ·Όμ ‘μ„Όμ„œ κ°μ‹œκ°€ ν™”λ©΄ 잠금으둜 λŠκΈ°μ§€ μ•Šλ„λ‘, "κ°€μ΄λ“œ μ ‘κ·Ό"을 /// 직접 μΌœλ‹¬λΌκ³  μ•ˆλ‚΄ν•˜λŠ” νŒμ—…. 학생이 μΌ°λ‹€κ³  확인해야 μ‹€μ œ κ°μ‹œκ°€ μ‹œμž‘λœλ‹€. void _showGuidedAccessDialog(String pocketNumber) { if (!mounted) return; _closeAnyOpenDialog(); _isDialogOpen = true; showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: const Text("κ°μ‹œ λͺ¨λ“œ 켜기 전에"), content: const Text( "iOSλŠ” 화면을 잠그면 κ°μ‹œκ°€ λŠκ²¨μš”. μ•„λž˜ μˆœμ„œλ‘œ 'κ°€μ΄λ“œ μ ‘κ·Ό'을 λ¨Όμ € μΌœμ£Όμ„Έμš”.\n\n" "1. μ‚¬μ΄λ“œ(λ˜λŠ” ν™ˆ) λ²„νŠΌ 3번 λΉ λ₯΄κ²Œ λˆ„λ₯΄κΈ°\n" "2. λͺ©λ‘μ—μ„œ 'κ°€μ΄λ“œ μ ‘κ·Ό' 선택 β†’ ν™”λ©΄ 였λ₯Έμͺ½ μ•„λž˜ 'μ‹œμž‘' λˆ„λ₯΄κΈ°\n\n" "λ‹€ μΌœμ…¨μœΌλ©΄ μ•„λž˜ λ²„νŠΌμ„ 눌러 κ°μ‹œλ₯Ό μ‹œμž‘ν•˜μ„Έμš”.\n" "(κ°μ‹œλ₯Ό 끝낼 땐 λ¨Όμ € μ‚¬μ΄λ“œ λ²„νŠΌ 3번으둜 κ°€μ΄λ“œ 접근을 끈 λ‹€μŒ λ‹€μ‹œ νƒœκΉ…ν•˜μ„Έμš”.)", ), actions: [ TextButton( onPressed: () { Navigator.pop(context); _controller.beginIosProximityWatch(pocketNumber); }, child: const Text("κ°€μ΄λ“œ μ ‘κ·Ό μΌ°μ–΄μš”"), ), ], ), ).then((_) => _isDialogOpen = false); } /// 🎊 μΆœμ„ 성곡 μ•Œλ¦Όμ°½ void _showSuccessDialog(String pocketNumber) { if (!mounted) return; _closeAnyOpenDialog(); _isDialogOpen = true; 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("확인"), ), ], ), ).then((_) => _isDialogOpen = false); } void _showSnackBar(String text, Color color) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(text), backgroundColor: color)); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: _controller, builder: (context, _) { if (_controller.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( _controller.isProcessing ? Icons.sync : Icons.nfc, size: 100, color: _controller.isProcessing ? Colors.orange : Colors.blueAccent, ), const SizedBox(height: 30), // μƒνƒœ λ©”μ‹œμ§€ ν‘œμ‹œ Text( _controller.statusMessage, textAlign: TextAlign.center, style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: _controller.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( _controller.activePocketNumber ?? "", style: const TextStyle( color: Colors.white54, fontSize: 14, letterSpacing: 2, ), ), const SizedBox(height: 12), Text( _controller.statusMessage, textAlign: TextAlign.center, style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, height: 1.4, ), ), const SizedBox(height: 8), Text( !kIsWeb && Platform.isIOS ? "κ°€μ΄λ“œ 접근을 μΌ  μ±„λ‘œ ν™”λ©΄λ§Œ μž κΈ°λ„λ‘ λ‘μ„Έμš”.\n(μž„μ˜λ‘œ 앱을 λ‚˜κ°€λ©΄ κ°μ‹œκ°€ λŠκΉλ‹ˆλ‹€)\nκ°μ‹œλ₯Ό 끝내렀면 κ°€μ΄λ“œ 접근을 λ¨Όμ € 끄고 λ‹€μ‹œ νƒœκΉ…ν•˜μ„Έμš”." : "화면을 꺼도 κ°μ‹œλŠ” κ³„μ†λ©λ‹ˆλ‹€.\nμ•Œλ¦Όλ°”μ—μ„œ μƒνƒœλ₯Ό 확인할 수 μžˆμ–΄μš”.\n(폰을 κΊΌλ‚΄ λ‹€μ‹œ νƒœκΉ…ν•˜λ©΄ 반좜 μ²˜λ¦¬λ©λ‹ˆλ‹€)", textAlign: TextAlign.center, style: const TextStyle(color: Colors.white38, fontSize: 13), ), ], ), ), ), ); } }