- 화면마다 위젯/스타일만 담당하는 UI 파일과, 서버통신/상태/파생로직만 담당하는 ChangeNotifier 컨트롤러 파일로 1:1 분리 (lib/screens/ -> lib/ui/ + lib/function/) - UI는 ListenableBuilder로 컨트롤러를 구독해서 재렌더링, 버튼은 컨트롤러 메서드만 호출 - 다이얼로그/스낵바 등 위젯 코드는 전부 UI 파일에 남기고, 컨트롤러는 결과값(성공여부+메시지) 또는 콜백으로만 UI와 통신 (BuildContext/Widget 의존성 없음) - 디자인/레이아웃은 기존과 완전히 동일하게 유지 (순수 코드 재배치) - change_password_screen.dart, debug_pocket_main.dart(미사용 파일)는 깨지지 않게 import 경로만 갱신하고 리팩터링은 보류
241 lines
9.4 KiB
Dart
241 lines
9.4 KiB
Dart
// 📲 자습실 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_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;
|
|
|
|
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;
|
|
|
|
NfcPocketCheckinController({
|
|
required this.studentId,
|
|
required this.studentName,
|
|
required this.onMessage,
|
|
required this.onCheckInSuccess,
|
|
required this.onViolationDetected,
|
|
});
|
|
|
|
bool _isProcessing = false; // 중복 태깅 및 연타 방지 플래그
|
|
bool _isWatching = false; // 백그라운드 조도 센서 감시가 진행 중인지 여부
|
|
String _statusMessage = "주머니의 NFC 스티커에 폰 뒷면을 대어주세요.";
|
|
String? _activePocketNumber;
|
|
|
|
StreamSubscription<Map<String, dynamic>?>? _violationSub;
|
|
StreamSubscription<Map<String, dynamic>?>? _checkedOutSub;
|
|
|
|
bool get isProcessing => _isProcessing;
|
|
bool get isWatching => _isWatching;
|
|
String get statusMessage => _statusMessage;
|
|
String? get activePocketNumber => _activePocketNumber;
|
|
|
|
bool get watchServiceSupported => !kIsWeb && Platform.isAndroid;
|
|
|
|
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();
|
|
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<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": 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);
|
|
await _startBackgroundWatch(pocketNumber);
|
|
} else {
|
|
onMessage("⚠️ 출석 실패: ${result['message']}", WatchMessageLevel.warning);
|
|
}
|
|
} else {
|
|
onMessage(
|
|
"🚨 서버 에러 (코드: ${response.statusCode})",
|
|
WatchMessageLevel.error,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
onMessage("❌ 서버 연결 실패: $e", WatchMessageLevel.error);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 🛡️ 주머니 제출 모드: 화면이 꺼져도 백그라운드 서비스가 조도 센서로 감시
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// NFC 태깅 성공 직후 호출. 배터리 최적화 예외를 한 번 요청한 뒤 백그라운드 감시 서비스를 시작한다.
|
|
Future<void> _startBackgroundWatch(String pocketNumber) async {
|
|
if (!watchServiceSupported) {
|
|
onMessage("ℹ️ 이 기기(iOS)는 백그라운드 감시를 지원하지 않습니다.", WatchMessageLevel.info);
|
|
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<void> _checkOut() async {
|
|
FlutterBackgroundService().invoke('stop_watch');
|
|
_isWatching = false;
|
|
_activePocketNumber = null;
|
|
_statusMessage = "✅ 폰을 회수했습니다. 감시가 종료되었습니다.";
|
|
notifyListeners();
|
|
onMessage("✅ 감시가 종료되었습니다. 수고하셨습니다!", WatchMessageLevel.success);
|
|
}
|
|
}
|