Files
school-attendance/lib/function/teacher_student_management_controller.dart
T

380 lines
13 KiB
Dart
Raw Normal View History

// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태/엑셀 일괄등록) 담당 컨트롤러.
import 'dart:convert';
import 'package:archive/archive.dart';
import 'package:excel/excel.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
/// 📄 엑셀 한 줄에서 뽑아낸 학생 정보 (아직 서버에 보내기 전, 미리보기용).
class BulkStudentRow {
final String studentId;
final String name;
final int? grade;
const BulkStudentRow({
required this.studentId,
required this.name,
this.grade,
});
}
/// 📊 일괄 등록 결과. [failures]는 "학번 이름: 실패사유" 형태의 문자열 목록.
class BulkImportResult {
final int successCount;
final List<String> failures;
const BulkImportResult({required this.successCount, required this.failures});
}
class TeacherStudentManagementController extends ChangeNotifier {
bool _isWorking = false;
bool get isWorking => _isWorking;
int _bulkTotal = 0;
int _bulkDone = 0;
int get bulkTotal => _bulkTotal;
int get bulkDone => _bulkDone;
List<dynamic> _students = [];
bool _isLoadingStudents = true;
String _searchQuery = '';
int? _gradeFilter; // null = 전체
List<dynamic> get students => _students;
bool get isLoadingStudents => _isLoadingStudents;
String get searchQuery => _searchQuery;
int? get gradeFilter => _gradeFilter;
/// 🔍 학번 검색 + 학년 필터를 적용하고, 학년 오름차순(1→2→3, 미배정은 맨 뒤)으로
/// 정렬한 목록. 화면에는 이 목록을 그린다.
List<dynamic> get filteredStudents {
final query = _searchQuery.trim();
final filtered = _students.where((s) {
if (_gradeFilter != null && s['grade'] != _gradeFilter) return false;
if (query.isEmpty) return true;
return s['id'].toString().contains(query);
}).toList();
filtered.sort((a, b) {
final ga = a['grade'] as int?;
final gb = b['grade'] as int?;
if (ga == null && gb == null) return 0;
if (ga == null) return 1;
if (gb == null) return -1;
return ga.compareTo(gb);
});
return filtered;
}
void setSearchQuery(String query) {
_searchQuery = query;
notifyListeners();
}
void setGradeFilter(int? grade) {
_gradeFilter = grade;
notifyListeners();
}
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열=학년(선택, 숫자) 순서를 기대한다.
/// 학번이나 이름이 비어있는 줄은 조용히 무시한다.
List<BulkStudentRow> parseExcelBytes(Uint8List bytes) {
final excel = Excel.decodeBytes(_sanitizeXlsxBytes(bytes));
if (excel.tables.isEmpty) return [];
final sheet = excel.tables.values.first;
final rows = <BulkStudentRow>[];
for (final row in sheet.rows.skip(1)) {
final studentId = _cellText(row.isNotEmpty ? row[0]?.value : null);
final name = _cellText(row.length > 1 ? row[1]?.value : null);
if (studentId == null || name == null) continue;
final grade = _cellToGrade(row.length > 2 ? row[2]?.value : null);
rows.add(BulkStudentRow(studentId: studentId, name: name, grade: grade));
}
return rows;
}
/// 🩹 [엑셀 파싱 버그 우회] 일부 엑셀 파일(구글시트/WPS 등에서 내보낸 파일 포함)은
/// styles.xml에 커스텀 숫자서식 id를 164 미만(예: 0)으로 잘못 적어두는데, excel
/// 패키지가 이걸 엄격하게 걸러내며 예외를 던져서 파일 전체를 못 읽게 만든다.
/// 우리는 셀 값(학번/이름/학년)만 필요하고 서식은 필요 없으니, zip 안의 styles.xml만
/// 열어서 그 잘못된 커스텀 서식 선언을 지우고 다시 압축해서 넘긴다.
Uint8List _sanitizeXlsxBytes(Uint8List bytes) {
try {
final zip = ZipDecoder().decodeBytes(bytes);
final stylesFile = zip.files.cast<ArchiveFile?>().firstWhere(
(f) => f?.name == 'xl/styles.xml',
orElse: () => null,
);
if (stylesFile == null) return bytes;
final xml = utf8.decode(stylesFile.content as List<int>);
final fixedXml = _stripInvalidCustomNumFmts(xml);
if (fixedXml == xml) return bytes; // 고칠 게 없었으면 원본 그대로.
final newZip = Archive();
for (final file in zip.files) {
if (!file.isFile) continue;
if (file.name == 'xl/styles.xml') {
final content = utf8.encode(fixedXml);
newZip.addFile(ArchiveFile('xl/styles.xml', content.length, content));
} else {
newZip.addFile(ArchiveFile(file.name, file.size, file.content));
}
}
final rezipped = ZipEncoder().encode(newZip);
return rezipped != null ? Uint8List.fromList(rezipped) : bytes;
} catch (_) {
return bytes; // 손대다 실패하면 원본 그대로 시도 (기존 동작 유지).
}
}
/// styles.xml 문자열에서 `<numFmt numFmtId="N" .../>` 태그 중 N이 164 미만인
/// (규격 위반인) 것만 통째로 제거한다. 그 서식을 참조하던 셀은 기본 서식으로 처리될
/// 뿐이라 값 읽기에는 영향이 없다.
String _stripInvalidCustomNumFmts(String xml) {
final buffer = StringBuffer();
int i = 0;
while (true) {
final tagStart = xml.indexOf('<numFmt ', i);
if (tagStart == -1) {
buffer.write(xml.substring(i));
break;
}
final tagEnd = xml.indexOf('/>', tagStart);
if (tagEnd == -1) {
buffer.write(xml.substring(i));
break;
}
buffer.write(xml.substring(i, tagStart));
final tag = xml.substring(tagStart, tagEnd + 2);
final idMatch = RegExp(r'numFmtId="(\d+)"').firstMatch(tag);
final id = idMatch != null ? int.tryParse(idMatch.group(1)!) : null;
if (id == null || id >= 164) {
buffer.write(tag); // 정상 범위의 커스텀 서식은 그대로 둔다.
}
i = tagEnd + 2;
}
return buffer.toString();
}
String? _cellText(CellValue? value) {
if (value == null) return null;
String text;
if (value is IntCellValue) {
text = value.value.toString();
} else if (value is DoubleCellValue) {
text = value.value == value.value.roundToDouble()
? value.value.toInt().toString()
: value.value.toString();
} else {
text = value.toString().trim();
}
return text.isEmpty ? null : text;
}
int? _cellToGrade(CellValue? value) {
if (value == null) return null;
if (value is IntCellValue) return value.value;
if (value is DoubleCellValue) return value.value.round();
return int.tryParse(value.toString().trim());
}
/// ➕ 엑셀에서 뽑아낸 학생 목록을 한 명씩 순서대로 서버에 등록한다 (기본 비밀번호 1234).
/// 한 명 실패해도 나머지는 계속 진행하고, 마지막에 성공/실패를 모아서 돌려준다.
Future<BulkImportResult> bulkAddStudents(List<BulkStudentRow> rows) async {
_isWorking = true;
_bulkTotal = rows.length;
_bulkDone = 0;
notifyListeners();
int successCount = 0;
final failures = <String>[];
for (final row in rows) {
final (success, message) = await _createStudentRequest(
studentId: row.studentId,
name: row.name,
password: "1234",
grade: row.grade,
);
if (success) {
successCount++;
} else {
failures.add('${row.studentId} ${row.name}: $message');
}
_bulkDone++;
notifyListeners();
}
await fetchStudents();
_isWorking = false;
notifyListeners();
return BulkImportResult(successCount: successCount, failures: failures);
}
/// ➕ 학생 계정 추가. (성공여부, 메시지)를 반환한다 — UI가 성공했을 때만 입력칸을 비운다.
Future<(bool success, String message)> addStudentAccount({
required String studentId,
required String name,
required String password,
int? grade,
}) async {
_isWorking = true;
notifyListeners();
try {
final result = await _createStudentRequest(
studentId: studentId,
name: name,
password: password,
grade: grade,
);
if (result.$1) await fetchStudents();
return result;
} finally {
_isWorking = false;
notifyListeners();
}
}
/// 실제 계정 생성 HTTP 요청. isWorking 상태는 건드리지 않는다 —
/// 단건 등록(addStudentAccount)과 일괄 등록(bulkAddStudents) 양쪽에서
/// 각자 알맞은 시점에 isWorking을 관리하기 위해 분리했다.
Future<(bool success, String message)> _createStudentRequest({
required String studentId,
required String name,
required String password,
int? grade,
}) async {
try {
final url = Uri.parse('$baseUrl/api/users/create');
final response = await http.post(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"studentId": studentId,
"name": name,
"password": password,
"grade": grade,
}),
);
final result = jsonDecode(utf8.decode(response.bodyBytes));
if (response.statusCode == 200 && result['status'] == 'success') {
return (true, '${result['message'] ?? '계정이 생성되었습니다.'}');
} else {
return (false, '생성 실패: ${result['message'] ?? '오류 발생'}');
}
} catch (e) {
return (false, '네트워크 에러: $e');
}
}
/// 🏫 이미 만들어진 학생 계정의 학년을 지정/변경한다. grade가 null이면 "미배정"으로 되돌린다.
Future<(bool success, String message)> updateStudentGrade({
required String studentId,
int? grade,
}) async {
_isWorking = true;
notifyListeners();
try {
final url = Uri.parse('$baseUrl/api/users/update-grade');
final response = await http.post(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId, "grade": grade}),
);
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'] ?? '오류 발생'}');
}
} 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');
try {
final response = await http.delete(
url,
headers: {"Content-Type": "application/json"},
body: jsonEncode({"studentId": studentId}),
);
if (response.statusCode == 200) {
final resData = jsonDecode(utf8.decode(response.bodyBytes));
await fetchStudents();
return (true, '${resData['message']}');
} else {
return (false, '삭제 실패하였습니다.');
}
} catch (e) {
return (false, '서버 에러 발생');
} finally {
_isWorking = false;
notifyListeners();
}
}
}