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, + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +}