- 조도 센서가 폰 반출을 감지하면, 위반 경고를 울리기 전에 서버에 반출 허용 상태(하교/허용시간대)를 먼저 확인 - 허용된 상태면 위반 경고 없이 조용히 정상 회수로 처리
398 lines
15 KiB
Dart
398 lines
15 KiB
Dart
// 📲 자습실 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<NfcPocketCheckInScreen> createState() => _NfcPocketCheckInScreenState();
|
|
}
|
|
|
|
class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
|
bool _isProcessing = false; // 중복 태깅 및 연타 방지 플래그
|
|
bool _isWatching = false; // 백그라운드 조도 센서 감시가 진행 중인지 여부
|
|
String _statusMessage = "주머니의 NFC 스티커에 폰 뒷면을 대어주세요.";
|
|
String? _activePocketNumber;
|
|
|
|
StreamSubscription<Map<String, dynamic>?>? _violationSub;
|
|
StreamSubscription<Map<String, dynamic>?>? _checkedOutSub;
|
|
bool _isDialogOpen = false; // 출석/위반 팝업이 겹쳐서 뜨는 것을 막기 위한 플래그
|
|
|
|
bool get _watchServiceSupported => !kIsWeb && Platform.isAndroid;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_startNfcSession();
|
|
if (_watchServiceSupported) {
|
|
// 화면이 열려있는 동안은 실시간으로 위반 알림을 받아 팝업을 띄운다.
|
|
// 무단 반출이 한 번 감지되면 "신뢰된 감시 세션"은 끝난 것으로 보고,
|
|
// 다음 태깅은 체크아웃이 아니라 새 출석(재출석)으로 처리되도록 감시 상태를 해제한다.
|
|
_violationSub = FlutterBackgroundService().on('violation_detected').listen((
|
|
event,
|
|
) {
|
|
if (!mounted) return;
|
|
setState(() => _isWatching = false);
|
|
_showViolationDialog(event?['pocketNumber']?.toString());
|
|
});
|
|
// 🏫 하교 처리 등으로 반출이 허용된 상태에서 폰을 꺼내면, 위반이 아니라 정상 회수로 처리된다.
|
|
_checkedOutSub = FlutterBackgroundService().on('checked_out').listen((event) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isWatching = false;
|
|
_activePocketNumber = null;
|
|
_statusMessage = "✅ 폰을 회수했습니다. 수고하셨습니다!";
|
|
});
|
|
_showSnackBar("✅ 폰이 정상적으로 회수되었습니다.", Colors.green);
|
|
});
|
|
}
|
|
}
|
|
|
|
@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) {
|
|
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<void> _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<void> _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<void> _checkOut() async {
|
|
FlutterBackgroundService().invoke('stop_watch');
|
|
setState(() {
|
|
_isWatching = false;
|
|
_activePocketNumber = null;
|
|
_statusMessage = "✅ 폰을 회수했습니다. 감시가 종료되었습니다.";
|
|
});
|
|
_showSnackBar("✅ 감시가 종료되었습니다. 수고하셨습니다!", Colors.green);
|
|
}
|
|
|
|
/// 이미 떠 있는 팝업(출석 완료/무단반출 감지)이 있으면 새 팝업을 띄우기 전에 먼저 닫는다.
|
|
/// (태깅→반출→재태깅이 빠르게 반복되면 팝업이 여러 개 겹쳐 쌓이는 것을 방지)
|
|
void _closeAnyOpenDialog() {
|
|
if (_isDialogOpen && mounted) {
|
|
Navigator.of(context, rootNavigator: true).pop();
|
|
}
|
|
_isDialogOpen = false;
|
|
}
|
|
|
|
void _showViolationDialog(String? pocketNumber) {
|
|
_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 ?? _activePocketNumber}] 주머니에서 휴대폰이 꺼내진 것으로 감지되었습니다.\n담당 선생님께 알림이 전송되었습니다.",
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text("확인"),
|
|
),
|
|
],
|
|
),
|
|
).then((_) => _isDialogOpen = false);
|
|
}
|
|
|
|
/// 🎊 출석 성공 알림창
|
|
void _showSuccessDialog(String pocketNumber) {
|
|
_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) {
|
|
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알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: Colors.white38, fontSize: 13),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|