선생님/마스터 계정의 학생 계정 관리 화면을 하나로 통합
- 두 화면이 서로 다른 기능만 갖고 있던 문제 해결: 선생님 화면(엑셀 일괄등록, 학년 지정, 커스텀 초기비번)에 마스터 화면에만 있던 실시간 학생 목록 조회 + 기기 리셋(학생이 폰 바꿨을 때 재등록용) 기능을 합침 - 학번을 직접 타이핑하던 블라인드 학년변경/삭제 다이얼로그를 목록의 행별 액션(학년 칩, 기기 리셋 버튼, 삭제 버튼)으로 교체 - 계정 생성/삭제/학년변경 성공 시 목록 자동 새로고침 - 마스터(2061) 계정의 "학생 계정 관리" 카드도 이 통합 화면으로 연결하고, 중복되던 student_management_screen.dart / student_management_controller.dart 삭제 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,102 +0,0 @@
|
||||
// 🔐 (마스터 계정용) 학생 계정 및 기기 관리 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
|
||||
class StudentManagementController extends ChangeNotifier {
|
||||
List<dynamic> _students = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
List<dynamic> get students => _students;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
Future<void> init() => fetchStudents();
|
||||
|
||||
/// 🌐 서버에서 전체 학생 목록 불러오기. 실패 시 에러 메시지를 반환한다(성공 시 null).
|
||||
Future<String?> fetchStudents() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final response = await http.get(Uri.parse('$baseUrl/api/users'));
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
_students = data['users'] ?? [];
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return '데이터 불러오기 실패 (${response.statusCode})';
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return '데이터 불러오기 실패: $e';
|
||||
}
|
||||
}
|
||||
|
||||
/// 🌐 서버로 기기 초기화(리셋) 명령 보내기
|
||||
Future<(bool success, String message)> resetDevice(
|
||||
String studentId,
|
||||
String studentName,
|
||||
) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/users/reset'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"studentId": studentId}),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
await fetchStudents();
|
||||
return (true, '✅ $studentName 학생 기기 초기화 완료!');
|
||||
}
|
||||
return (false, '❌ 초기화 통신 실패');
|
||||
} catch (e) {
|
||||
return (false, '❌ 초기화 통신 실패');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🌐 서버로 계정 완전 삭제 명령 보내기
|
||||
Future<(bool success, String message)> deleteUser(
|
||||
String studentId,
|
||||
String studentName,
|
||||
) async {
|
||||
try {
|
||||
final response = await http.delete(
|
||||
Uri.parse('$baseUrl/api/users/delete'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"studentId": studentId}),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
await fetchStudents();
|
||||
return (true, '💥 $studentName 학생의 계정이 영구 삭제되었습니다.');
|
||||
}
|
||||
return (false, '❌ 계정 삭제 통신 실패');
|
||||
} catch (e) {
|
||||
return (false, '❌ 계정 삭제 통신 실패');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🌐 신규 학생 계정 생성
|
||||
Future<(bool success, String message)> createUser(
|
||||
String studentId,
|
||||
String name,
|
||||
) async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/users/create'),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: jsonEncode({"studentId": studentId, "name": name}),
|
||||
);
|
||||
final res = jsonDecode(response.body);
|
||||
if (res['status'] == 'success') {
|
||||
await fetchStudents();
|
||||
return (true, '✅ ${res['message']}');
|
||||
}
|
||||
return (false, '❌ ${res['message']}');
|
||||
} catch (e) {
|
||||
return (false, '❌ 학생 등록 실패 (통신 에러)');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,58 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
||||
int get bulkTotal => _bulkTotal;
|
||||
int get bulkDone => _bulkDone;
|
||||
|
||||
List<dynamic> _students = [];
|
||||
bool _isLoadingStudents = true;
|
||||
List<dynamic> get students => _students;
|
||||
bool get isLoadingStudents => _isLoadingStudents;
|
||||
|
||||
Future<void> init() => fetchStudents();
|
||||
|
||||
/// 🌐 전체 학생 목록 불러오기 (학번/이름/학년/기기등록상태).
|
||||
Future<void> fetchStudents() async {
|
||||
_isLoadingStudents = 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'] ?? [];
|
||||
}
|
||||
} catch (_) {
|
||||
// 목록 새로고침 실패는 조용히 무시 — 화면엔 마지막으로 불러온 목록이 남는다.
|
||||
} finally {
|
||||
_isLoadingStudents = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🌐 학생의 등록된 기기(스마트폰) UUID + 비밀번호(1234)를 초기화한다.
|
||||
/// (학생이 폰을 바꿨을 때 다시 등록할 수 있게 해주는 용도)
|
||||
Future<(bool success, String message)> resetDevice(
|
||||
String studentId,
|
||||
String studentName,
|
||||
) async {
|
||||
_isWorking = true;
|
||||
notifyListeners();
|
||||
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 학생 기기 초기화 완료! (비밀번호도 1234로 초기화됨)');
|
||||
}
|
||||
return (false, '❌ 초기화 통신 실패');
|
||||
} catch (e) {
|
||||
return (false, '❌ 초기화 통신 실패: $e');
|
||||
} finally {
|
||||
_isWorking = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 📄 엑셀 파일 바이트를 파싱한다. 1행은 머리글로 보고 건너뛰며,
|
||||
/// A열=학번, B열=이름, C열=학년(선택, 숫자) 순서를 기대한다.
|
||||
/// 학번이나 이름이 비어있는 줄은 조용히 무시한다.
|
||||
@@ -105,6 +157,7 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
await fetchStudents();
|
||||
_isWorking = false;
|
||||
notifyListeners();
|
||||
return BulkImportResult(successCount: successCount, failures: failures);
|
||||
@@ -120,12 +173,14 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
||||
_isWorking = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
return await _createStudentRequest(
|
||||
final result = await _createStudentRequest(
|
||||
studentId: studentId,
|
||||
name: name,
|
||||
password: password,
|
||||
grade: grade,
|
||||
);
|
||||
if (result.$1) await fetchStudents();
|
||||
return result;
|
||||
} finally {
|
||||
_isWorking = false;
|
||||
notifyListeners();
|
||||
@@ -182,6 +237,7 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
||||
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
|
||||
if (response.statusCode == 200 && result['status'] == 'success') {
|
||||
await fetchStudents();
|
||||
return (true, '✅ ${result['message'] ?? '학년이 지정되었습니다.'}');
|
||||
} else {
|
||||
return (false, '❌ 지정 실패: ${result['message'] ?? '오류 발생'}');
|
||||
@@ -209,6 +265,7 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final resData = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
await fetchStudents();
|
||||
return (true, '✅ ${resData['message']}');
|
||||
} else {
|
||||
return (false, '❌ 삭제 실패하였습니다.');
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 'teacher_student_management_page.dart';
|
||||
import 'nfc_tag_writer_screen.dart';
|
||||
|
||||
// -------------------------------------------------------------
|
||||
@@ -384,7 +384,7 @@ class _StudentDashboardState extends State<StudentDashboard> {
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
const StudentManagementScreen(),
|
||||
const TeacherStudentManagementPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
// 🔐 (마스터 계정용) 학생 계정 및 기기 관리 화면 (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<StudentManagementScreen> createState() =>
|
||||
_StudentManagementScreenState();
|
||||
}
|
||||
|
||||
class _StudentManagementScreenState extends State<StudentManagementScreen> {
|
||||
final StudentManagementController _controller =
|
||||
StudentManagementController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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']),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,12 @@ class _TeacherStudentManagementPageState
|
||||
int? _selectedGrade;
|
||||
bool _isDragging = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
@@ -244,185 +250,125 @@ class _TeacherStudentManagementPageState
|
||||
);
|
||||
}
|
||||
|
||||
// 🏫 [기존 학생 학년 지정/변경 다이얼로그]
|
||||
void _showUpdateGradeDialog() {
|
||||
final TextEditingController idController = TextEditingController();
|
||||
int? grade;
|
||||
// 🏫 [목록 행의 "학년" 칩] 눌러서 바로 학년 변경 (미배정/1/2/3학년 중 선택).
|
||||
Future<void> _showGradeMenu(
|
||||
BuildContext tileContext,
|
||||
String studentId,
|
||||
String studentName,
|
||||
int? currentGrade,
|
||||
) async {
|
||||
final RenderBox box = tileContext.findRenderObject() as RenderBox;
|
||||
final Offset position = box.localToGlobal(Offset.zero);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
title: const Text(
|
||||
'🏫 학년 지정/변경',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'이미 만들어진 학생 계정의 학년을 지정하거나 바꿉니다.',
|
||||
style: TextStyle(color: Colors.black54, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: idController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '학번',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.badge),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int?>(
|
||||
initialValue: grade,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '학년',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.class_),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: null, child: Text('미배정')),
|
||||
DropdownMenuItem(value: 1, child: Text('1학년')),
|
||||
DropdownMenuItem(value: 2, child: Text('2학년')),
|
||||
DropdownMenuItem(value: 3, child: Text('3학년')),
|
||||
],
|
||||
onChanged: (value) => setDialogState(() => grade = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final inputId = idController.text.trim();
|
||||
if (inputId.isEmpty) return;
|
||||
Navigator.pop(context);
|
||||
final (_, message) = await _controller.updateStudentGrade(
|
||||
studentId: inputId,
|
||||
grade: grade,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'적용',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
final selected = await showMenu<int?>(
|
||||
context: tileContext,
|
||||
position: RelativeRect.fromLTRB(
|
||||
position.dx,
|
||||
position.dy + box.size.height,
|
||||
position.dx,
|
||||
0,
|
||||
),
|
||||
items: const [
|
||||
PopupMenuItem(value: null, child: Text('미배정')),
|
||||
PopupMenuItem(value: 1, child: Text('1학년')),
|
||||
PopupMenuItem(value: 2, child: Text('2학년')),
|
||||
PopupMenuItem(value: 3, child: Text('3학년')),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (selected == currentGrade) return; // 취소했거나 같은 값 선택
|
||||
if (!mounted) return;
|
||||
|
||||
// ❌ [학생 삭제 버튼 동작]
|
||||
Future<void> _deleteStudentAccount(String studentId) async {
|
||||
final (_, message) = await _controller.deleteStudentAccount(studentId);
|
||||
final (_, message) = await _controller.updateStudentGrade(
|
||||
studentId: studentId,
|
||||
grade: selected,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// 🚨 계정 삭제 확인 팝업창 모달
|
||||
void _showDeleteDialog() {
|
||||
final TextEditingController deleteIdController = TextEditingController();
|
||||
// 🔓 [목록 행의 "기기 리셋" 버튼] 학생이 폰을 바꿨을 때 등록 초기화.
|
||||
void _confirmResetDevice(String studentId, String studentName) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text(
|
||||
'⚠️ 기기 등록 초기화',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.purple),
|
||||
),
|
||||
content: Text('$studentName 학생의 스마트폰 기기 등록과 비밀번호(1234)를 초기화하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
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(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.purple,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
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),
|
||||
),
|
||||
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 _confirmDeleteStudent(String studentId, String studentName) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.red[700]),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'계정 완전 삭제',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
content: Text(
|
||||
'정말로 $studentName ($studentId) 학생의 계정을 영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[600],
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
final (_, message) = await _controller.deleteStudentAccount(
|
||||
studentId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
},
|
||||
child: const Text('영구 삭제'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -642,7 +588,7 @@ class _TeacherStudentManagementPageState
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
|
||||
// 🏷️ 인디케이터 바 (기존 학생 학년 지정/변경)
|
||||
// 🏷️ 인디케이터 바 (전체 학생 목록)
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
@@ -655,139 +601,134 @@ class _TeacherStudentManagementPageState
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'기존 학생 학년 지정/변경',
|
||||
'전체 학생 목록',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
InkWell(
|
||||
onTap: _showUpdateGradeDialog,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Colors.blue.shade200),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.class_rounded, color: Colors.blue, size: 32),
|
||||
SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'학번으로 학년 지정',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'이미 만든 계정의 학년을 나중에 지정하거나 바꿀 때 사용',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blueAccent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios_rounded,
|
||||
color: Colors.blue,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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 Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
onPressed: _controller.isLoadingStudents
|
||||
? null
|
||||
: _controller.fetchStudents,
|
||||
tooltip: '새로고침',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'학년 칩을 눌러 학년을 바꾸고, 기기 리셋은 학생이 폰을 바꿨을 때 사용하세요.',
|
||||
style: TextStyle(color: Colors.black54, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 🚨 학생 삭제 트리거 카드
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
_controller.isLoadingStudents
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 40),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: _controller.students.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'가입된 학생 계정이 없습니다.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios_rounded,
|
||||
color: Colors.red,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _controller.students.length,
|
||||
itemBuilder: (context, index) {
|
||||
final student = _controller.students[index];
|
||||
final String studentId = student['id'].toString();
|
||||
final String studentName = student['name'].toString();
|
||||
final int? grade = student['grade'] as int?;
|
||||
final bool needsReset = student['device'] == '초기화 필요';
|
||||
|
||||
return Card(
|
||||
elevation: 1,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: needsReset
|
||||
? Colors.red[50]
|
||||
: Colors.blue[50],
|
||||
child: Icon(
|
||||
needsReset ? Icons.lock_reset : Icons.person,
|
||||
color: needsReset ? Colors.red : Colors.blue,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
'$studentName ($studentId)',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
needsReset ? '기기 초기화 승인 대기중' : '정상 등록 상태',
|
||||
style: TextStyle(
|
||||
color: needsReset ? Colors.red : Colors.grey,
|
||||
fontWeight: needsReset
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Builder(
|
||||
builder: (chipContext) => ActionChip(
|
||||
label: Text(
|
||||
grade != null ? '$grade학년' : '미배정',
|
||||
),
|
||||
onPressed: () => _showGradeMenu(
|
||||
chipContext,
|
||||
studentId,
|
||||
studentName,
|
||||
grade,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.lock_reset,
|
||||
color: Colors.purple,
|
||||
),
|
||||
tooltip: '기기 리셋',
|
||||
onPressed: () => _confirmResetDevice(
|
||||
studentId,
|
||||
studentName,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.delete_forever_rounded,
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
tooltip: '계정 영구 삭제',
|
||||
onPressed: () => _confirmDeleteStudent(
|
||||
studentId,
|
||||
studentName,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user