From 29e319110c19800994856a396157329becce2e46 Mon Sep 17 00:00:00 2001 From: sihoo Date: Wed, 5 Aug 2026 15:04:24 +0900 Subject: [PATCH] =?UTF-8?q?lib/=20=EC=BD=94=EB=93=9C=EB=A5=BC=20UI(lib/ui/?= =?UTF-8?q?)=EC=99=80=20=EA=B8=B0=EB=8A=A5(lib/function/)=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면마다 위젯/스타일만 담당하는 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 경로만 갱신하고 리팩터링은 보류 --- lib/debug_pocket_main.dart | 2 +- lib/function/admin_controller.dart | 31 + lib/function/login_controller.dart | 211 +++++++ .../nfc_pocket_checkin_controller.dart | 240 ++++++++ lib/function/nfc_tag_writer_controller.dart | 141 +++++ .../student_dashboard_controller.dart | 33 + .../student_management_controller.dart | 102 ++++ .../teacher_attendance_controller.dart | 301 ++++++++++ lib/function/teacher_register_controller.dart | 51 ++ ...teacher_student_management_controller.dart | 67 +++ lib/main_client_app.dart | 2 +- lib/nfc_poccket_checkin_screen.dart | 397 ------------ lib/screens/admin_dashboard.dart | 154 ----- lib/screens/change_password_screen.dart | 2 +- lib/screens/login_screen.dart | 551 ----------------- lib/screens/nfc_tag_writer_screen.dart | 204 ------- lib/screens/student_dashboard.dart | 496 --------------- lib/screens/student_management_screen.dart | 338 ----------- lib/screens/teacher_register_screen.dart | 152 ----- .../teacher_student_management_page.dart | 363 ----------- lib/ui/admin_dashboard.dart | 142 +++++ lib/ui/login_screen.dart | 435 ++++++++++++++ lib/ui/nfc_poccket_checkin_screen.dart | 236 ++++++++ lib/ui/nfc_tag_writer_screen.dart | 114 ++++ lib/ui/student_dashboard.dart | 494 +++++++++++++++ lib/ui/student_management_screen.dart | 307 ++++++++++ .../teacher_attendance_page.dart | 565 +++++------------- lib/{screens => ui}/teacher_dashboard.dart | 0 lib/ui/teacher_register_screen.dart | 141 +++++ lib/ui/teacher_student_management_page.dart | 348 +++++++++++ 30 files changed, 3558 insertions(+), 3062 deletions(-) create mode 100644 lib/function/admin_controller.dart create mode 100644 lib/function/login_controller.dart create mode 100644 lib/function/nfc_pocket_checkin_controller.dart create mode 100644 lib/function/nfc_tag_writer_controller.dart create mode 100644 lib/function/student_dashboard_controller.dart create mode 100644 lib/function/student_management_controller.dart create mode 100644 lib/function/teacher_attendance_controller.dart create mode 100644 lib/function/teacher_register_controller.dart create mode 100644 lib/function/teacher_student_management_controller.dart delete mode 100644 lib/nfc_poccket_checkin_screen.dart delete mode 100644 lib/screens/admin_dashboard.dart delete mode 100644 lib/screens/login_screen.dart delete mode 100644 lib/screens/nfc_tag_writer_screen.dart delete mode 100644 lib/screens/student_dashboard.dart delete mode 100644 lib/screens/student_management_screen.dart delete mode 100644 lib/screens/teacher_register_screen.dart delete mode 100644 lib/screens/teacher_student_management_page.dart create mode 100644 lib/ui/admin_dashboard.dart create mode 100644 lib/ui/login_screen.dart create mode 100644 lib/ui/nfc_poccket_checkin_screen.dart create mode 100644 lib/ui/nfc_tag_writer_screen.dart create mode 100644 lib/ui/student_dashboard.dart create mode 100644 lib/ui/student_management_screen.dart rename lib/{screens => ui}/teacher_attendance_page.dart (60%) rename lib/{screens => ui}/teacher_dashboard.dart (100%) create mode 100644 lib/ui/teacher_register_screen.dart create mode 100644 lib/ui/teacher_student_management_page.dart diff --git a/lib/debug_pocket_main.dart b/lib/debug_pocket_main.dart index f5dcbc6..19b6374 100644 --- a/lib/debug_pocket_main.dart +++ b/lib/debug_pocket_main.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'nfc_poccket_checkin_screen.dart'; +import 'ui/nfc_poccket_checkin_screen.dart'; // 🧪 디버그 전용 진입점: NFC 주머니 체크인 + 조도 센서 감시 화면을 바로 테스트하기 위한 파일. void main() { diff --git a/lib/function/admin_controller.dart b/lib/function/admin_controller.dart new file mode 100644 index 0000000..d3b8fe3 --- /dev/null +++ b/lib/function/admin_controller.dart @@ -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 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(); + } + } +} diff --git a/lib/function/login_controller.dart b/lib/function/login_controller.dart new file mode 100644 index 0000000..5dab246 --- /dev/null +++ b/lib/function/login_controller.dart @@ -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 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'); + } + } +} diff --git a/lib/function/nfc_pocket_checkin_controller.dart b/lib/function/nfc_pocket_checkin_controller.dart new file mode 100644 index 0000000..56eef73 --- /dev/null +++ b/lib/function/nfc_pocket_checkin_controller.dart @@ -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?>? _violationSub; + StreamSubscription?>? _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 _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 _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 _checkOut() async { + FlutterBackgroundService().invoke('stop_watch'); + _isWatching = false; + _activePocketNumber = null; + _statusMessage = "✅ 폰을 회수했습니다. 감시가 종료되었습니다."; + notifyListeners(); + onMessage("✅ 감시가 종료되었습니다. 수고하셨습니다!", WatchMessageLevel.success); + } +} diff --git a/lib/function/nfc_tag_writer_controller.dart b/lib/function/nfc_tag_writer_controller.dart new file mode 100644 index 0000000..bef4605 --- /dev/null +++ b/lib/function/nfc_tag_writer_controller.dart @@ -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 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 _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, + ); + } +} diff --git a/lib/function/student_dashboard_controller.dart b/lib/function/student_dashboard_controller.dart new file mode 100644 index 0000000..7971bce --- /dev/null +++ b/lib/function/student_dashboard_controller.dart @@ -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(); + } + } +} diff --git a/lib/function/student_management_controller.dart b/lib/function/student_management_controller.dart new file mode 100644 index 0000000..7e71c20 --- /dev/null +++ b/lib/function/student_management_controller.dart @@ -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 _students = []; + bool _isLoading = true; + + List get students => _students; + bool get isLoading => _isLoading; + + Future init() => fetchStudents(); + + /// 🌐 서버에서 전체 학생 목록 불러오기. 실패 시 에러 메시지를 반환한다(성공 시 null). + Future 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, '❌ 학생 등록 실패 (통신 에러)'); + } + } +} diff --git a/lib/function/teacher_attendance_controller.dart b/lib/function/teacher_attendance_controller.dart new file mode 100644 index 0000000..7ad443f --- /dev/null +++ b/lib/function/teacher_attendance_controller.dart @@ -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 _roster = []; // 전체 학생 명단 (/api/users) + List _logs = []; // 출석 로그 (/api/logs) + Map _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 manualRefresh() async { + _isRefreshing = true; + _safeNotify(); + await fetchAll(); + _isRefreshing = false; + _safeNotify(); + } + + Future 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 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 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 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 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 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 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> get _todaysCheckInsByStudentId { + // "YYYY-MM-DD HH:MM:SS" 형태의 문자열끼리는 그대로 비교해도 시간 순서가 맞는다. + final String cutoff = + _dismissedAt ?? + '${DateTime.now().toIso8601String().substring(0, 10)} 00:00:00'; + final Map> 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 get _everCheckedInStudentIds { + final Set 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> 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> 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; + } +} diff --git a/lib/function/teacher_register_controller.dart b/lib/function/teacher_register_controller.dart new file mode 100644 index 0000000..d8be488 --- /dev/null +++ b/lib/function/teacher_register_controller.dart @@ -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(); + } + } +} diff --git a/lib/function/teacher_student_management_controller.dart b/lib/function/teacher_student_management_controller.dart new file mode 100644 index 0000000..02ff12a --- /dev/null +++ b/lib/function/teacher_student_management_controller.dart @@ -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(); + } + } +} diff --git a/lib/main_client_app.dart b/lib/main_client_app.dart index 33ddd47..98240bf 100644 --- a/lib/main_client_app.dart +++ b/lib/main_client_app.dart @@ -7,7 +7,7 @@ import 'package:firebase_core/firebase_core.dart'; import 'firebase_options.dart'; import 'config.dart'; import 'pocket_watch_service.dart'; -import 'screens/login_screen.dart'; +import 'ui/login_screen.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/nfc_poccket_checkin_screen.dart b/lib/nfc_poccket_checkin_screen.dart deleted file mode 100644 index 9e90c0e..0000000 --- a/lib/nfc_poccket_checkin_screen.dart +++ /dev/null @@ -1,397 +0,0 @@ -// 📲 자습실 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; - StreamSubscription?>? _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 _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 _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), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/admin_dashboard.dart b/lib/screens/admin_dashboard.dart deleted file mode 100644 index 7d2d7b2..0000000 --- a/lib/screens/admin_dashboard.dart +++ /dev/null @@ -1,154 +0,0 @@ -// 🛠️ 시스템 관리자 대시보드. 출석 DB 전체 초기화 기능만 담당한다. -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import '../config.dart'; -import 'login_screen.dart'; - -// ========================================== -// 🛠️ 5. 관리자 대시보드 (DB 초기화 암호 파라미터 보정 완료) -// ========================================== -class AdminDashboard extends StatefulWidget { - const AdminDashboard({super.key}); - - @override - State createState() => _AdminDashboardState(); -} - -class _AdminDashboardState extends State { - bool _isLoading = false; - - Future resetDatabase() async { - setState(() => _isLoading = true); - - // 🆕 백엔드 보안 규칙에 맞추어 초기화 토큰 비밀번호 파라미터(?password=...)를 연동 주소에 매칭했습니다. - final url = Uri.parse('$baseUrl/reset-db?password=adminreset2010'); - - try { - final response = await http.get(url); - - if (response.statusCode == 200) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('💥 DB가 깔끔하게 초기화되었습니다! (출석 번호 1번부터 시작)'), - ), - ); - } else { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('❌ 초기화 실패: 서버 권한 오류 (${response.statusCode})'), - ), - ); - } - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('❌ 네트워크 에러: 서버와 연결할 수 없습니다.')), - ); - } finally { - if (mounted) setState(() => _isLoading = false); - } - } - - void _showResetConfirmDialog() { - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return AlertDialog( - title: const Text( - '⚠️ DB 초기화 경고', - style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold), - ), - content: const Text( - '모든 학생의 출석 및 휴대폰 제출 데이터가 영구적으로 삭제됩니다.\n\n정말 초기화하시겠습니까?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('취소', style: TextStyle(color: Colors.grey)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - onPressed: () { - Navigator.pop(dialogContext); - resetDatabase(); - }, - child: const Text( - '초기화 실행', - style: TextStyle(color: Colors.white), - ), - ), - ], - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('🛠️ 관리자 시스템'), - backgroundColor: Colors.orange, - foregroundColor: Colors.white, - actions: [ - IconButton( - icon: const Icon(Icons.logout), - onPressed: () => Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (context) => const LoginScreen()), - ), - ), - ], - ), - body: Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.admin_panel_settings, - size: 100, - color: Colors.orange, - ), - const SizedBox(height: 20), - const Text( - '데이터베이스 관리', - style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 40), - - _isLoading - ? const CircularProgressIndicator(color: Colors.red) - : SizedBox( - width: double.infinity, - height: 60, - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red[50], - foregroundColor: Colors.red, - side: const BorderSide(color: Colors.red, width: 2), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - icon: const Icon(Icons.delete_forever, size: 28), - label: const Text( - '모든 출석 데이터 초기화', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - onPressed: _showResetConfirmDialog, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/change_password_screen.dart b/lib/screens/change_password_screen.dart index 49e68f8..ce9360d 100644 --- a/lib/screens/change_password_screen.dart +++ b/lib/screens/change_password_screen.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import '../config.dart'; -import 'login_screen.dart'; +import '../ui/login_screen.dart'; // 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요. class ChangePasswordScreen extends StatefulWidget { diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart deleted file mode 100644 index a4c9be4..0000000 --- a/lib/screens/login_screen.dart +++ /dev/null @@ -1,551 +0,0 @@ -// 🔑 로그인 화면. 학번/비밀번호 인증, 최초 로그인 비밀번호 변경, 권한(학생/교사/관리자)별 화면 분기를 담당한다. -import 'dart:convert'; -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'package:firebase_messaging/firebase_messaging.dart'; -import '../config.dart'; -import 'student_dashboard.dart'; -import 'teacher_dashboard.dart'; -import 'admin_dashboard.dart'; -import 'teacher_register_screen.dart'; - -// ------------------------------------------------------------- -// 1. 로그인 화면 (LoginScreen) -// ------------------------------------------------------------- - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - final TextEditingController _idController = TextEditingController(); - final TextEditingController _pwController = TextEditingController(); - bool _isLoading = false; - - Future _login() async { - String currentInputId = _idController.text.trim(); - String currentInputPw = _pwController.text.trim(); - - if (currentInputId.isEmpty || currentInputPw.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('⚠️ 학번과 비밀번호를 모두 입력해 주세요.'))); - return; - } - - // 🔥 [개발자 마스터 계정] - if (currentInputId == "2061") { - if (currentInputPw == "happy9642!") { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('👑 개발자 최고 권한으로 로그인되었습니다.')), - ); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => const StudentDashboard( - studentId: "2061", - studentName: "훗춧가룻", - isDeviceMatched: true, // 🆕 [수정] 마스터 계정은 언제나 프리패스이므로 true 변경! - ), - ), - ); - return; - } else { - _showErrorDialog('개발자 계정의 마스터 비밀번호가 올바르지 않습니다.'); - return; - } - } - - setState(() => _isLoading = true); - - try { - String deviceUuid = await getDeviceUuid(); - String fcmToken = ""; - - try { - fcmToken = kIsWeb - ? await FirebaseMessaging.instance.getToken( - vapidKey: webVapidKey, - ) ?? - "" - : await FirebaseMessaging.instance.getToken() ?? ""; - print("발급된 FCM 토큰: $fcmToken"); - } catch (e) { - print("FCM 토큰 가져오기 실패 (파이어베이스 미설정): $e"); - } - - final response = await http.post( - Uri.parse('$baseUrl/login'), - headers: {"Content-Type": "application/json"}, - body: jsonEncode({ - "studentId": currentInputId, - "password": currentInputPw, - "deviceUuid": deviceUuid, - "fcmToken": fcmToken, - }), - ); - - final resData = jsonDecode(utf8.decode(response.bodyBytes)); - - if (response.statusCode == 200) { - print("🚨 서버 응답 데이터: $resData"); - - 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() ?? - currentInputId; - - 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() ?? currentInputId; - - 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) { - _showFirstLoginPasswordDialog( - studentId, - name, - role, - isDeviceMatched, - ); // 🆕 매개변수 추가 - return; - } - - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('✅ $name님 환영합니다!'))); - _navigateBasedOnRole( - role, - studentId, - name, - isDeviceMatched, - ); // 🆕 [수정] 기기 인증 결과 전달 - } else { - String errorMsg = '로그인에 실패했습니다.'; - if (resData is Map) { - errorMsg = - resData['detail']?.toString() ?? - resData['message']?.toString() ?? - errorMsg; - } - _showErrorDialog(errorMsg); - } - } catch (e) { - _showErrorDialog('서버와 연결할 수 없습니다. 서버 상태를 확인하세요!\n($e)'); - } finally { - setState(() => _isLoading = false); - } - } - - // 🛠️ 초기 비밀번호 변경 팝업창 (기기 매칭 데이터 파라미터 추가) - void _showFirstLoginPasswordDialog( - String studentId, - String name, - String role, - bool isDeviceMatched, // 🆕 추가 - ) { - final TextEditingController newPwController = TextEditingController(); - - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext dialogContext) { - bool isUpdating = false; - - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: const Text( - '🔒 초기 비밀번호 변경', - style: TextStyle(fontWeight: FontWeight.bold), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - '보안을 위해 새로운 비밀번호를 설정해 주세요.', - style: TextStyle(color: Colors.redAccent), - ), - const SizedBox(height: 16), - TextField( - controller: newPwController, - obscureText: true, - decoration: const InputDecoration( - labelText: '새 비밀번호', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.lock_reset), - ), - ), - ], - ), - actions: [ - isUpdating - ? const Padding( - padding: EdgeInsets.only(right: 20.0), - child: CircularProgressIndicator(), - ) - : ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - foregroundColor: Colors.white, - ), - onPressed: () async { - String newPassword = newPwController.text.trim(); - if (newPassword.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('⚠️ 새 비밀번호를 입력해 주세요.'), - ), - ); - return; - } - - setDialogState(() => isUpdating = true); - - 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), - ); - setDialogState(() => isUpdating = false); - - if (response.statusCode == 200 && - resData['status'] == 'success') { - Navigator.pop(dialogContext); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('✅ 비밀번호가 변경되었습니다!'), - ), - ); - _navigateBasedOnRole( - role, - studentId, - name, - isDeviceMatched, - ); // 🆕 수정 - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('❌ 실패: ${resData['message']}'), - ), - ); - } - } catch (e) { - setDialogState(() => isUpdating = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('서버 에러 발생: $e')), - ); - } - }, - child: const Text('변경하고 시작하기'), - ), - ], - ); - }, - ); - }, - ); - } - - // 🆕 [수정] 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달 - void _navigateBasedOnRole( - String role, - String studentId, - String name, - bool isDeviceMatched, - ) { - if (role == 'teacher') { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => - TeacherDashboard(teacherId: studentId, teacherName: name), - ), - ); - } else if (role == 'admin') { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (context) => const AdminDashboard()), - ); - } else { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => StudentDashboard( - studentId: studentId, - studentName: name, - isDeviceMatched: - isDeviceMatched, // 🆕 [수정] 더이상 false 고정이 아니라 서버 판단 결과 전송! - ), - ), - ); - } - } - - void _showErrorDialog(String message) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text( - '⚠️ 인증 실패', - style: TextStyle(fontWeight: FontWeight.bold), - ), - content: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('확인'), - ), - ], - ), - ); - } - - void _showLegacyPasswordDialog( - String title, - String correctPassword, - Widget nextPage, - ) { - final TextEditingController passwordController = TextEditingController(); - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext dialogContext) { - return AlertDialog( - title: Text('🔒 $title 권한 인증 (기존 방식)'), - content: TextField( - controller: passwordController, - obscureText: true, - keyboardType: TextInputType.number, - textInputAction: TextInputAction.done, - onSubmitted: (value) { - if (value == correctPassword) { - Navigator.pop(dialogContext); - Navigator.push( - context, - MaterialPageRoute(builder: (context) => nextPage), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')), - ); - } - }, - decoration: const InputDecoration( - hintText: '비밀번호 4자리를 입력하세요', - border: OutlineInputBorder(), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('취소', style: TextStyle(color: Colors.grey)), - ), - ElevatedButton( - onPressed: () { - if (passwordController.text == correctPassword) { - Navigator.pop(dialogContext); - Navigator.push( - context, - MaterialPageRoute(builder: (context) => nextPage), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')), - ); - } - }, - child: const Text('인증하기'), - ), - ], - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.grey[50], - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.school, size: 80, color: Colors.indigo), - const SizedBox(height: 16), - const Text( - '백산고등학교 모니터', - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: Colors.indigo, - ), - ), - const SizedBox(height: 8), - const Text( - '학생 편의 및 학교생활 도우미', - style: TextStyle(color: Colors.grey, fontSize: 15), - ), - const SizedBox(height: 40), - TextField( - controller: _idController, - textInputAction: TextInputAction.next, - decoration: const InputDecoration( - labelText: '학번 또는 교직원 번호', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.person), - ), - ), - const SizedBox(height: 16), - TextField( - controller: _pwController, - obscureText: true, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _login(), - decoration: const InputDecoration( - labelText: '비밀번호', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.lock), - ), - ), - const SizedBox(height: 24), - _isLoading - ? const CircularProgressIndicator() - : SizedBox( - width: double.infinity, - height: 55, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - onPressed: _login, - child: const Text( - '로그인', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - const SizedBox(height: 4), - TextButton( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const TeacherRegisterScreen(), - ), - ), - child: const Text( - '👨‍🏫 선생님이신가요? 교사 회원가입 하기', - style: TextStyle( - color: Colors.indigo, - fontWeight: FontWeight.bold, - ), - ), - ), - const Divider(height: 40), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - TextButton.icon( - icon: const Icon( - Icons.gavel, - size: 18, - color: Colors.grey, - ), - label: const Text( - '교사 간편인증', - style: TextStyle(color: Colors.grey), - ), - onPressed: () => _showLegacyPasswordDialog( - '선생님', - '1234', - const TeacherDashboard(), - ), - ), - TextButton.icon( - icon: const Icon( - Icons.settings, - size: 18, - color: Colors.grey, - ), - label: const Text( - '관리자 간편인증', - style: TextStyle(color: Colors.grey), - ), - onPressed: () => _showLegacyPasswordDialog( - '시스템 관리자', - '4936', - const AdminDashboard(), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/screens/nfc_tag_writer_screen.dart b/lib/screens/nfc_tag_writer_screen.dart deleted file mode 100644 index bc61ecd..0000000 --- a/lib/screens/nfc_tag_writer_screen.dart +++ /dev/null @@ -1,204 +0,0 @@ -// 🏷️ (관리자용) 주머니 NFC 스티커 초기 설정 화면. 빈 태그에 "POCKET_번호" 텍스트를 써넣는다. -import 'dart:convert'; -import 'dart:typed_data'; -import 'package:flutter/material.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 NfcTagWriterScreen extends StatefulWidget { - const NfcTagWriterScreen({super.key}); - - @override - State createState() => _NfcTagWriterScreenState(); -} - -class _NfcTagWriterScreenState extends State { - final TextEditingController _numberController = TextEditingController( - text: "1", - ); - bool _isWriting = false; - String _statusMessage = "주머니 번호를 입력하고 '쓰기 시작'을 눌러주세요."; - - @override - void dispose() { - NfcManager.instance.stopSession(); - _numberController.dispose(); - super.dispose(); - } - - void _startWriteSession() async { - final String number = _numberController.text.trim(); - if (number.isEmpty) { - _showSnackBar("⚠️ 주머니 번호를 입력해주세요.", Colors.orange); - return; - } - final String pocketLabel = "POCKET_$number"; - - bool isAvailable = await NfcManager.instance.isAvailable(); - if (!isAvailable) { - _showSnackBar("❌ NFC가 꺼져있거나 지원되지 않습니다.", Colors.red); - return; - } - - setState(() { - _isWriting = true; - _statusMessage = "📡 [$pocketLabel] 쓸 준비 완료! 스티커에 폰 뒷면을 대주세요."; - }); - - NfcManager.instance.startSession( - pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693}, - onDiscovered: (NfcTag tag) async { - try { - final ndef = Ndef.from(tag); - if (ndef == null) { - _showSnackBar("❌ 이 태그는 NDEF 쓰기를 지원하지 않는 종류입니다.", Colors.red); - return; - } - if (!ndef.isWritable) { - _showSnackBar("❌ 이 태그는 쓰기 잠금(read-only) 상태입니다.", Colors.red); - return; - } - - await ndef.write( - message: NdefMessage(records: [_createTextRecord(pocketLabel)]), - ); - - // 🔒 태그 복제 방지: 하드웨어 UID를 서버에 등록해서 이 물리 태그만 [pocketLabel]로 인정되게 한다. - final String? tagUid = _getTagUid(tag); - if (tagUid != null) { - await _registerTagUid(tagUid, pocketLabel); - } - - if (mounted) { - _showSnackBar( - tagUid != null - ? "✅ [$pocketLabel] 쓰기 + UID 등록 성공!" - : "⚠️ [$pocketLabel] 쓰기는 성공했지만 UID를 못 읽어 등록은 안 됐습니다.", - tagUid != null ? Colors.green : Colors.orange, - ); - // 다음 스티커를 연달아 쓰기 편하도록 번호를 자동으로 1 올려준다. - final int? n = int.tryParse(number); - setState(() { - if (n != null) _numberController.text = (n + 1).toString(); - _statusMessage = "다음 번호를 확인하고 '쓰기 시작'을 다시 눌러주세요."; - }); - } - } catch (e) { - _showSnackBar("❌ 쓰기 실패: $e", Colors.red); - } finally { - await NfcManager.instance.stopSession(); - if (mounted) setState(() => _isWriting = 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(); - } - - /// 서버에 "이 UID는 이 주머니 번호다"를 등록한다. - Future _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) { - _showSnackBar("❌ 서버에 UID 등록 실패: $e", Colors.red); - } - } - - /// 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, - ); - } - - 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 Scaffold( - appBar: AppBar( - title: const Text("🏷️ NFC 주머니 태그 쓰기"), - backgroundColor: Colors.deepPurple, - foregroundColor: Colors.white, - ), - body: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextField( - controller: _numberController, - keyboardType: TextInputType.number, - enabled: !_isWriting, - decoration: const InputDecoration( - labelText: "주머니 번호", - prefixText: "POCKET_", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 32), - Icon( - _isWriting ? Icons.nfc : Icons.edit_note_rounded, - size: 80, - color: _isWriting ? Colors.orange : Colors.deepPurple, - ), - const SizedBox(height: 16), - Text( - _statusMessage, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 15), - ), - const SizedBox(height: 32), - SizedBox( - width: double.infinity, - height: 55, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.deepPurple, - foregroundColor: Colors.white, - ), - onPressed: _isWriting ? null : _startWriteSession, - child: Text( - _isWriting ? "태그를 기다리는 중..." : "쓰기 시작", - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/student_dashboard.dart b/lib/screens/student_dashboard.dart deleted file mode 100644 index 6e28926..0000000 --- a/lib/screens/student_dashboard.dart +++ /dev/null @@ -1,496 +0,0 @@ -// 🎓 학생 대시보드. NFC 주머니 체크인 진입, 실시간 학교 상황 안내, -// 개발자 마스터 계정 전용 관리 메뉴(현황/DB제어/계정관리/삭제)를 담당한다. -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import '../config.dart'; -import '../nfc_poccket_checkin_screen.dart'; -import 'login_screen.dart'; -import 'teacher_attendance_page.dart'; -import 'admin_dashboard.dart'; -import 'student_management_screen.dart'; -import 'nfc_tag_writer_screen.dart'; - -// ------------------------------------------------------------- -// 2. 학생 대시보드 (StudentDashboard) -// ------------------------------------------------------------- - -class StudentDashboard extends StatefulWidget { - final String studentId; - final String studentName; - final bool isDeviceMatched; // 🆕 [변경] 기기 UUID가 매칭되었는지 확인하는 변수 추가 - - const StudentDashboard({ - super.key, - required this.studentId, - required this.studentName, - required this.isDeviceMatched, // 🆕 [변경] 필수 매개변수로 등록 - }); - - @override - State createState() => _StudentDashboardState(); -} - -class _StudentDashboardState extends State { - bool _isLoading = false; - - // 1️⃣ NFC 태그 카드 → 실제 NFC 주머니 체크인 화면으로 이동 - void _openPocketCheckIn(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => NfcPocketCheckInScreen( - studentId: widget.studentId, - studentName: widget.studentName, - ), - ), - ); - } - - // 2️⃣ [기존 동일] 마스터 계정 전용 회원 삭제 API 호출 함수 - Future _deleteUser(BuildContext context, String userId) async { - setState(() => _isLoading = true); - 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); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('✅ ${responseData['message']}'))); - } else { - final errorData = jsonDecode(response.body); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('❌ 삭제 실패: ${errorData['detail'] ?? '알 수 없는 오류'}'), - ), - ); - } - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('❌ 서버와 연결할 수 없습니다. (네트워크 에러)')), - ); - } finally { - setState(() => _isLoading = false); - } - } - - // 3️⃣ [기존 동일] 학번/교직원 번호를 입력받는 모던 팝업창(Dialog) - void _showDeleteUserDialog(BuildContext context) { - final TextEditingController idController = TextEditingController(); - - showDialog( - context: context, - builder: (context) { - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - title: const Row( - children: [ - Icon(Icons.warning_amber_rounded, color: Colors.redAccent), - SizedBox(width: 10), - Text( - '계정 강제 삭제', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), - ), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '학생의 학번 또는 교사의 교직원 번호를 입력하세요.\nDB에서 해당 계정과 토큰이 즉시 삭제됩니다.', - style: TextStyle( - color: Colors.black54, - fontSize: 13, - height: 1.4, - ), - ), - const SizedBox(height: 16), - TextField( - controller: idController, - decoration: InputDecoration( - labelText: '학번 또는 교직원 번호', - hintText: '예: 201101 또는 T1001', - labelStyle: TextStyle(color: Colors.red[400]), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide(color: Colors.red[400]!, width: 2), - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - ), - prefixIcon: const Icon(Icons.person_remove_alt_1_rounded), - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text( - '취소', - style: TextStyle( - color: Colors.grey, - fontWeight: FontWeight.bold, - ), - ), - ), - ElevatedButton( - onPressed: () { - final inputId = idController.text.trim(); - if (inputId.isNotEmpty) { - Navigator.pop(context); - _deleteUser(context, inputId); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.redAccent, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - elevation: 0, - ), - child: const Text( - '삭제 실행', - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - ], - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - final bool isDeveloper = widget.studentId == "2061"; - - return Scaffold( - backgroundColor: Colors.grey[100], - appBar: AppBar( - title: Text( - isDeveloper ? '👑 MASTER CONTROL' : '🎓 STUDENT PORTAL', - style: const TextStyle( - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - ), - ), - centerTitle: true, - backgroundColor: isDeveloper - ? Colors.deepPurple[700] - : Colors.indigo[700], - foregroundColor: Colors.white, - elevation: 0, - actions: [ - IconButton( - icon: const Icon(Icons.logout_rounded), - onPressed: () => Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (context) => const LoginScreen()), - ), - ), - ], - ), - body: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), - decoration: BoxDecoration( - color: isDeveloper - ? Colors.deepPurple[700] - : Colors.indigo[700], - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(32), - bottomRight: Radius.circular(32), - ), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - child: CircleAvatar( - radius: 30, - backgroundColor: isDeveloper - ? Colors.deepPurple[50] - : Colors.indigo[50], - child: Icon( - isDeveloper - ? Icons.admin_panel_settings_rounded - : Icons.school_rounded, - size: 32, - color: isDeveloper ? Colors.deepPurple : Colors.indigo, - ), - ), - ), - const SizedBox(width: 18), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${widget.studentName} 님', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - isDeveloper - ? '최고 관리 권한 활성화됨' - : '학번: ${widget.studentId} | 인증 완료', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.8), - fontSize: 14, - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 32), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: Row( - children: [ - Container( - width: 4, - height: 18, - decoration: BoxDecoration( - color: isDeveloper ? Colors.deepPurple : Colors.indigo, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 10), - const Text( - '스마트 관리 시스템 메뉴', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: GridView.count( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisCount: 2, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - childAspectRatio: 0.95, - children: [ - // 🆕 [변경 지점] 기기가 일치하면 정상 활성화, 일치하지 않으면 자물쇠Lock 처리 - if (isDeveloper || widget.studentId != "2061") - _buildModernCard( - icon: widget.isDeviceMatched - ? Icons.contactless_rounded - : Icons.lock_rounded, - title: 'NFC 태그', - subtitle: widget.isDeviceMatched - ? '출석 및 폰 수거 완료' - : '⚠️ 본인 인증 기기 전용', - color: widget.isDeviceMatched - ? Colors.blue - : Colors.grey[400]!, - onTap: widget.isDeviceMatched - ? () => _openPocketCheckIn(context) - : () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - '🚨 대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.', - ), - ), - ); - }, - isActionButton: widget.isDeviceMatched, - isLoading: _isLoading, - ), - - // 🆕 [추가 지점] 기기 일치 여부 상관없이 태블릿에서도 누구나 확인 가능한 학교 상황판 카드 - _buildModernCard( - icon: Icons.fastfood_rounded, - title: '실시간 학교 상황', - subtitle: '급식실 줄 & 매점 재고 확인', - color: Colors.orange[700]!, - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('🍔 실시간 학교 상황 페이지로 이동합니다.'), - ), - ); - }, - ), - - if (isDeveloper) - _buildModernCard( - icon: Icons.monitor_heart_rounded, - title: '실시간 현황', - subtitle: '교사용 수거 모니터링', - color: Colors.teal, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const TeacherAttendancePage(), - ), - ), - ), - if (isDeveloper) - _buildModernCard( - icon: Icons.terminal_rounded, - title: '서버 DB 제어', - subtitle: '시스템 원격 초기화', - color: Colors.amber[800]!, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const AdminDashboard(), - ), - ), - ), - if (isDeveloper) - _buildModernCard( - icon: Icons.add_moderator_rounded, - title: '학생 계정 관리', - subtitle: 'UUID 리셋 및 승인', - color: Colors.purple, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const StudentManagementScreen(), - ), - ), - ), - if (isDeveloper) - _buildModernCard( - icon: Icons.delete_sweep_rounded, - title: '계정 강제 삭제', - subtitle: '학생 및 교사 DB 삭제', - color: Colors.red[600]!, - onTap: () => _showDeleteUserDialog(context), - ), - if (isDeveloper) - _buildModernCard( - icon: Icons.edit_note_rounded, - title: 'NFC 태그 쓰기', - subtitle: '주머니 스티커 초기 설정', - color: Colors.deepPurple, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const NfcTagWriterScreen(), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 32), - ], - ), - ), - ); - } - - // 5️⃣ [카드 디자인 위젯] - 기존 형태 완전 보존 - Widget _buildModernCard({ - required IconData icon, - required String title, - required String subtitle, - required Color color, - required VoidCallback onTap, - bool isActionButton = false, - bool isLoading = false, - }) { - return InkWell( - onTap: isLoading ? null : onTap, - borderRadius: BorderRadius.circular(24), - child: Ink( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 16, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(16), - ), - child: Icon(icon, color: color, size: 28), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - if (isLoading) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else if (isActionButton) - Icon( - Icons.touch_app_rounded, - size: 16, - color: color.withValues(alpha: 0.5), - ), - ], - ), - const SizedBox(height: 4), - Text( - subtitle, - style: TextStyle( - fontSize: 12, - color: Colors.grey[500], - height: 1.2, - ), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/student_management_screen.dart b/lib/screens/student_management_screen.dart deleted file mode 100644 index a2a6706..0000000 --- a/lib/screens/student_management_screen.dart +++ /dev/null @@ -1,338 +0,0 @@ -// 🔐 (마스터 계정용) 학생 계정 및 기기 관리 화면. 기기 UUID 초기화와 계정 삭제를 담당한다. -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import '../config.dart'; - -// ========================================== -// 🔐 6. 학생 계정 및 기기 관리 화면 (기기 리셋 + 계정 삭제 완본) -// ========================================== -class StudentManagementScreen extends StatefulWidget { - const StudentManagementScreen({super.key}); - - @override - State createState() => - _StudentManagementScreenState(); -} - -class _StudentManagementScreenState extends State { - List realStudents = []; - bool _isLoading = true; - - @override - void initState() { - super.initState(); - _fetchStudents(); - } - - // 🌐 서버에서 전체 학생 목록 불러오기 - Future _fetchStudents() async { - setState(() => _isLoading = true); - try { - final response = await http.get(Uri.parse('$baseUrl/api/users')); - if (response.statusCode == 200) { - final data = jsonDecode(utf8.decode(response.bodyBytes)); - setState(() { - realStudents = data['users'] ?? []; - _isLoading = false; - }); - } - } catch (e) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('데이터 불러오기 실패: $e'))); - setState(() => _isLoading = false); - } - } - - // 🌐 서버로 기기 초기화(리셋) 명령 보내기 - Future _resetDevice(String studentId, String studentName) async { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text( - '⚠️ 기기 잠금 해제', - style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple), - ), - content: Text('$studentName 학생의 스마트폰 기기 등록을 초기화하시겠습니까?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('취소', style: TextStyle(color: Colors.grey)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.purple, - foregroundColor: Colors.white, - ), - onPressed: () async { - Navigator.pop(ctx); - 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) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('✅ $studentName 학생 기기 초기화 완료!')), - ); - _fetchStudents(); - } - } catch (e) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('❌ 초기화 통신 실패'))); - } - }, - child: const Text('초기화 승인'), - ), - ], - ), - ); - } - - // 🌐 [새 기능] 서버로 계정 완전 삭제 명령 보내기 함수 - Future _deleteUser(String studentId, String studentName) async { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text( - '🚨 계정 완전 삭제 경고', - style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red), - ), - content: Text( - '정말로 $studentName ($studentId) 학생의 계정을 시스템에서 탈퇴(삭제)시키겠습니까?\n\n이 작업은 되돌릴 수 없으며, 해당 학생은 다시 회원가입을 진행해야 합니다.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('취소', style: TextStyle(color: Colors.grey)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), - onPressed: () async { - Navigator.pop(ctx); - try { - // 💡 http.delete 함수를 사용하여 서버에 계정 삭제 요청 발송! - final response = await http.delete( - Uri.parse('$baseUrl/api/users/delete'), - headers: {"Content-Type": "application/json"}, - body: jsonEncode({"studentId": studentId}), - ); - - if (response.statusCode == 200) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('💥 $studentName 학생의 계정이 영구 삭제되었습니다.'), - ), - ); - _fetchStudents(); // 성공하면 목록 새로고침! - } - } catch (e) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('❌ 계정 삭제 통신 실패'))); - } - }, - child: const Text('영구 삭제'), - ), - ], - ), - ); - } - - // 💡 _StudentManagementScreenState 클래스 내부에 붙여넣으세요. - void _showCreateUserDialog() { - final idController = TextEditingController(); - final nameController = TextEditingController(); - - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('👤 신규 학생 계정 추가'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: idController, - decoration: const InputDecoration(labelText: '학번 입력 (예: 20101)'), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 8), - TextField( - controller: nameController, - decoration: const InputDecoration(labelText: '학생 이름 입력'), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('취소'), - ), - ElevatedButton( - onPressed: () async { - String studentId = idController.text.trim(); - String name = nameController.text.trim(); - - if (studentId.isEmpty || name.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('⚠️ 학번과 이름을 모두 입력하세요.')), - ); - return; - } - - try { - // ⚠️ 여기도 훗춧가룻님의 프로젝트 전역 baseUrl 변수명으로 맞춰주세요! - 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') { - Navigator.pop(context); // 팝업 닫기 - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('✅ ${res['message']}')), - ); - _fetchStudents(); // 🔄 추가 완료 후 목록 새로고침 (기존 함수명 확인 필요) - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('❌ ${res['message']}')), - ); - } - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('❌ 학생 등록 실패 (통신 에러)')), - ); - } - }, - child: const Text('생성'), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.grey[50], - appBar: AppBar( - title: const Text( - '학생 계정 및 기기 관리', - style: TextStyle(fontWeight: FontWeight.bold), - ), - backgroundColor: Colors.purple, - foregroundColor: Colors.white, - actions: [ - IconButton( - icon: const Icon(Icons.person_add_alt_1_rounded), - onPressed: () => _showCreateUserDialog(), // 팝업 띄우는 함수 제작하여 연결 - ), - IconButton( - icon: const Icon(Icons.refresh), - onPressed: _fetchStudents, - ), - ], - ), - body: _isLoading - ? const Center(child: CircularProgressIndicator(color: Colors.purple)) - : realStudents.isEmpty - ? const Center( - child: Text( - '가입된 학생 계정이 없습니다.', - style: TextStyle(color: Colors.grey, fontSize: 16), - ), - ) - : ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: realStudents.length, - itemBuilder: (context, index) { - final student = realStudents[index]; - final bool needsReset = student['device'] == "초기화 필요"; - - return Card( - elevation: 2, - margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - leading: CircleAvatar( - backgroundColor: needsReset - ? Colors.red[50] - : Colors.purple[50], - child: Icon( - needsReset ? Icons.lock_reset : Icons.person, - color: needsReset ? Colors.red : Colors.purple, - ), - ), - title: Text( - '${student['name']} (${student['id']})', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - subtitle: Text( - needsReset ? '기기 초기화 승인 대기중' : '정상 등록 상태', - style: TextStyle( - color: needsReset ? Colors.red : Colors.grey, - fontWeight: needsReset - ? FontWeight.bold - : FontWeight.normal, - ), - ), - // 🕹️ 오른쪽 끝에 [기기 리셋] 버튼과 [쓰레기통(삭제)] 버튼을 나란히 배치하는 Row 레이아웃 기획 - trailing: Row( - mainAxisSize: MainAxisSize.min, // 콤팩트하게 뭉치기 - children: [ - if (!needsReset) - OutlinedButton( - style: OutlinedButton.styleFrom( - foregroundColor: Colors.purple, - side: BorderSide( - color: Colors.purple.withValues(alpha: 0.5), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onPressed: () => - _resetDevice(student['id'], student['name']), - child: const Text('기기 리셋'), - ) - else - const Icon(Icons.check_circle, color: Colors.green), - - const SizedBox(width: 8), - - // 🔴 [새 버튼] 최고권한 마스터 전용 계정 영구 삭제 쓰레기통 버튼! - IconButton( - icon: const Icon( - Icons.delete_forever_rounded, - color: Colors.redAccent, - ), - tooltip: '계정 영구 삭제', - onPressed: () => - _deleteUser(student['id'], student['name']), - ), - ], - ), - ), - ); - }, - ), - ); - } -} diff --git a/lib/screens/teacher_register_screen.dart b/lib/screens/teacher_register_screen.dart deleted file mode 100644 index d199398..0000000 --- a/lib/screens/teacher_register_screen.dart +++ /dev/null @@ -1,152 +0,0 @@ -// 👨‍🏫 교사 회원가입 화면. 교사 인증 코드 확인 후 신규 교사 계정을 생성한다. -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import '../config.dart'; - -class TeacherRegisterScreen extends StatefulWidget { - const TeacherRegisterScreen({super.key}); - - @override - State createState() => _TeacherRegisterScreenState(); -} - -class _TeacherRegisterScreenState extends State { - final _idController = TextEditingController(); - final _pwController = TextEditingController(); - final _nameController = TextEditingController(); - final _secretController = TextEditingController(); - bool _isLoading = false; - - Future _registerTeacher() async { - String id = _idController.text.trim(); - String pw = _pwController.text.trim(); - String name = _nameController.text.trim(); - String secret = _secretController.text.trim(); - - // 💡 테스트용 고유값 (실제 디바이스 UUID 연동 로직이 있다면 그걸 넣으세요) - String dummyUuid = "TEACHER_PHONE_$id"; - - if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('⚠️ 모든 빈칸을 입력해 주세요.'))); - return; - } - - setState(() => _isLoading = true); - try { - final response = await http.post( - Uri.parse('$baseUrl/api/users/register-teacher'), - headers: {"Content-Type": "application/json"}, - body: jsonEncode({ - "teacherId": id, - "password": pw, - "name": name, - "secretCode": secret, - "deviceUuid": dummyUuid, - }), - ); - - final res = jsonDecode(response.body); - - if (response.statusCode == 200 && res['status'] == 'success') { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('✅ ${res['message']}'))); - Navigator.pop(context); // 가입 성공 시 로그인 화면으로 복귀 - } else { - // 백엔드에서 보낸 에러 메시지(detail 혹은 message) 출력 - String errorMsg = res['detail'] ?? res['message'] ?? '회원가입에 실패했습니다.'; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('❌ $errorMsg'))); - } - } catch (e) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('❌ 서버와 통신에 실패했습니다.'))); - } finally { - setState(() => _isLoading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('교사 회원가입'), - backgroundColor: Colors.indigo, - foregroundColor: Colors.white, - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '👨‍🏫 교직원 전용 인증', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - const Text( - '학교에서 발급한 교사 가입 비밀코드가 필요합니다.', - style: TextStyle(color: Colors.grey), - ), - const SizedBox(height: 24), - TextField( - controller: _secretController, - obscureText: true, - decoration: const InputDecoration( - labelText: '🔑 교사 인증 비밀코드 입력', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 16), - TextField( - controller: _idController, - decoration: const InputDecoration( - labelText: '교직원 번호 (ID로 사용)', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - TextField( - controller: _nameController, - decoration: const InputDecoration( - labelText: '선생님 성함', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - TextField( - controller: _pwController, - obscureText: true, - decoration: const InputDecoration( - labelText: '사용할 비밀번호 입력', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - foregroundColor: Colors.white, - ), - onPressed: _isLoading ? null : _registerTeacher, - child: _isLoading - ? const CircularProgressIndicator(color: Colors.white) - : const Text('교사 계정 생성하기'), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/teacher_student_management_page.dart b/lib/screens/teacher_student_management_page.dart deleted file mode 100644 index cd519cb..0000000 --- a/lib/screens/teacher_student_management_page.dart +++ /dev/null @@ -1,363 +0,0 @@ -// 👥 교사용 학생 계정 관리 화면. 신규 학생 계정 추가와 계정 강제 삭제를 담당한다. -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import '../config.dart'; - -// ----------------------------------------------------------------------------- -// 👥 [서브 화면 2] 학생 계정 관리 란 (추가 양식 폼 + 강제 삭제 다이얼로그 완전 내장) -// ----------------------------------------------------------------------------- -class TeacherStudentManagementPage extends StatefulWidget { - const TeacherStudentManagementPage({super.key}); - - @override - State createState() => - _TeacherStudentManagementPageState(); -} - -class _TeacherStudentManagementPageState - extends State { - final TextEditingController _addIdController = TextEditingController(); - final TextEditingController _addNameController = TextEditingController(); - final TextEditingController _addPwController = TextEditingController(); - bool _isWorking = false; - - // ➕ [학생 추가 API 연동용 함수] - - Future _addStudentAccount() async { - final sId = _addIdController.text.trim(); - final sName = _addNameController.text.trim(); - final sPw = _addPwController.text.trim(); - - if (sId.isEmpty || sName.isEmpty || sPw.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('⚠️ 모든 입력란을 채워주세요.'))); - return; - } - - setState(() => _isWorking = true); - try { - final url = Uri.parse('$baseUrl/api/users/register-student'); - final response = await http.post( - url, - headers: {"Content-Type": "application/json"}, - body: jsonEncode({"studentId": sId, "name": sName, "password": sPw}), - ); - final result = jsonDecode(utf8.decode(response.bodyBytes)); - - if (response.statusCode == 200 || response.statusCode == 201) { - _addIdController.clear(); - _addNameController.clear(); - _addPwController.clear(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('✅ 계정 생성 완료: ${result['message'] ?? '성공'}')), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('❌ 생성 실패: ${result['message'] ?? '오류 발생'}')), - ); - } - } catch (e) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('🚨 네트워크 에러: $e'))); - } finally { - setState(() => _isWorking = false); - } - } - - // ❌ [학생 삭제 API 연동용 함수] - Future _deleteStudentAccount(String studentId) async { - setState(() => _isWorking = true); - 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)); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('✅ ${resData['message']}'))); - } else { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('❌ 삭제 실패하였습니다.'))); - } - } catch (e) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('❌ 서버 에러 발생'))); - } finally { - setState(() => _isWorking = false); - } - } - - // 🚨 계정 삭제 확인 팝업창 모달 - void _showDeleteDialog() { - final TextEditingController deleteIdController = TextEditingController(); - showDialog( - context: context, - builder: (context) { - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - title: Row( - children: [ - Icon(Icons.warning_amber_rounded, color: Colors.orange[700]), - const SizedBox(width: 10), - const Text( - '학생 계정 강제 삭제', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), - ), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - '영구 삭제할 학생의 학번을 정확하게 입력하세요.', - style: TextStyle(color: Colors.black54, fontSize: 13), - ), - const SizedBox(height: 16), - TextField( - controller: deleteIdController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: '학번 입력', - labelStyle: TextStyle(color: Colors.orange[700]), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: Colors.orange[700]!, - width: 2, - ), - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - ), - prefixIcon: const Icon(Icons.no_accounts_rounded), - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('취소', style: TextStyle(color: Colors.grey)), - ), - ElevatedButton( - onPressed: () { - final inputId = deleteIdController.text.trim(); - if (inputId.isNotEmpty) { - Navigator.pop(context); - _deleteStudentAccount(inputId); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.orange[700], - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Text( - '삭제 확정', - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - ], - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.grey[100], - appBar: AppBar( - title: const Text( - '⚙️ 학생 통합 관리 센터', - style: TextStyle(fontWeight: FontWeight.bold), - ), - backgroundColor: Colors.orange, - foregroundColor: Colors.white, - elevation: 0, - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 🏷️ 인디케이터 바 1 (추가 메뉴) - Row( - children: [ - Container( - width: 4, - height: 16, - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - const Text( - '신규 학생 계정 추가', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ], - ), - const SizedBox(height: 16), - - // 📝 학생 추가 컨테이너 폼 - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 16, - ), - ], - ), - child: Column( - children: [ - TextField( - controller: _addIdController, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: '학번', - prefixIcon: Icon(Icons.badge), - ), - ), - const SizedBox(height: 12), - TextField( - controller: _addNameController, - decoration: const InputDecoration( - labelText: '이름', - prefixIcon: Icon(Icons.person), - ), - ), - const SizedBox(height: 12), - TextField( - controller: _addPwController, - obscureText: true, - decoration: const InputDecoration( - labelText: '초기 비밀번호', - prefixIcon: Icon(Icons.lock), - ), - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: _isWorking ? null : _addStudentAccount, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.orange, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: _isWorking - ? const CircularProgressIndicator(color: Colors.white) - : const Text( - '학생 등록 완료', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 36), - - // 🏷️ 인디케이터 바 2 (삭제 권한 제어메뉴) - Row( - children: [ - Container( - width: 4, - height: 16, - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 8), - const Text( - '위험 구역 (Account Reset)', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.redAccent, - ), - ), - ], - ), - const SizedBox(height: 16), - - // 🚨 학생 삭제 트리거 카드 - InkWell( - onTap: _showDeleteDialog, - borderRadius: BorderRadius.circular(24), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.red[50], - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.red.shade200), - ), - child: const Row( - children: [ - Icon( - Icons.delete_sweep_rounded, - color: Colors.red, - size: 32, - ), - SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '학생 계정 강제 원격 삭제', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.red, - ), - ), - SizedBox(height: 2), - Text( - '인증 초기화 및 DB 제거용', - style: TextStyle( - fontSize: 12, - color: Colors.redAccent, - ), - ), - ], - ), - ), - Icon( - Icons.arrow_forward_ios_rounded, - color: Colors.red, - size: 16, - ), - ], - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/ui/admin_dashboard.dart b/lib/ui/admin_dashboard.dart new file mode 100644 index 0000000..738c1d4 --- /dev/null +++ b/lib/ui/admin_dashboard.dart @@ -0,0 +1,142 @@ +// 🛠️ 시스템 관리자 대시보드 (UI 전용). 출석 DB 전체 초기화 버튼을 그린다. +// 서버 통신/상태는 lib/function/admin_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/admin_controller.dart'; +import 'login_screen.dart'; + +// ========================================== +// 🛠️ 5. 관리자 대시보드 (DB 초기화 암호 파라미터 보정 완료) +// ========================================== +class AdminDashboard extends StatefulWidget { + const AdminDashboard({super.key}); + + @override + State createState() => _AdminDashboardState(); +} + +class _AdminDashboardState extends State { + final AdminController _controller = AdminController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _resetDatabase() async { + final message = await _controller.resetDatabase(); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + void _showResetConfirmDialog() { + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text( + '⚠️ DB 초기화 경고', + style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + ), + content: const Text( + '모든 학생의 출석 및 휴대폰 제출 데이터가 영구적으로 삭제됩니다.\n\n정말 초기화하시겠습니까?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('취소', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + onPressed: () { + Navigator.pop(dialogContext); + _resetDatabase(); + }, + child: const Text( + '초기화 실행', + style: TextStyle(color: Colors.white), + ), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + return Scaffold( + appBar: AppBar( + title: const Text('🛠️ 관리자 시스템'), + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () => Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const LoginScreen()), + ), + ), + ], + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.admin_panel_settings, + size: 100, + color: Colors.orange, + ), + const SizedBox(height: 20), + const Text( + '데이터베이스 관리', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 40), + + _controller.isLoading + ? const CircularProgressIndicator(color: Colors.red) + : SizedBox( + width: double.infinity, + height: 60, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[50], + foregroundColor: Colors.red, + side: const BorderSide( + color: Colors.red, + width: 2, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: const Icon(Icons.delete_forever, size: 28), + label: const Text( + '모든 출석 데이터 초기화', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + onPressed: _showResetConfirmDialog, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/login_screen.dart b/lib/ui/login_screen.dart new file mode 100644 index 0000000..ba9901d --- /dev/null +++ b/lib/ui/login_screen.dart @@ -0,0 +1,435 @@ +// 🔑 로그인 화면 (UI 전용). 입력폼/다이얼로그/화면 이동만 담당한다. +// 인증 로직/서버 통신은 lib/function/login_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/login_controller.dart'; +import 'student_dashboard.dart'; +import 'teacher_dashboard.dart'; +import 'admin_dashboard.dart'; +import 'teacher_register_screen.dart'; + +// ------------------------------------------------------------- +// 1. 로그인 화면 (LoginScreen) +// ------------------------------------------------------------- + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final LoginController _controller = LoginController(); + final TextEditingController _idController = TextEditingController(); + final TextEditingController _pwController = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + _idController.dispose(); + _pwController.dispose(); + super.dispose(); + } + + Future _login() async { + final result = await _controller.login( + _idController.text.trim(), + _pwController.text.trim(), + ); + if (!mounted) return; + + switch (result.outcome) { + case LoginOutcome.error: + _showErrorDialog(result.errorMessage!); + break; + case LoginOutcome.masterSuccess: + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('👑 개발자 최고 권한으로 로그인되었습니다.')), + ); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => StudentDashboard( + studentId: result.studentId!, + studentName: result.name!, + isDeviceMatched: true, + ), + ), + ); + break; + case LoginOutcome.needsPasswordChange: + _showFirstLoginPasswordDialog( + result.studentId!, + result.name!, + result.role!, + result.isDeviceMatched, + ); + break; + case LoginOutcome.success: + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('✅ ${result.name}님 환영합니다!'))); + _navigateBasedOnRole( + result.role!, + result.studentId!, + result.name!, + result.isDeviceMatched, + ); + break; + } + } + + // 🛠️ 초기 비밀번호 변경 팝업창 (기기 매칭 데이터 파라미터 추가) + void _showFirstLoginPasswordDialog( + String studentId, + String name, + String role, + bool isDeviceMatched, + ) { + final TextEditingController newPwController = TextEditingController(); + + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + bool isUpdating = false; + + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: const Text( + '🔒 초기 비밀번호 변경', + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '보안을 위해 새로운 비밀번호를 설정해 주세요.', + style: TextStyle(color: Colors.redAccent), + ), + const SizedBox(height: 16), + TextField( + controller: newPwController, + obscureText: true, + decoration: const InputDecoration( + labelText: '새 비밀번호', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock_reset), + ), + ), + ], + ), + actions: [ + isUpdating + ? const Padding( + padding: EdgeInsets.only(right: 20.0), + child: CircularProgressIndicator(), + ) + : ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + ), + onPressed: () async { + String newPassword = newPwController.text.trim(); + if (newPassword.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('⚠️ 새 비밀번호를 입력해 주세요.'), + ), + ); + return; + } + + setDialogState(() => isUpdating = true); + + final (success, message) = await _controller + .changePassword( + studentId: studentId, + newPassword: newPassword, + ); + setDialogState(() => isUpdating = false); + + if (success) { + Navigator.pop(dialogContext); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + _navigateBasedOnRole( + role, + studentId, + name, + isDeviceMatched, + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + }, + child: const Text('변경하고 시작하기'), + ), + ], + ); + }, + ); + }, + ); + } + + // 🆕 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달 + void _navigateBasedOnRole( + String role, + String studentId, + String name, + bool isDeviceMatched, + ) { + if (role == 'teacher') { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => + TeacherDashboard(teacherId: studentId, teacherName: name), + ), + ); + } else if (role == 'admin') { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const AdminDashboard()), + ); + } else { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => StudentDashboard( + studentId: studentId, + studentName: name, + isDeviceMatched: isDeviceMatched, + ), + ), + ); + } + } + + void _showErrorDialog(String message) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text( + '⚠️ 인증 실패', + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('확인'), + ), + ], + ), + ); + } + + void _showLegacyPasswordDialog( + String title, + String correctPassword, + Widget nextPage, + ) { + final TextEditingController passwordController = TextEditingController(); + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: Text('🔒 $title 권한 인증 (기존 방식)'), + content: TextField( + controller: passwordController, + obscureText: true, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + onSubmitted: (value) { + if (value == correctPassword) { + Navigator.pop(dialogContext); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => nextPage), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')), + ); + } + }, + decoration: const InputDecoration( + hintText: '비밀번호 4자리를 입력하세요', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('취소', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + onPressed: () { + if (passwordController.text == correctPassword) { + Navigator.pop(dialogContext); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => nextPage), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')), + ); + } + }, + child: const Text('인증하기'), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: Colors.grey[50], + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.school, size: 80, color: Colors.indigo), + const SizedBox(height: 16), + const Text( + '백산고등학교 모니터', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: Colors.indigo, + ), + ), + const SizedBox(height: 8), + const Text( + '학생 편의 및 학교생활 도우미', + style: TextStyle(color: Colors.grey, fontSize: 15), + ), + const SizedBox(height: 40), + TextField( + controller: _idController, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: '학번 또는 교직원 번호', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _pwController, + obscureText: true, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _login(), + decoration: const InputDecoration( + labelText: '비밀번호', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock), + ), + ), + const SizedBox(height: 24), + _controller.isLoading + ? const CircularProgressIndicator() + : SizedBox( + width: double.infinity, + height: 55, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: _login, + child: const Text( + '로그인', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 4), + TextButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TeacherRegisterScreen(), + ), + ), + child: const Text( + '👨‍🏫 선생님이신가요? 교사 회원가입 하기', + style: TextStyle( + color: Colors.indigo, + fontWeight: FontWeight.bold, + ), + ), + ), + const Divider(height: 40), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + icon: const Icon( + Icons.gavel, + size: 18, + color: Colors.grey, + ), + label: const Text( + '교사 간편인증', + style: TextStyle(color: Colors.grey), + ), + onPressed: () => _showLegacyPasswordDialog( + '선생님', + '1234', + const TeacherDashboard(), + ), + ), + TextButton.icon( + icon: const Icon( + Icons.settings, + size: 18, + color: Colors.grey, + ), + label: const Text( + '관리자 간편인증', + style: TextStyle(color: Colors.grey), + ), + onPressed: () => _showLegacyPasswordDialog( + '시스템 관리자', + '4936', + const AdminDashboard(), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/nfc_poccket_checkin_screen.dart b/lib/ui/nfc_poccket_checkin_screen.dart new file mode 100644 index 0000000..20c042f --- /dev/null +++ b/lib/ui/nfc_poccket_checkin_screen.dart @@ -0,0 +1,236 @@ +// 📲 자습실 NFC 출석체크 화면 (UI 전용). 태깅 상태 화면/팝업/스낵바만 그린다. +// NFC 세션, 서버 통신, 백그라운드 감시 연동은 +// lib/function/nfc_pocket_checkin_controller.dart가 담당한다. +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, + ); + _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); + } + + /// 🎊 출석 성공 알림창 + 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), + const Text( + "📴 화면을 꺼도 감시는 계속됩니다.\n알림바에서 상태를 확인할 수 있어요.\n(폰을 꺼내 다시 태깅하면 반출 처리됩니다)", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white38, fontSize: 13), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/nfc_tag_writer_screen.dart b/lib/ui/nfc_tag_writer_screen.dart new file mode 100644 index 0000000..6b8b5ee --- /dev/null +++ b/lib/ui/nfc_tag_writer_screen.dart @@ -0,0 +1,114 @@ +// 🏷️ (관리자용) 주머니 NFC 스티커 초기 설정 화면 (UI 전용). 빈 태그에 "POCKET_번호"를 쓰는 +// 버튼/입력폼을 그린다. NFC 세션/서버 통신은 lib/function/nfc_tag_writer_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/nfc_tag_writer_controller.dart'; + +class NfcTagWriterScreen extends StatefulWidget { + const NfcTagWriterScreen({super.key}); + + @override + State createState() => _NfcTagWriterScreenState(); +} + +class _NfcTagWriterScreenState extends State { + final TextEditingController _numberController = TextEditingController( + text: "1", + ); + late final NfcTagWriterController _controller; + + @override + void initState() { + super.initState(); + _controller = NfcTagWriterController( + onMessage: _showSnackBar, + onNextNumberSuggested: (next) => _numberController.text = next, + ); + } + + @override + void dispose() { + _controller.dispose(); + _numberController.dispose(); + super.dispose(); + } + + void _showSnackBar(String text, bool isError) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(text), + backgroundColor: isError ? Colors.red : Colors.green, + ), + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final bool isWriting = _controller.isWriting; + return Scaffold( + appBar: AppBar( + title: const Text("🏷️ NFC 주머니 태그 쓰기"), + backgroundColor: Colors.deepPurple, + foregroundColor: Colors.white, + ), + body: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _numberController, + keyboardType: TextInputType.number, + enabled: !isWriting, + decoration: const InputDecoration( + labelText: "주머니 번호", + prefixText: "POCKET_", + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 32), + Icon( + isWriting ? Icons.nfc : Icons.edit_note_rounded, + size: 80, + color: isWriting ? Colors.orange : Colors.deepPurple, + ), + const SizedBox(height: 16), + Text( + _controller.statusMessage, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 15), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + height: 55, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurple, + foregroundColor: Colors.white, + ), + onPressed: isWriting + ? null + : () => _controller.startWriteSession( + _numberController.text.trim(), + ), + child: Text( + isWriting ? "태그를 기다리는 중..." : "쓰기 시작", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/student_dashboard.dart b/lib/ui/student_dashboard.dart new file mode 100644 index 0000000..6f91fa6 --- /dev/null +++ b/lib/ui/student_dashboard.dart @@ -0,0 +1,494 @@ +// 🎓 학생 대시보드 (UI 전용). NFC 주머니 체크인 진입, 실시간 학교 상황 안내, +// 개발자 마스터 계정 전용 관리 메뉴(현황/DB제어/계정관리/삭제) 카드를 그린다. +// 계정 삭제 서버 통신은 lib/function/student_dashboard_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/student_dashboard_controller.dart'; +import 'nfc_poccket_checkin_screen.dart'; +import 'login_screen.dart'; +import 'teacher_attendance_page.dart'; +import 'admin_dashboard.dart'; +import 'student_management_screen.dart'; +import 'nfc_tag_writer_screen.dart'; + +// ------------------------------------------------------------- +// 2. 학생 대시보드 (StudentDashboard) +// ------------------------------------------------------------- + +class StudentDashboard extends StatefulWidget { + final String studentId; + final String studentName; + final bool isDeviceMatched; // 🆕 [변경] 기기 UUID가 매칭되었는지 확인하는 변수 추가 + + const StudentDashboard({ + super.key, + required this.studentId, + required this.studentName, + required this.isDeviceMatched, // 🆕 [변경] 필수 매개변수로 등록 + }); + + @override + State createState() => _StudentDashboardState(); +} + +class _StudentDashboardState extends State { + final StudentDashboardController _controller = StudentDashboardController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + // 1️⃣ NFC 태그 카드 → 실제 NFC 주머니 체크인 화면으로 이동 + void _openPocketCheckIn(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NfcPocketCheckInScreen( + studentId: widget.studentId, + studentName: widget.studentName, + ), + ), + ); + } + + // 2️⃣ [기존 동일] 마스터 계정 전용 회원 삭제 버튼 동작 + Future _deleteUser(String userId) async { + final (_, message) = await _controller.deleteUser(userId); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // 3️⃣ [기존 동일] 학번/교직원 번호를 입력받는 모던 팝업창(Dialog) + void _showDeleteUserDialog(BuildContext context) { + final TextEditingController idController = TextEditingController(); + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + title: const Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.redAccent), + SizedBox(width: 10), + Text( + '계정 강제 삭제', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '학생의 학번 또는 교사의 교직원 번호를 입력하세요.\nDB에서 해당 계정과 토큰이 즉시 삭제됩니다.', + style: TextStyle( + color: Colors.black54, + fontSize: 13, + height: 1.4, + ), + ), + const SizedBox(height: 16), + TextField( + controller: idController, + decoration: InputDecoration( + labelText: '학번 또는 교직원 번호', + hintText: '예: 201101 또는 T1001', + labelStyle: TextStyle(color: Colors.red[400]), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: Colors.red[400]!, width: 2), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + prefixIcon: const Icon(Icons.person_remove_alt_1_rounded), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text( + '취소', + style: TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + ), + ), + ), + ElevatedButton( + onPressed: () { + final inputId = idController.text.trim(); + if (inputId.isNotEmpty) { + Navigator.pop(context); + _deleteUser(inputId); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: const Text( + '삭제 실행', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final bool isDeveloper = widget.studentId == "2061"; + + return Scaffold( + backgroundColor: Colors.grey[100], + appBar: AppBar( + title: Text( + isDeveloper ? '👑 MASTER CONTROL' : '🎓 STUDENT PORTAL', + style: const TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + centerTitle: true, + backgroundColor: isDeveloper + ? Colors.deepPurple[700] + : Colors.indigo[700], + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.logout_rounded), + onPressed: () => Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const LoginScreen()), + ), + ), + ], + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 28, + ), + decoration: BoxDecoration( + color: isDeveloper + ? Colors.deepPurple[700] + : Colors.indigo[700], + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(32), + bottomRight: Radius.circular(32), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: CircleAvatar( + radius: 30, + backgroundColor: isDeveloper + ? Colors.deepPurple[50] + : Colors.indigo[50], + child: Icon( + isDeveloper + ? Icons.admin_panel_settings_rounded + : Icons.school_rounded, + size: 32, + color: isDeveloper + ? Colors.deepPurple + : Colors.indigo, + ), + ), + ), + const SizedBox(width: 18), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${widget.studentName} 님', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + isDeveloper + ? '최고 관리 권한 활성화됨' + : '학번: ${widget.studentId} | 인증 완료', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.8), + fontSize: 14, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 32), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Row( + children: [ + Container( + width: 4, + height: 18, + decoration: BoxDecoration( + color: isDeveloper + ? Colors.deepPurple + : Colors.indigo, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 10), + const Text( + '스마트 관리 시스템 메뉴', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.95, + children: [ + // 🆕 [변경 지점] 기기가 일치하면 정상 활성화, 일치하지 않으면 자물쇠Lock 처리 + if (isDeveloper || widget.studentId != "2061") + _buildModernCard( + icon: widget.isDeviceMatched + ? Icons.contactless_rounded + : Icons.lock_rounded, + title: 'NFC 태그', + subtitle: widget.isDeviceMatched + ? '출석 및 폰 수거 완료' + : '⚠️ 본인 인증 기기 전용', + color: widget.isDeviceMatched + ? Colors.blue + : Colors.grey[400]!, + onTap: widget.isDeviceMatched + ? () => _openPocketCheckIn(context) + : () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + '🚨 대리 출석 방지를 위해 등록된 본인 스마트폰에서만 출석 가능합니다.', + ), + ), + ); + }, + isActionButton: widget.isDeviceMatched, + isLoading: _controller.isLoading, + ), + + // 🆕 [추가 지점] 기기 일치 여부 상관없이 태블릿에서도 누구나 확인 가능한 학교 상황판 카드 + _buildModernCard( + icon: Icons.fastfood_rounded, + title: '실시간 학교 상황', + subtitle: '급식실 줄 & 매점 재고 확인', + color: Colors.orange[700]!, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('🍔 실시간 학교 상황 페이지로 이동합니다.'), + ), + ); + }, + ), + + if (isDeveloper) + _buildModernCard( + icon: Icons.monitor_heart_rounded, + title: '실시간 현황', + subtitle: '교사용 수거 모니터링', + color: Colors.teal, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const TeacherAttendancePage(), + ), + ), + ), + if (isDeveloper) + _buildModernCard( + icon: Icons.terminal_rounded, + title: '서버 DB 제어', + subtitle: '시스템 원격 초기화', + color: Colors.amber[800]!, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const AdminDashboard(), + ), + ), + ), + if (isDeveloper) + _buildModernCard( + icon: Icons.add_moderator_rounded, + title: '학생 계정 관리', + subtitle: 'UUID 리셋 및 승인', + color: Colors.purple, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const StudentManagementScreen(), + ), + ), + ), + if (isDeveloper) + _buildModernCard( + icon: Icons.delete_sweep_rounded, + title: '계정 강제 삭제', + subtitle: '학생 및 교사 DB 삭제', + color: Colors.red[600]!, + onTap: () => _showDeleteUserDialog(context), + ), + if (isDeveloper) + _buildModernCard( + icon: Icons.edit_note_rounded, + title: 'NFC 태그 쓰기', + subtitle: '주머니 스티커 초기 설정', + color: Colors.deepPurple, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const NfcTagWriterScreen(), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ); + }, + ); + } + + // 5️⃣ [카드 디자인 위젯] - 기존 형태 완전 보존 + Widget _buildModernCard({ + required IconData icon, + required String title, + required String subtitle, + required Color color, + required VoidCallback onTap, + bool isActionButton = false, + bool isLoading = false, + }) { + return InkWell( + onTap: isLoading ? null : onTap, + borderRadius: BorderRadius.circular(24), + child: Ink( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Icon(icon, color: color, size: 28), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + if (isLoading) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else if (isActionButton) + Icon( + Icons.touch_app_rounded, + size: 16, + color: color.withValues(alpha: 0.5), + ), + ], + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + height: 1.2, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/student_management_screen.dart b/lib/ui/student_management_screen.dart new file mode 100644 index 0000000..77232d2 --- /dev/null +++ b/lib/ui/student_management_screen.dart @@ -0,0 +1,307 @@ +// 🔐 (마스터 계정용) 학생 계정 및 기기 관리 화면 (UI 전용). 기기 UUID 초기화/계정 삭제/생성 다이얼로그를 그린다. +// 서버 통신/상태는 lib/function/student_management_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/student_management_controller.dart'; + +// ========================================== +// 🔐 6. 학생 계정 및 기기 관리 화면 (기기 리셋 + 계정 삭제 완본) +// ========================================== +class StudentManagementScreen extends StatefulWidget { + const StudentManagementScreen({super.key}); + + @override + State createState() => + _StudentManagementScreenState(); +} + +class _StudentManagementScreenState extends State { + final StudentManagementController _controller = + StudentManagementController(); + + @override + void initState() { + super.initState(); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _fetchStudents() async { + final error = await _controller.fetchStudents(); + if (error != null && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + } + } + + // 🌐 서버로 기기 초기화(리셋) 명령 보내기 + void _resetDevice(String studentId, String studentName) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text( + '⚠️ 기기 잠금 해제', + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple), + ), + content: Text('$studentName 학생의 스마트폰 기기 등록을 초기화하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('취소', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.purple, + foregroundColor: Colors.white, + ), + onPressed: () async { + Navigator.pop(ctx); + final (_, message) = await _controller.resetDevice( + studentId, + studentName, + ); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + }, + child: const Text('초기화 승인'), + ), + ], + ), + ); + } + + // 🌐 [새 기능] 서버로 계정 완전 삭제 명령 보내기 + void _deleteUser(String studentId, String studentName) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text( + '🚨 계정 완전 삭제 경고', + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red), + ), + content: Text( + '정말로 $studentName ($studentId) 학생의 계정을 시스템에서 탈퇴(삭제)시키겠습니까?\n\n이 작업은 되돌릴 수 없으며, 해당 학생은 다시 회원가입을 진행해야 합니다.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('취소', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + onPressed: () async { + Navigator.pop(ctx); + final (_, message) = await _controller.deleteUser( + studentId, + studentName, + ); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + }, + child: const Text('영구 삭제'), + ), + ], + ), + ); + } + + void _showCreateUserDialog() { + final idController = TextEditingController(); + final nameController = TextEditingController(); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('👤 신규 학생 계정 추가'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: idController, + decoration: const InputDecoration(labelText: '학번 입력 (예: 20101)'), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 8), + TextField( + controller: nameController, + decoration: const InputDecoration(labelText: '학생 이름 입력'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('취소'), + ), + ElevatedButton( + onPressed: () async { + String studentId = idController.text.trim(); + String name = nameController.text.trim(); + + if (studentId.isEmpty || name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('⚠️ 학번과 이름을 모두 입력하세요.')), + ); + return; + } + + final (success, message) = await _controller.createUser( + studentId, + name, + ); + if (!mounted) return; + if (success) { + Navigator.pop(context); // 팝업 닫기 + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + }, + child: const Text('생성'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final realStudents = _controller.students; + return Scaffold( + backgroundColor: Colors.grey[50], + appBar: AppBar( + title: const Text( + '학생 계정 및 기기 관리', + style: TextStyle(fontWeight: FontWeight.bold), + ), + backgroundColor: Colors.purple, + foregroundColor: Colors.white, + actions: [ + IconButton( + icon: const Icon(Icons.person_add_alt_1_rounded), + onPressed: () => _showCreateUserDialog(), + ), + IconButton(icon: const Icon(Icons.refresh), onPressed: _fetchStudents), + ], + ), + body: _controller.isLoading + ? const Center( + child: CircularProgressIndicator(color: Colors.purple), + ) + : realStudents.isEmpty + ? const Center( + child: Text( + '가입된 학생 계정이 없습니다.', + style: TextStyle(color: Colors.grey, fontSize: 16), + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: realStudents.length, + itemBuilder: (context, index) { + final student = realStudents[index]; + final bool needsReset = student['device'] == "초기화 필요"; + + return Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + leading: CircleAvatar( + backgroundColor: needsReset + ? Colors.red[50] + : Colors.purple[50], + child: Icon( + needsReset ? Icons.lock_reset : Icons.person, + color: needsReset ? Colors.red : Colors.purple, + ), + ), + title: Text( + '${student['name']} (${student['id']})', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + subtitle: Text( + needsReset ? '기기 초기화 승인 대기중' : '정상 등록 상태', + style: TextStyle( + color: needsReset ? Colors.red : Colors.grey, + fontWeight: needsReset + ? FontWeight.bold + : FontWeight.normal, + ), + ), + // 🕹️ 오른쪽 끝에 [기기 리셋] 버튼과 [쓰레기통(삭제)] 버튼을 나란히 배치하는 Row 레이아웃 기획 + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!needsReset) + OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.purple, + side: BorderSide( + color: Colors.purple.withValues( + alpha: 0.5, + ), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () => _resetDevice( + student['id'], + student['name'], + ), + child: const Text('기기 리셋'), + ) + else + const Icon( + Icons.check_circle, + color: Colors.green, + ), + + const SizedBox(width: 8), + + // 🔴 [새 버튼] 최고권한 마스터 전용 계정 영구 삭제 쓰레기통 버튼! + IconButton( + icon: const Icon( + Icons.delete_forever_rounded, + color: Colors.redAccent, + ), + tooltip: '계정 영구 삭제', + onPressed: () => + _deleteUser(student['id'], student['name']), + ), + ], + ), + ), + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/screens/teacher_attendance_page.dart b/lib/ui/teacher_attendance_page.dart similarity index 60% rename from lib/screens/teacher_attendance_page.dart rename to lib/ui/teacher_attendance_page.dart index 359d8e1..06ae154 100644 --- a/lib/screens/teacher_attendance_page.dart +++ b/lib/ui/teacher_attendance_page.dart @@ -1,10 +1,7 @@ -// 📋 실시간 출석 현황 화면. 전체 학생 명단(/api/users)과 출석 로그(/api/logs)를 -// 합쳐서 출석/미출석 요약 카드 및 필터링된 목록을 3초마다 갱신해 보여준다. -import 'dart:async'; -import 'dart:convert'; +// 📋 실시간 출석 현황 화면 (UI 전용). 위젯 빌드/레이아웃/스타일만 담당하고, +// 서버 통신·상태·파생 로직은 lib/function/teacher_attendance_controller.dart가 담당한다. import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import '../config.dart'; +import '../function/teacher_attendance_controller.dart'; // ----------------------------------------------------------------------------- // 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화) @@ -16,199 +13,36 @@ class TeacherAttendancePage extends StatefulWidget { State createState() => _TeacherAttendancePageState(); } -// 로그의 'name' 컬럼은 백엔드에서 "학생이름 (주머니정보)" 형태로 합쳐져 저장되어 있어서 -// 이름과 주머니 번호를 분리해서 보여주려면 클라이언트에서 파싱해야 한다. -final RegExp _logNamePattern = RegExp(r'^(.*?)\s*\(([^)]*)\)$'); - class _TeacherAttendancePageState extends State { - List _roster = []; // 전체 학생 명단 (/api/users) - List _logs = []; // 출석 로그 (/api/logs) - Map _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" + final TeacherAttendanceController _controller = TeacherAttendanceController(); @override void initState() { super.initState(); - _fetchAll(); - _timer = Timer.periodic(const Duration(seconds: 3), (timer) => _fetchAll()); + _controller.init(); } @override void dispose() { - _timer?.cancel(); + _controller.dispose(); super.dispose(); } - Future _manualRefresh() async { - setState(() => _isRefreshing = true); - await _fetchAll(); - if (mounted) setState(() => _isRefreshing = false); - } - - Future _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 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']; - } - } - - setState(() { - _roster = usersData['users'] ?? []; - _logs = logsData['logs'] ?? []; - _activeViolationsByStudentId = activeViolations; - _dismissedAt = dismissedAt; - _attendanceTime = attendanceTime; - _globalPermissionUntil = globalPermissionUntil; - _isLoading = false; - }); - } else { - setState(() => _isLoading = false); - } - } catch (e) { - setState(() => _isLoading = false); - } - } - - /// 🏫 하교 처리: 전체 반출 허용 + 오늘 출석 표시 기준선을 지금 시각으로 옮긴다. - Future _dismissAll() async { - try { - final response = await http.post(Uri.parse('$baseUrl/api/dismiss')); - final result = jsonDecode(utf8.decode(response.bodyBytes)); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(result['message'] ?? '하교 처리되었습니다.')), - ); - _fetchAll(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('❌ 하교 처리 실패: $e')), - ); - } - } - - /// 🚨 선생님이 특정 학생에게 지금부터 N분간 반출을 허용한다. - Future _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)); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(result['message'] ?? '처리되었습니다.')), - ); - _fetchAll(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('❌ 반출 허용 실패: $e')), - ); - } - } - - /// ⏰ 지금부터 N분간 전체 학생의 반출을 자동으로 허용한다 (쉬는시간 등). - Future _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)); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(result['message'] ?? '처리되었습니다.')), - ); - _fetchAll(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('❌ 설정 실패: $e')), - ); - } - } - - /// ⏰ 자습실 출석시간(기준 시각)을 지정한다. 이 시각 이후 태깅한 학생만 "출석 완료"로 강조 표시된다. - Future _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)); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(result['message'] ?? '처리되었습니다.')), - ); - _fetchAll(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('❌ 출석시간 설정 실패: $e')), - ); - } + /// 컨트롤러 액션을 실행하고, 결과 메시지를 스낵바로 보여준다. + Future _runAction(Future Function() action) async { + final message = await action(); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } Future _showAttendanceTimeDialog() async { - final TimeOfDay initial = _attendanceTime != null + final String? current = _controller.attendanceTime; + final TimeOfDay initial = current != null ? TimeOfDay( - hour: int.parse(_attendanceTime!.split(':')[0]), - minute: int.parse(_attendanceTime!.split(':')[1]), + hour: int.parse(current.split(':')[0]), + minute: int.parse(current.split(':')[1]), ) : const TimeOfDay(hour: 19, minute: 0); @@ -221,25 +55,7 @@ class _TeacherAttendancePageState extends State { final String formatted = '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}'; - _setAttendanceTime(formatted); - } - - /// 🧪 테스트용: 하교(12시간)/반출 허용 시간 설정 등으로 켜져 있는 허용 시간대를 즉시 해제한다. - Future _resetTestPermissions() async { - try { - final response = await http.post(Uri.parse('$baseUrl/api/debug/reset-permissions')); - final result = jsonDecode(utf8.decode(response.bodyBytes)); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(result['message'] ?? '처리되었습니다.')), - ); - _fetchAll(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('❌ 초기화 실패: $e')), - ); - } + await _runAction(() => _controller.setAttendanceTime(formatted)); } void _showTestResetConfirmDialog() { @@ -259,7 +75,7 @@ class _TeacherAttendancePageState extends State { ElevatedButton( onPressed: () { Navigator.pop(context); - _resetTestPermissions(); + _runAction(() => _controller.resetTestPermissions()); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.grey[700], @@ -295,7 +111,7 @@ class _TeacherAttendancePageState extends State { onPressed: () { final minutes = int.tryParse(controller.text.trim()) ?? 5; Navigator.pop(context); - _allowRemoval(studentId, minutes); + _runAction(() => _controller.allowRemoval(studentId, minutes)); }, child: const Text('허용하기'), ), @@ -338,7 +154,7 @@ class _TeacherAttendancePageState extends State { onPressed: () { final minutes = int.tryParse(controller.text.trim()) ?? 10; Navigator.pop(context); - _setPermissionWindow(minutes); + _runAction(() => _controller.setPermissionWindow(minutes)); }, child: const Text('설정하기'), ), @@ -364,7 +180,7 @@ class _TeacherAttendancePageState extends State { ElevatedButton( onPressed: () { Navigator.pop(context); - _dismissAll(); + _runAction(() => _controller.dismissAll()); }, child: const Text('하교 처리'), ), @@ -373,170 +189,99 @@ class _TeacherAttendancePageState extends State { ); } - /// "오늘 출석"의 기준선. 하교 처리를 했다면 그 시각 이후, 안 했다면 오늘 자정부터. - Map> get _todaysCheckInsByStudentId { - // "YYYY-MM-DD HH:MM:SS" 형태의 문자열끼리는 그대로 비교해도 시간 순서가 맞는다. - final String cutoff = - _dismissedAt ?? - '${DateTime.now().toIso8601String().substring(0, 10)} 00:00:00'; - final Map> 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 get _everCheckedInStudentIds { - final Set 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> 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> 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; - } - @override Widget build(BuildContext context) { - final all = _combinedStudentStatus; - final int totalCount = all.length; - final int checkedInCount = - all.where((s) => s['isAttendanceComplete'] == true).length; - final int absentCount = totalCount - checkedInCount; + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final all = _controller.combinedStudentStatus; + final int totalCount = all.length; + final int checkedInCount = all + .where((s) => s['isAttendanceComplete'] == true) + .length; + final int absentCount = totalCount - checkedInCount; - return Scaffold( - backgroundColor: Colors.grey[100], - appBar: AppBar( - title: const Text( - '📋 실시간 출석 현황', - style: TextStyle(fontWeight: FontWeight.bold), - ), - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - elevation: 0, - actions: [ - IconButton( - onPressed: (_isLoading || _isRefreshing) ? null : _manualRefresh, - icon: _isRefreshing - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.refresh_rounded), - tooltip: '새로고침', - ), - TextButton.icon( - onPressed: _showAttendanceTimeDialog, - icon: const Icon(Icons.access_time_rounded, color: Colors.white), - label: Text( - _attendanceTime != null ? '출석시간 $_attendanceTime' : '자습실 출석시간 설정', - style: const TextStyle(color: Colors.white), + return Scaffold( + backgroundColor: Colors.grey[100], + appBar: AppBar( + title: const Text( + '📋 실시간 출석 현황', + style: TextStyle(fontWeight: FontWeight.bold), ), + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + onPressed: (_controller.isLoading || _controller.isRefreshing) + ? null + : _controller.manualRefresh, + icon: _controller.isRefreshing + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.refresh_rounded), + tooltip: '새로고침', + ), + TextButton.icon( + onPressed: _showAttendanceTimeDialog, + icon: const Icon(Icons.access_time_rounded, color: Colors.white), + label: Text( + _controller.attendanceTime != null + ? '출석시간 ${_controller.attendanceTime}' + : '자습실 출석시간 설정', + style: const TextStyle(color: Colors.white), + ), + ), + TextButton.icon( + onPressed: _showPermissionWindowDialog, + icon: const Icon(Icons.timer_outlined, color: Colors.white), + label: const Text( + '반출 허용 시간 설정', + style: TextStyle(color: Colors.white), + ), + ), + TextButton.icon( + onPressed: _showDismissalConfirmDialog, + icon: const Icon(Icons.school_rounded, color: Colors.white), + label: const Text('하교', style: TextStyle(color: Colors.white)), + ), + IconButton( + onPressed: _showTestResetConfirmDialog, + icon: const Icon( + Icons.bug_report_outlined, + color: Colors.white70, + ), + tooltip: '🧪 테스트용: 허용시간 초기화', + ), + const SizedBox(width: 8), + ], ), - TextButton.icon( - onPressed: _showPermissionWindowDialog, - icon: const Icon(Icons.timer_outlined, color: Colors.white), - label: const Text( - '반출 허용 시간 설정', - style: TextStyle(color: Colors.white), - ), - ), - TextButton.icon( - onPressed: _showDismissalConfirmDialog, - icon: const Icon(Icons.school_rounded, color: Colors.white), - label: const Text( - '하교', - style: TextStyle(color: Colors.white), - ), - ), - IconButton( - onPressed: _showTestResetConfirmDialog, - icon: const Icon(Icons.bug_report_outlined, color: Colors.white70), - tooltip: '🧪 테스트용: 허용시간 초기화', - ), - const SizedBox(width: 8), - ], - ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : LayoutBuilder( - builder: (context, constraints) { - final bool isWide = constraints.maxWidth >= 800; - return isWide - ? _buildDesktopBody(totalCount, checkedInCount, absentCount) - : _buildMobileBody(totalCount, checkedInCount, absentCount); - }, - ), + body: _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : LayoutBuilder( + builder: (context, constraints) { + final bool isWide = constraints.maxWidth >= 800; + return isWide + ? _buildDesktopBody( + totalCount, + checkedInCount, + absentCount, + ) + : _buildMobileBody( + totalCount, + checkedInCount, + absentCount, + ); + }, + ), + ); + }, ); } @@ -544,13 +289,14 @@ class _TeacherAttendancePageState extends State { // 📱 모바일 레이아웃 (기존 카드 리스트) // ----------------------------------------------------------------------- Widget _buildMobileBody(int total, int checkedIn, int absent) { + final filteredStudents = _controller.filteredStudents; return Column( children: [ _buildSummaryCards(total, checkedIn, absent), _buildPermissionBanner(), _buildFilterChips(), Expanded( - child: _filteredStudents.isEmpty + child: filteredStudents.isEmpty ? const Center( child: Text( '해당하는 학생이 없습니다.', @@ -559,14 +305,18 @@ class _TeacherAttendancePageState extends State { ) : ListView.builder( padding: const EdgeInsets.symmetric(vertical: 12), - itemCount: _filteredStudents.length, + itemCount: filteredStudents.length, itemBuilder: (context, index) { - final student = _filteredStudents[index]; + final student = filteredStudents[index]; final bool isCheckedIn = student['isCheckedIn']; - final bool hasViolation = student['hasActiveViolation'] == true; - final bool isPending = student['attendanceStatus'] == 'PENDING'; - final bool isComplete = student['attendanceStatus'] == 'COMPLETE'; - final bool emphasizeTime = isComplete && _attendanceTime != null; + final bool hasViolation = + student['hasActiveViolation'] == true; + final bool isPending = + student['attendanceStatus'] == 'PENDING'; + final bool isComplete = + student['attendanceStatus'] == 'COMPLETE'; + final bool emphasizeTime = + isComplete && _controller.attendanceTime != null; final Color statusColor = hasViolation ? Colors.red : (isPending @@ -635,17 +385,18 @@ class _TeacherAttendancePageState extends State { ? '학번: ${student['studentId']} | ⏳ 출석 미완료 (제출: ${student['checkInTime']})' : (isComplete ? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}' - : (student['hasEverCheckedIn'] == true - ? '학번: ${student['studentId']} | 미제출' - : '학번: ${student['studentId']} | 미등록'))), + : (student['hasEverCheckedIn'] == + true + ? '학번: ${student['studentId']} | 미제출' + : '학번: ${student['studentId']} | 미등록'))), style: TextStyle( color: hasViolation ? Colors.red[700] : (isPending - ? Colors.orange[800] - : (isComplete - ? Colors.grey[600] - : Colors.red[400])), + ? Colors.orange[800] + : (isComplete + ? Colors.grey[600] + : Colors.red[400])), fontSize: emphasizeTime ? 14 : 12, fontWeight: (hasViolation || emphasizeTime) ? FontWeight.bold @@ -664,8 +415,9 @@ class _TeacherAttendancePageState extends State { decoration: BoxDecoration( color: Colors.blue.shade50, borderRadius: BorderRadius.circular(20), - border: - Border.all(color: Colors.blue.shade200), + border: Border.all( + color: Colors.blue.shade200, + ), ), child: Text( student['pocketNumber'], @@ -711,6 +463,7 @@ class _TeacherAttendancePageState extends State { // 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때) // ----------------------------------------------------------------------- Widget _buildDesktopBody(int total, int checkedIn, int absent) { + final filteredStudents = _controller.filteredStudents; return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 1100), @@ -767,7 +520,7 @@ class _TeacherAttendancePageState extends State { ), ], ), - child: _filteredStudents.isEmpty + child: filteredStudents.isEmpty ? const Center( child: Text( '해당하는 학생이 없습니다.', @@ -787,7 +540,7 @@ class _TeacherAttendancePageState extends State { DataColumn(label: Text('주머니 번호')), DataColumn(label: Text('작업')), ], - rows: _filteredStudents.map((student) { + rows: filteredStudents.map((student) { final bool isCheckedIn = student['isCheckedIn']; final bool hasViolation = student['hasActiveViolation'] == true; @@ -796,7 +549,7 @@ class _TeacherAttendancePageState extends State { final bool isComplete = student['attendanceStatus'] == 'COMPLETE'; final bool emphasizeTime = - isComplete && _attendanceTime != null; + isComplete && _controller.attendanceTime != null; final Color statusColor = hasViolation ? Colors.red : (isPending @@ -806,8 +559,10 @@ class _TeacherAttendancePageState extends State { color: hasViolation ? WidgetStateProperty.all(Colors.red[50]) : (isPending - ? WidgetStateProperty.all(Colors.orange[50]) - : null), + ? WidgetStateProperty.all( + Colors.orange[50], + ) + : null), cells: [ DataCell( Icon( @@ -839,17 +594,18 @@ class _TeacherAttendancePageState extends State { ? '⏳ 출석 미완료 (제출: ${student['checkInTime']})' : (isComplete ? '${student['checkInTime']}' - : (student['hasEverCheckedIn'] == true - ? '미제출' - : '미등록'))), + : (student['hasEverCheckedIn'] == + true + ? '미제출' + : '미등록'))), style: TextStyle( color: hasViolation ? Colors.red[700] : (isPending - ? Colors.orange[800] - : (isComplete - ? Colors.grey[700] - : Colors.red[400])), + ? Colors.orange[800] + : (isComplete + ? Colors.grey[700] + : Colors.red[400])), fontSize: emphasizeTime ? 15 : 14, fontWeight: (hasViolation || emphasizeTime) ? FontWeight.bold @@ -900,8 +656,8 @@ class _TeacherAttendancePageState extends State { foregroundColor: Colors.white, padding: const EdgeInsets.symmetric( - horizontal: 12, - ), + horizontal: 12, + ), ), ) : const Text('-'), @@ -1002,11 +758,11 @@ class _TeacherAttendancePageState extends State { ); } - /// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자) /// 🔓 하교(12시간)/반출 허용 시간 설정으로 지금 전체 반출이 허용 중이면 눈에 띄게 배너로 알려준다. /// (허용 중일 땐 무단반출을 감지해도 대시보드에 뜨지 않기 때문에, 왜 안 뜨는지 헷갈리지 않게 하기 위함) Widget _buildPermissionBanner() { - if (_globalPermissionUntil == null) return const SizedBox.shrink(); + final String? until = _controller.globalPermissionUntil; + if (until == null) return const SizedBox.shrink(); return Container( width: double.infinity, @@ -1023,7 +779,7 @@ class _TeacherAttendancePageState extends State { const SizedBox(width: 8), Expanded( child: Text( - '🔓 지금 전체 반출 허용 중입니다 ($_globalPermissionUntil 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.', + '🔓 지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.', style: TextStyle( color: Colors.amber[900], fontWeight: FontWeight.bold, @@ -1036,6 +792,7 @@ class _TeacherAttendancePageState extends State { ); } + /// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자) Widget _buildFilterChips() { return Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), @@ -1043,20 +800,20 @@ class _TeacherAttendancePageState extends State { children: [ FilterChip( label: const Text("전체"), - selected: _filterType == "ALL", - onSelected: (_) => setState(() => _filterType = "ALL"), + selected: _controller.filterType == "ALL", + onSelected: (_) => _controller.setFilter("ALL"), ), const SizedBox(width: 8), FilterChip( label: const Text("🟢 출석자"), - selected: _filterType == "CHECKED_IN", - onSelected: (_) => setState(() => _filterType = "CHECKED_IN"), + selected: _controller.filterType == "CHECKED_IN", + onSelected: (_) => _controller.setFilter("CHECKED_IN"), ), const SizedBox(width: 8), FilterChip( label: const Text("🔴 미출석자"), - selected: _filterType == "ABSENT", - onSelected: (_) => setState(() => _filterType = "ABSENT"), + selected: _controller.filterType == "ABSENT", + onSelected: (_) => _controller.setFilter("ABSENT"), ), ], ), diff --git a/lib/screens/teacher_dashboard.dart b/lib/ui/teacher_dashboard.dart similarity index 100% rename from lib/screens/teacher_dashboard.dart rename to lib/ui/teacher_dashboard.dart diff --git a/lib/ui/teacher_register_screen.dart b/lib/ui/teacher_register_screen.dart new file mode 100644 index 0000000..f66a649 --- /dev/null +++ b/lib/ui/teacher_register_screen.dart @@ -0,0 +1,141 @@ +// 👨‍🏫 교사 회원가입 화면 (UI 전용). 서버 통신/상태는 +// lib/function/teacher_register_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/teacher_register_controller.dart'; + +class TeacherRegisterScreen extends StatefulWidget { + const TeacherRegisterScreen({super.key}); + + @override + State createState() => _TeacherRegisterScreenState(); +} + +class _TeacherRegisterScreenState extends State { + final TeacherRegisterController _controller = TeacherRegisterController(); + final _idController = TextEditingController(); + final _pwController = TextEditingController(); + final _nameController = TextEditingController(); + final _secretController = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + _idController.dispose(); + _pwController.dispose(); + _nameController.dispose(); + _secretController.dispose(); + super.dispose(); + } + + Future _registerTeacher() async { + String id = _idController.text.trim(); + String pw = _pwController.text.trim(); + String name = _nameController.text.trim(); + String secret = _secretController.text.trim(); + + if (id.isEmpty || pw.isEmpty || name.isEmpty || secret.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('⚠️ 모든 빈칸을 입력해 주세요.'))); + return; + } + + final (success, message) = await _controller.registerTeacher( + id: id, + password: pw, + name: name, + secretCode: secret, + ); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + if (success) { + Navigator.pop(context); // 가입 성공 시 로그인 화면으로 복귀 + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + return Scaffold( + appBar: AppBar( + title: const Text('교사 회원가입'), + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '👨‍🏫 교직원 전용 인증', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + const Text( + '학교에서 발급한 교사 가입 비밀코드가 필요합니다.', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 24), + TextField( + controller: _secretController, + obscureText: true, + decoration: const InputDecoration( + labelText: '🔑 교사 인증 비밀코드 입력', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + TextField( + controller: _idController, + decoration: const InputDecoration( + labelText: '교직원 번호 (ID로 사용)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: '선생님 성함', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _pwController, + obscureText: true, + decoration: const InputDecoration( + labelText: '사용할 비밀번호 입력', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + ), + onPressed: _controller.isLoading ? null : _registerTeacher, + child: _controller.isLoading + ? const CircularProgressIndicator(color: Colors.white) + : const Text('교사 계정 생성하기'), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/teacher_student_management_page.dart b/lib/ui/teacher_student_management_page.dart new file mode 100644 index 0000000..1d91937 --- /dev/null +++ b/lib/ui/teacher_student_management_page.dart @@ -0,0 +1,348 @@ +// 👥 교사용 학생 계정 관리 화면 (UI 전용). 신규 학생 계정 추가 폼과 강제 삭제 다이얼로그를 그린다. +// 서버 통신/상태는 lib/function/teacher_student_management_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/teacher_student_management_controller.dart'; + +// ----------------------------------------------------------------------------- +// 👥 [서브 화면 2] 학생 계정 관리 란 (추가 양식 폼 + 강제 삭제 다이얼로그 완전 내장) +// ----------------------------------------------------------------------------- +class TeacherStudentManagementPage extends StatefulWidget { + const TeacherStudentManagementPage({super.key}); + + @override + State createState() => + _TeacherStudentManagementPageState(); +} + +class _TeacherStudentManagementPageState + extends State { + final TeacherStudentManagementController _controller = + TeacherStudentManagementController(); + final TextEditingController _addIdController = TextEditingController(); + final TextEditingController _addNameController = TextEditingController(); + final TextEditingController _addPwController = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + _addIdController.dispose(); + _addNameController.dispose(); + _addPwController.dispose(); + super.dispose(); + } + + // ➕ [학생 추가 버튼 동작] + Future _addStudentAccount() async { + final sId = _addIdController.text.trim(); + final sName = _addNameController.text.trim(); + final sPw = _addPwController.text.trim(); + + if (sId.isEmpty || sName.isEmpty || sPw.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('⚠️ 모든 입력란을 채워주세요.'))); + return; + } + + final (success, message) = await _controller.addStudentAccount( + studentId: sId, + name: sName, + password: sPw, + ); + if (!mounted) return; + if (success) { + _addIdController.clear(); + _addNameController.clear(); + _addPwController.clear(); + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // ❌ [학생 삭제 버튼 동작] + Future _deleteStudentAccount(String studentId) async { + final (_, message) = await _controller.deleteStudentAccount(studentId); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // 🚨 계정 삭제 확인 팝업창 모달 + void _showDeleteDialog() { + final TextEditingController deleteIdController = TextEditingController(); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + title: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.orange[700]), + const SizedBox(width: 10), + const Text( + '학생 계정 강제 삭제', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '영구 삭제할 학생의 학번을 정확하게 입력하세요.', + style: TextStyle(color: Colors.black54, fontSize: 13), + ), + const SizedBox(height: 16), + TextField( + controller: deleteIdController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: '학번 입력', + labelStyle: TextStyle(color: Colors.orange[700]), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: Colors.orange[700]!, + width: 2, + ), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + ), + prefixIcon: const Icon(Icons.no_accounts_rounded), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('취소', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + onPressed: () { + final inputId = deleteIdController.text.trim(); + if (inputId.isNotEmpty) { + Navigator.pop(context); + _deleteStudentAccount(inputId); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange[700], + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + '삭제 확정', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: Colors.grey[100], + appBar: AppBar( + title: const Text( + '⚙️ 학생 통합 관리 센터', + style: TextStyle(fontWeight: FontWeight.bold), + ), + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + elevation: 0, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 🏷️ 인디케이터 바 1 (추가 메뉴) + Row( + children: [ + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + const Text( + '신규 학생 계정 추가', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 16), + + // 📝 학생 추가 컨테이너 폼 + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 16, + ), + ], + ), + child: Column( + children: [ + TextField( + controller: _addIdController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '학번', + prefixIcon: Icon(Icons.badge), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _addNameController, + decoration: const InputDecoration( + labelText: '이름', + prefixIcon: Icon(Icons.person), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _addPwController, + obscureText: true, + decoration: const InputDecoration( + labelText: '초기 비밀번호', + prefixIcon: Icon(Icons.lock), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _controller.isWorking + ? null + : _addStudentAccount, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _controller.isWorking + ? const CircularProgressIndicator( + color: Colors.white, + ) + : const Text( + '학생 등록 완료', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 36), + + // 🏷️ 인디케이터 바 2 (삭제 권한 제어메뉴) + Row( + children: [ + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + const Text( + '위험 구역 (Account Reset)', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.redAccent, + ), + ), + ], + ), + const SizedBox(height: 16), + + // 🚨 학생 삭제 트리거 카드 + InkWell( + onTap: _showDeleteDialog, + borderRadius: BorderRadius.circular(24), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.red[50], + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.red.shade200), + ), + child: const Row( + children: [ + Icon( + Icons.delete_sweep_rounded, + color: Colors.red, + size: 32, + ), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '학생 계정 강제 원격 삭제', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + SizedBox(height: 2), + Text( + '인증 초기화 및 DB 제거용', + style: TextStyle( + fontSize: 12, + color: Colors.redAccent, + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios_rounded, + color: Colors.red, + size: 16, + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +}