lib/ 코드를 UI(lib/ui/)와 기능(lib/function/)으로 분리
- 화면마다 위젯/스타일만 담당하는 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 경로만 갱신하고 리팩터링은 보류
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
// 🛠️ 관리자 대시보드의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class AdminController extends ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
Future<String> resetDatabase() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
// 🆕 백엔드 보안 규칙에 맞추어 초기화 토큰 비밀번호 파라미터(?password=...)를 연동 주소에 매칭했습니다.
|
||||
final url = Uri.parse('$baseUrl/reset-db?password=adminreset2010');
|
||||
|
||||
try {
|
||||
final response = await http.get(url);
|
||||
if (response.statusCode == 200) {
|
||||
return '💥 DB가 깔끔하게 초기화되었습니다! (출석 번호 1번부터 시작)';
|
||||
} else {
|
||||
return '❌ 초기화 실패: 서버 권한 오류 (${response.statusCode})';
|
||||
}
|
||||
} catch (e) {
|
||||
return '❌ 네트워크 에러: 서버와 연결할 수 없습니다.';
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// 🔑 로그인 화면의 기능(서버 통신/인증 분기 로직) 담당 컨트롤러.
|
||||
// 위젯/BuildContext/다이얼로그 코드는 없다 — 전부 UI 파일(lib/screens/login_screen.dart) 몫.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart' show ChangeNotifier, kIsWeb;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import '../config.dart';
|
||||
|
||||
enum LoginOutcome { masterSuccess, needsPasswordChange, success, error }
|
||||
|
||||
/// 로그인 시도 결과. UI는 [outcome]만 보고 어떤 화면/다이얼로그를 띄울지 분기한다.
|
||||
class LoginResult {
|
||||
final LoginOutcome outcome;
|
||||
final String? role;
|
||||
final String? studentId;
|
||||
final String? name;
|
||||
final bool isDeviceMatched;
|
||||
final String? errorMessage;
|
||||
|
||||
const LoginResult.masterSuccess({
|
||||
required this.studentId,
|
||||
required this.name,
|
||||
}) : outcome = LoginOutcome.masterSuccess,
|
||||
role = null,
|
||||
isDeviceMatched = true,
|
||||
errorMessage = null;
|
||||
|
||||
const LoginResult.needsPasswordChange({
|
||||
required this.studentId,
|
||||
required this.name,
|
||||
required this.role,
|
||||
required this.isDeviceMatched,
|
||||
}) : outcome = LoginOutcome.needsPasswordChange,
|
||||
errorMessage = null;
|
||||
|
||||
const LoginResult.success({
|
||||
required this.role,
|
||||
required this.studentId,
|
||||
required this.name,
|
||||
required this.isDeviceMatched,
|
||||
}) : outcome = LoginOutcome.success,
|
||||
errorMessage = null;
|
||||
|
||||
const LoginResult.error(this.errorMessage)
|
||||
: outcome = LoginOutcome.error,
|
||||
role = null,
|
||||
studentId = null,
|
||||
name = null,
|
||||
isDeviceMatched = false;
|
||||
}
|
||||
|
||||
class LoginController extends ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
Future<LoginResult> login(String id, String password) async {
|
||||
if (id.isEmpty || password.isEmpty) {
|
||||
return const LoginResult.error('⚠️ 학번과 비밀번호를 모두 입력해 주세요.');
|
||||
}
|
||||
|
||||
// 🔥 [개발자 마스터 계정]
|
||||
if (id == "2061") {
|
||||
if (password == "happy9642!") {
|
||||
return const LoginResult.masterSuccess(
|
||||
studentId: "2061",
|
||||
name: "훗춧가룻",
|
||||
);
|
||||
} else {
|
||||
return const LoginResult.error('개발자 계정의 마스터 비밀번호가 올바르지 않습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
String deviceUuid = await getDeviceUuid();
|
||||
String fcmToken = "";
|
||||
|
||||
try {
|
||||
fcmToken = kIsWeb
|
||||
? await FirebaseMessaging.instance.getToken(
|
||||
vapidKey: webVapidKey,
|
||||
) ??
|
||||
""
|
||||
: await FirebaseMessaging.instance.getToken() ?? "";
|
||||
} catch (e) {
|
||||
// 파이어베이스 미설정 등 - 로그인 자체는 계속 진행
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/login'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({
|
||||
"studentId": id,
|
||||
"password": password,
|
||||
"deviceUuid": deviceUuid,
|
||||
"fcmToken": fcmToken,
|
||||
}),
|
||||
);
|
||||
|
||||
final resData = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
String role = '';
|
||||
String name = '';
|
||||
String studentId = '';
|
||||
int isFirstLogin = 0;
|
||||
|
||||
var userObj = resData['user'];
|
||||
if (userObj != null) {
|
||||
role = userObj['role']?.toString() ?? '';
|
||||
name =
|
||||
userObj['name']?.toString() ??
|
||||
userObj['studentName']?.toString() ??
|
||||
userObj['student_name']?.toString() ??
|
||||
'';
|
||||
studentId =
|
||||
userObj['studentId']?.toString() ??
|
||||
userObj['student_id']?.toString() ??
|
||||
id;
|
||||
|
||||
var rawFirst = userObj['isFirstLogin'] ?? userObj['is_first_login'];
|
||||
isFirstLogin = (rawFirst is bool)
|
||||
? (rawFirst ? 1 : 0)
|
||||
: (int.tryParse(rawFirst.toString()) ?? 0);
|
||||
} else {
|
||||
role = resData['role']?.toString() ?? '';
|
||||
name =
|
||||
resData['name']?.toString() ??
|
||||
resData['studentName']?.toString() ??
|
||||
'';
|
||||
studentId = resData['studentId']?.toString() ?? id;
|
||||
|
||||
var rawFirst = resData['isFirstLogin'] ?? resData['is_first_login'];
|
||||
isFirstLogin = (rawFirst is bool)
|
||||
? (rawFirst ? 1 : 0)
|
||||
: (int.tryParse(rawFirst.toString()) ?? 0);
|
||||
}
|
||||
|
||||
if (name.trim().isEmpty) {
|
||||
name = "알수없음";
|
||||
}
|
||||
|
||||
// 🆕 서버 응답 데이터에서 기기 일치 여부 추출하기
|
||||
// (서버가 주는 Key 이름에 맞춰서 데이터를 가져옵니다. 아래 항목 중 맞는 게 알아서 들어감)
|
||||
bool isDeviceMatched =
|
||||
resData['isDeviceMatched'] ??
|
||||
resData['isUuidMatched'] ??
|
||||
resData['is_matched'] ??
|
||||
(userObj != null
|
||||
? (userObj['isDeviceMatched'] ?? userObj['is_matched'] ?? false)
|
||||
: false);
|
||||
|
||||
if (isFirstLogin == 1) {
|
||||
return LoginResult.needsPasswordChange(
|
||||
studentId: studentId,
|
||||
name: name,
|
||||
role: role,
|
||||
isDeviceMatched: isDeviceMatched,
|
||||
);
|
||||
}
|
||||
|
||||
return LoginResult.success(
|
||||
role: role,
|
||||
studentId: studentId,
|
||||
name: name,
|
||||
isDeviceMatched: isDeviceMatched,
|
||||
);
|
||||
} else {
|
||||
String errorMsg = '로그인에 실패했습니다.';
|
||||
if (resData is Map) {
|
||||
errorMsg =
|
||||
resData['detail']?.toString() ??
|
||||
resData['message']?.toString() ??
|
||||
errorMsg;
|
||||
}
|
||||
return LoginResult.error(errorMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
return LoginResult.error('서버와 연결할 수 없습니다. 서버 상태를 확인하세요!\n($e)');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🛠️ 초기 비밀번호 변경. (성공여부, 메시지)를 반환한다.
|
||||
Future<(bool success, String message)> changePassword({
|
||||
required String studentId,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/users/change-password'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"studentId": studentId, "newPassword": newPassword}),
|
||||
);
|
||||
|
||||
final resData = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
|
||||
if (response.statusCode == 200 && resData['status'] == 'success') {
|
||||
return (true, '✅ 비밀번호가 변경되었습니다!');
|
||||
} else {
|
||||
return (false, '❌ 실패: ${resData['message']}');
|
||||
}
|
||||
} catch (e) {
|
||||
return (false, '서버 에러 발생: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// 📲 자습실 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// 🏷️ (관리자용) NFC 주머니 태그 쓰기 화면의 기능(NFC 세션/서버 통신/상태) 담당 컨트롤러.
|
||||
// 실제 스낵바 표시나 텍스트필드 조작은 UI 파일(lib/ui/nfc_tag_writer_screen.dart)의
|
||||
// 콜백을 통해서만 이루어진다 — 이 파일엔 위젯 코드가 없다.
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.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_record.dart';
|
||||
import 'package:nfc_manager_ndef/nfc_manager_ndef.dart';
|
||||
import '../config.dart';
|
||||
|
||||
class NfcTagWriterController extends ChangeNotifier {
|
||||
/// (메시지, 색상) — 스낵바로 보여줄 이벤트. UI가 정의해서 넘겨준다.
|
||||
final void Function(String message, bool isError) onMessage;
|
||||
|
||||
/// 태그 쓰기에 성공하면 다음 스티커 번호를 제안한다. UI가 텍스트필드를 갱신한다.
|
||||
final void Function(String nextNumber) onNextNumberSuggested;
|
||||
|
||||
NfcTagWriterController({
|
||||
required this.onMessage,
|
||||
required this.onNextNumberSuggested,
|
||||
});
|
||||
|
||||
bool _isWriting = false;
|
||||
String _statusMessage = "주머니 번호를 입력하고 '쓰기 시작'을 눌러주세요.";
|
||||
|
||||
bool get isWriting => _isWriting;
|
||||
String get statusMessage => _statusMessage;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
NfcManager.instance.stopSession();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> startWriteSession(String number) async {
|
||||
if (number.isEmpty) {
|
||||
onMessage("⚠️ 주머니 번호를 입력해주세요.", true);
|
||||
return;
|
||||
}
|
||||
final String pocketLabel = "POCKET_$number";
|
||||
|
||||
bool isAvailable = await NfcManager.instance.isAvailable();
|
||||
if (!isAvailable) {
|
||||
onMessage("❌ NFC가 꺼져있거나 지원되지 않습니다.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
_isWriting = true;
|
||||
_statusMessage = "📡 [$pocketLabel] 쓸 준비 완료! 스티커에 폰 뒷면을 대주세요.";
|
||||
notifyListeners();
|
||||
|
||||
NfcManager.instance.startSession(
|
||||
pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693},
|
||||
onDiscovered: (NfcTag tag) async {
|
||||
try {
|
||||
final ndef = Ndef.from(tag);
|
||||
if (ndef == null) {
|
||||
onMessage("❌ 이 태그는 NDEF 쓰기를 지원하지 않는 종류입니다.", true);
|
||||
return;
|
||||
}
|
||||
if (!ndef.isWritable) {
|
||||
onMessage("❌ 이 태그는 쓰기 잠금(read-only) 상태입니다.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
await ndef.write(
|
||||
message: NdefMessage(records: [_createTextRecord(pocketLabel)]),
|
||||
);
|
||||
|
||||
// 🔒 태그 복제 방지: 하드웨어 UID를 서버에 등록해서 이 물리 태그만 [pocketLabel]로 인정되게 한다.
|
||||
final String? tagUid = _getTagUid(tag);
|
||||
if (tagUid != null) {
|
||||
await _registerTagUid(tagUid, pocketLabel);
|
||||
}
|
||||
|
||||
onMessage(
|
||||
tagUid != null
|
||||
? "✅ [$pocketLabel] 쓰기 + UID 등록 성공!"
|
||||
: "⚠️ [$pocketLabel] 쓰기는 성공했지만 UID를 못 읽어 등록은 안 됐습니다.",
|
||||
tagUid == null,
|
||||
);
|
||||
// 다음 스티커를 연달아 쓰기 편하도록 번호를 자동으로 1 올려준다.
|
||||
final int? n = int.tryParse(number);
|
||||
if (n != null) onNextNumberSuggested((n + 1).toString());
|
||||
_statusMessage = "다음 번호를 확인하고 '쓰기 시작'을 다시 눌러주세요.";
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
onMessage("❌ 쓰기 실패: $e", true);
|
||||
} finally {
|
||||
await NfcManager.instance.stopSession();
|
||||
_isWriting = 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();
|
||||
}
|
||||
|
||||
/// 서버에 "이 UID는 이 주머니 번호다"를 등록한다.
|
||||
Future<void> _registerTagUid(String tagUid, String pocketLabel) async {
|
||||
try {
|
||||
await http.post(
|
||||
Uri.parse("$baseUrl/api/pockets/register"),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"tagUid": tagUid, "pocketNumber": pocketLabel}),
|
||||
);
|
||||
} catch (e) {
|
||||
onMessage("❌ 서버에 UID 등록 실패: $e", true);
|
||||
}
|
||||
}
|
||||
|
||||
/// NFC Forum Text Record Type Definition에 맞춰 "언어코드+텍스트" 페이로드를 만든다.
|
||||
NdefRecord _createTextRecord(String text) {
|
||||
const languageCode = 'en';
|
||||
final languageBytes = utf8.encode(languageCode);
|
||||
final textBytes = utf8.encode(text);
|
||||
final payload = Uint8List.fromList([
|
||||
languageBytes.length, // 상태 바이트: UTF-8 + 언어코드 길이
|
||||
...languageBytes,
|
||||
...textBytes,
|
||||
]);
|
||||
return NdefRecord(
|
||||
typeNameFormat: TypeNameFormat.wellKnown,
|
||||
type: Uint8List.fromList([0x54]), // 'T' = Text Record
|
||||
identifier: Uint8List(0),
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// 🎓 학생 대시보드(마스터 계정 전용 계정 강제 삭제)의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class StudentDashboardController extends ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// 👑 마스터 계정 전용 회원 삭제 API 호출.
|
||||
Future<(bool success, String message)> deleteUser(String userId) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
final url = Uri.parse('$baseUrl/api/users/delete/$userId');
|
||||
|
||||
try {
|
||||
final response = await http.delete(url);
|
||||
if (response.statusCode == 200) {
|
||||
final responseData = jsonDecode(response.body);
|
||||
return (true, '✅ ${responseData['message']}');
|
||||
} else {
|
||||
final errorData = jsonDecode(response.body);
|
||||
return (false, '❌ 삭제 실패: ${errorData['detail'] ?? '알 수 없는 오류'}');
|
||||
}
|
||||
} catch (e) {
|
||||
return (false, '❌ 서버와 연결할 수 없습니다. (네트워크 에러)');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// 🔐 (마스터 계정용) 학생 계정 및 기기 관리 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class StudentManagementController extends ChangeNotifier {
|
||||
List<dynamic> _students = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
List<dynamic> get students => _students;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
Future<void> init() => fetchStudents();
|
||||
|
||||
/// 🌐 서버에서 전체 학생 목록 불러오기. 실패 시 에러 메시지를 반환한다(성공 시 null).
|
||||
Future<String?> fetchStudents() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final response = await http.get(Uri.parse('$baseUrl/api/users'));
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_students = data['users'] ?? [];
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return '데이터 불러오기 실패 (${response.statusCode})';
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return '데이터 불러오기 실패: $e';
|
||||
}
|
||||
}
|
||||
|
||||
/// 🌐 서버로 기기 초기화(리셋) 명령 보내기
|
||||
Future<(bool success, String message)> resetDevice(
|
||||
String studentId,
|
||||
String studentName,
|
||||
) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/users/reset'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"studentId": studentId}),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
await fetchStudents();
|
||||
return (true, '✅ $studentName 학생 기기 초기화 완료!');
|
||||
}
|
||||
return (false, '❌ 초기화 통신 실패');
|
||||
} catch (e) {
|
||||
return (false, '❌ 초기화 통신 실패');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🌐 서버로 계정 완전 삭제 명령 보내기
|
||||
Future<(bool success, String message)> deleteUser(
|
||||
String studentId,
|
||||
String studentName,
|
||||
) async {
|
||||
try {
|
||||
final response = await http.delete(
|
||||
Uri.parse('$baseUrl/api/users/delete'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"studentId": studentId}),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
await fetchStudents();
|
||||
return (true, '💥 $studentName 학생의 계정이 영구 삭제되었습니다.');
|
||||
}
|
||||
return (false, '❌ 계정 삭제 통신 실패');
|
||||
} catch (e) {
|
||||
return (false, '❌ 계정 삭제 통신 실패');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🌐 신규 학생 계정 생성
|
||||
Future<(bool success, String message)> createUser(
|
||||
String studentId,
|
||||
String name,
|
||||
) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/users/create'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"studentId": studentId, "name": name}),
|
||||
);
|
||||
final res = jsonDecode(response.body);
|
||||
if (res['status'] == 'success') {
|
||||
await fetchStudents();
|
||||
return (true, '✅ ${res['message']}');
|
||||
}
|
||||
return (false, '❌ ${res['message']}');
|
||||
} catch (e) {
|
||||
return (false, '❌ 학생 등록 실패 (통신 에러)');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// 📋 실시간 출석 현황 화면의 기능(상태/서버 통신/파생 로직) 담당 컨트롤러.
|
||||
// UI(lib/ui/teacher_attendance_page.dart)는 이 컨트롤러를 구독해서 화면만 그린다.
|
||||
// 이 파일에는 위젯/BuildContext/다이얼로그 코드가 없어야 한다 — 전부 UI 파일 몫.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
// 로그의 'name' 컬럼은 백엔드에서 "학생이름 (주머니정보)" 형태로 합쳐져 저장되어 있어서
|
||||
// 이름과 주머니 번호를 분리해서 보여주려면 클라이언트에서 파싱해야 한다.
|
||||
final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$');
|
||||
|
||||
class TeacherAttendanceController extends ChangeNotifier {
|
||||
List<dynamic> _roster = []; // 전체 학생 명단 (/api/users)
|
||||
List<dynamic> _logs = []; // 출석 로그 (/api/logs)
|
||||
Map<String, dynamic> _activeViolationsByStudentId = {}; // 무단반출 중인 학생 (/api/violations/active)
|
||||
String? _dismissedAt; // 오늘 가장 최근 하교 처리 시각 (/api/dismissal/latest). 이 시각 이후 기록만 "오늘 출석"으로 표시.
|
||||
String? _attendanceTime; // 선생님이 지정한 자습실 출석시간 "HH:MM" (/api/settings/attendance-time). null이면 미설정.
|
||||
String? _globalPermissionUntil; // 하교(12시간)/반출허용시간설정으로 전체 반출이 허용된 경우 그 만료 시각 (/api/permissions/status). null이면 비활성.
|
||||
Timer? _timer;
|
||||
bool _isLoading = true;
|
||||
bool _isRefreshing = false; // 새로고침 버튼 클릭 시 잠깐 도는 표시용
|
||||
String _filterType = "ALL"; // "ALL", "CHECKED_IN", "ABSENT"
|
||||
bool _disposed = false;
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// UI가 읽는 공개 상태
|
||||
// ----------------------------------------------------------------
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isRefreshing => _isRefreshing;
|
||||
String get filterType => _filterType;
|
||||
String? get attendanceTime => _attendanceTime;
|
||||
String? get globalPermissionUntil => _globalPermissionUntil;
|
||||
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
void init() {
|
||||
fetchAll();
|
||||
_timer = Timer.periodic(const Duration(seconds: 3), (timer) => fetchAll());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> manualRefresh() async {
|
||||
_isRefreshing = true;
|
||||
_safeNotify();
|
||||
await fetchAll();
|
||||
_isRefreshing = false;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
Future<void> fetchAll() async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
http.get(Uri.parse('$baseUrl/api/users')),
|
||||
http.get(Uri.parse('$baseUrl/api/logs')),
|
||||
http.get(Uri.parse('$baseUrl/api/violations/active')),
|
||||
http.get(Uri.parse('$baseUrl/api/dismissal/latest')),
|
||||
http.get(Uri.parse('$baseUrl/api/settings/attendance-time')),
|
||||
http.get(Uri.parse('$baseUrl/api/permissions/status')),
|
||||
]);
|
||||
|
||||
final usersRes = results[0];
|
||||
final logsRes = results[1];
|
||||
final violationsRes = results[2];
|
||||
final dismissalRes = results[3];
|
||||
final attendanceTimeRes = results[4];
|
||||
final permissionStatusRes = results[5];
|
||||
|
||||
if (usersRes.statusCode == 200 && logsRes.statusCode == 200) {
|
||||
final usersData = jsonDecode(utf8.decode(usersRes.bodyBytes));
|
||||
final logsData = jsonDecode(utf8.decode(logsRes.bodyBytes));
|
||||
|
||||
Map<String, dynamic> activeViolations = {};
|
||||
if (violationsRes.statusCode == 200) {
|
||||
final violationsData = jsonDecode(utf8.decode(violationsRes.bodyBytes));
|
||||
for (final v in (violationsData['violations'] ?? [])) {
|
||||
activeViolations[v['student_id'].toString()] = v;
|
||||
}
|
||||
}
|
||||
|
||||
String? dismissedAt;
|
||||
if (dismissalRes.statusCode == 200) {
|
||||
final dismissalData = jsonDecode(utf8.decode(dismissalRes.bodyBytes));
|
||||
dismissedAt = dismissalData['dismissedAt'];
|
||||
}
|
||||
|
||||
String? attendanceTime;
|
||||
if (attendanceTimeRes.statusCode == 200) {
|
||||
final attendanceTimeData =
|
||||
jsonDecode(utf8.decode(attendanceTimeRes.bodyBytes));
|
||||
attendanceTime = attendanceTimeData['attendanceTime'];
|
||||
}
|
||||
|
||||
String? globalPermissionUntil;
|
||||
if (permissionStatusRes.statusCode == 200) {
|
||||
final permissionStatusData =
|
||||
jsonDecode(utf8.decode(permissionStatusRes.bodyBytes));
|
||||
if (permissionStatusData['active'] == true) {
|
||||
globalPermissionUntil = permissionStatusData['permittedUntil'];
|
||||
}
|
||||
}
|
||||
|
||||
_roster = usersData['users'] ?? [];
|
||||
_logs = logsData['logs'] ?? [];
|
||||
_activeViolationsByStudentId = activeViolations;
|
||||
_dismissedAt = dismissedAt;
|
||||
_attendanceTime = attendanceTime;
|
||||
_globalPermissionUntil = globalPermissionUntil;
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
} else {
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
_safeNotify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🏫 하교 처리: 전체 반출 허용 + 오늘 출석 표시 기준선을 지금 시각으로 옮긴다.
|
||||
Future<String> dismissAll() async {
|
||||
try {
|
||||
final response = await http.post(Uri.parse('$baseUrl/api/dismiss'));
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
await fetchAll();
|
||||
return result['message'] ?? '하교 처리되었습니다.';
|
||||
} catch (e) {
|
||||
return '❌ 하교 처리 실패: $e';
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚨 선생님이 특정 학생에게 지금부터 N분간 반출을 허용한다.
|
||||
Future<String> allowRemoval(String studentId, int minutes) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/violations/allow'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"studentId": studentId, "minutes": minutes}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
await fetchAll();
|
||||
return result['message'] ?? '처리되었습니다.';
|
||||
} catch (e) {
|
||||
return '❌ 반출 허용 실패: $e';
|
||||
}
|
||||
}
|
||||
|
||||
/// ⏰ 지금부터 N분간 전체 학생의 반출을 자동으로 허용한다 (쉬는시간 등).
|
||||
Future<String> setPermissionWindow(int minutes) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/permissions/window'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"minutes": minutes}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
await fetchAll();
|
||||
return result['message'] ?? '처리되었습니다.';
|
||||
} catch (e) {
|
||||
return '❌ 설정 실패: $e';
|
||||
}
|
||||
}
|
||||
|
||||
/// ⏰ 자습실 출석시간(기준 시각)을 지정한다. 이 시각 이후 태깅한 학생만 "출석 완료"로 강조 표시된다.
|
||||
Future<String> setAttendanceTime(String time) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/settings/attendance-time'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"time": time}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
await fetchAll();
|
||||
return result['message'] ?? '처리되었습니다.';
|
||||
} catch (e) {
|
||||
return '❌ 출석시간 설정 실패: $e';
|
||||
}
|
||||
}
|
||||
|
||||
/// 🧪 테스트용: 하교(12시간)/반출 허용 시간 설정 등으로 켜져 있는 허용 시간대를 즉시 해제한다.
|
||||
Future<String> resetTestPermissions() async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/debug/reset-permissions'),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
await fetchAll();
|
||||
return result['message'] ?? '처리되었습니다.';
|
||||
} catch (e) {
|
||||
return '❌ 초기화 실패: $e';
|
||||
}
|
||||
}
|
||||
|
||||
void setFilter(String type) {
|
||||
_filterType = type;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// 파생 로직 (출석 상태 계산)
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/// "오늘 출석"의 기준선. 하교 처리를 했다면 그 시각 이후, 안 했다면 오늘 자정부터.
|
||||
Map<String, Map<String, String>> get _todaysCheckInsByStudentId {
|
||||
// "YYYY-MM-DD HH:MM:SS" 형태의 문자열끼리는 그대로 비교해도 시간 순서가 맞는다.
|
||||
final String cutoff =
|
||||
_dismissedAt ??
|
||||
'${DateTime.now().toIso8601String().substring(0, 10)} 00:00:00';
|
||||
final Map<String, Map<String, String>> result = {};
|
||||
|
||||
for (final log in _logs) {
|
||||
final String time = log['time']?.toString() ?? '';
|
||||
if (time.isEmpty || time.compareTo(cutoff) <= 0) {
|
||||
continue; // 하교 처리 시각(또는 오늘 자정) 이전 기록이면 무시
|
||||
}
|
||||
|
||||
final String studentId = log['student_id']?.toString() ?? '';
|
||||
if (studentId.isEmpty) continue;
|
||||
|
||||
// 이미 더 최신 기록을 찾았다면(로그는 최신순 정렬) 건너뛴다.
|
||||
if (result.containsKey(studentId)) continue;
|
||||
|
||||
final String rawName = log['name']?.toString() ?? '';
|
||||
final match = _logNamePattern.firstMatch(rawName);
|
||||
result[studentId] = {
|
||||
'name': match != null ? match.group(1)! : rawName,
|
||||
'pocketNumber': match != null ? match.group(2)! : '주머니 미지정',
|
||||
'time': time,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 지금까지(오늘 이전 포함) 단 한 번이라도 태깅한 적 있는 학생 학번 집합.
|
||||
/// (오늘 미제출인 학생 중에서도 "한 번도 태깅 안 해본 애"를 구분하기 위함)
|
||||
Set<String> get _everCheckedInStudentIds {
|
||||
final Set<String> ids = {};
|
||||
for (final log in _logs) {
|
||||
final String studentId = log['student_id']?.toString() ?? '';
|
||||
if (studentId.isNotEmpty) ids.add(studentId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// ⏰ 태깅 시간과 지정된 자습실 출석시간을 비교해 'NONE' / 'PENDING' / 'COMPLETE'를 반환한다.
|
||||
/// - NONE: 아직 태깅 안 함
|
||||
/// - PENDING: 태깅은 했지만 지정된 출석시간 이전이라 아직 "출석 완료"로 안 침
|
||||
/// - COMPLETE: 출석시간 미지정이거나, 지정된 출석시간 이후에 태깅함
|
||||
String _computeAttendanceStatus(String? checkInTime) {
|
||||
if (checkInTime == null) return 'NONE';
|
||||
if (_attendanceTime == null) return 'COMPLETE';
|
||||
|
||||
final String todayStr = DateTime.now().toIso8601String().substring(0, 10);
|
||||
final String cutoff = '$todayStr $_attendanceTime:00';
|
||||
return checkInTime.compareTo(cutoff) >= 0 ? 'COMPLETE' : 'PENDING';
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get combinedStudentStatus {
|
||||
final checkIns = _todaysCheckInsByStudentId;
|
||||
final everCheckedIn = _everCheckedInStudentIds;
|
||||
return _roster.map((u) {
|
||||
final String id = u['id']?.toString() ?? '';
|
||||
final checkIn = checkIns[id];
|
||||
final violation = _activeViolationsByStudentId[id];
|
||||
final String attendanceStatus = _computeAttendanceStatus(checkIn?['time']);
|
||||
return {
|
||||
'studentId': id,
|
||||
'studentName': u['name']?.toString() ?? '',
|
||||
'isCheckedIn': checkIn != null,
|
||||
'pocketNumber': checkIn?['pocketNumber'],
|
||||
'checkInTime': checkIn?['time'],
|
||||
'attendanceStatus': attendanceStatus,
|
||||
'isAttendanceComplete': attendanceStatus == 'COMPLETE',
|
||||
'hasEverCheckedIn': everCheckedIn.contains(id),
|
||||
'hasActiveViolation': violation != null,
|
||||
'violationPocket': violation?['pocket_number'],
|
||||
'violationTime': violation?['time'],
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get filteredStudents {
|
||||
final all = combinedStudentStatus;
|
||||
if (_filterType == "CHECKED_IN") {
|
||||
return all.where((s) => s['isAttendanceComplete'] == true).toList();
|
||||
} else if (_filterType == "ABSENT") {
|
||||
return all.where((s) => s['isAttendanceComplete'] == false).toList();
|
||||
}
|
||||
return all;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// 👨🏫 교사 회원가입 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class TeacherRegisterController extends ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// (성공여부, 메시지)를 반환한다 — UI가 성공 시에만 이전 화면으로 돌아간다.
|
||||
Future<(bool success, String message)> registerTeacher({
|
||||
required String id,
|
||||
required String password,
|
||||
required String name,
|
||||
required String secretCode,
|
||||
}) async {
|
||||
// 💡 테스트용 고유값 (실제 디바이스 UUID 연동 로직이 있다면 그걸 넣으세요)
|
||||
final String dummyUuid = "TEACHER_PHONE_$id";
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/users/register-teacher'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({
|
||||
"teacherId": id,
|
||||
"password": password,
|
||||
"name": name,
|
||||
"secretCode": secretCode,
|
||||
"deviceUuid": dummyUuid,
|
||||
}),
|
||||
);
|
||||
|
||||
final res = jsonDecode(response.body);
|
||||
|
||||
if (response.statusCode == 200 && res['status'] == 'success') {
|
||||
return (true, '✅ ${res['message']}');
|
||||
} else {
|
||||
String errorMsg = res['detail'] ?? res['message'] ?? '회원가입에 실패했습니다.';
|
||||
return (false, '❌ $errorMsg');
|
||||
}
|
||||
} catch (e) {
|
||||
return (false, '❌ 서버와 통신에 실패했습니다.');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class TeacherStudentManagementController extends ChangeNotifier {
|
||||
bool _isWorking = false;
|
||||
bool get isWorking => _isWorking;
|
||||
|
||||
/// ➕ 학생 계정 추가. (성공여부, 메시지)를 반환한다 — UI가 성공했을 때만 입력칸을 비운다.
|
||||
Future<(bool success, String message)> addStudentAccount({
|
||||
required String studentId,
|
||||
required String name,
|
||||
required String password,
|
||||
}) async {
|
||||
_isWorking = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final url = Uri.parse('$baseUrl/api/users/register-student');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({
|
||||
"studentId": studentId,
|
||||
"name": name,
|
||||
"password": password,
|
||||
}),
|
||||
);
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
return (true, '✅ 계정 생성 완료: ${result['message'] ?? '성공'}');
|
||||
} else {
|
||||
return (false, '❌ 생성 실패: ${result['message'] ?? '오류 발생'}');
|
||||
}
|
||||
} catch (e) {
|
||||
return (false, '🚨 네트워크 에러: $e');
|
||||
} finally {
|
||||
_isWorking = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// ❌ 학생 계정 삭제.
|
||||
Future<(bool success, String message)> deleteStudentAccount(
|
||||
String studentId,
|
||||
) async {
|
||||
_isWorking = true;
|
||||
notifyListeners();
|
||||
final url = Uri.parse('$baseUrl/api/users/delete/$studentId');
|
||||
try {
|
||||
final response = await http.delete(url);
|
||||
if (response.statusCode == 200) {
|
||||
final resData = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
return (true, '✅ ${resData['message']}');
|
||||
} else {
|
||||
return (false, '❌ 삭제 실패하였습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
return (false, '❌ 서버 에러 발생');
|
||||
} finally {
|
||||
_isWorking = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user