학생 계정 엑셀 일괄 등록 기능 추가 (신입생 등)
- 학생 계정 관리 화면에 드래그앤드롭/클릭 업로드 영역 추가 (excel, desktop_drop, file_picker 패키지 도입) - .xlsx 파싱 → 미리보기 팝업 → 확인 후 한 명씩 순서대로 계정 생성, 결과(성공/실패)를 요약해서 보여줌. 초기 비밀번호는 1234 고정 - file_picker의 웹 구현체가 pickFile(s)를 아직 구현하지 않아서 (UnimplementedError) 웹에서는 package:web으로 직접 <input type=file>을 다뤄서 우회 (lib/function/excel_file_picker_web.dart), 다른 플랫폼은 기존 file_picker 사용 (excel_file_picker_io.dart) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
// 📄 엑셀 파일 하나를 골라 바이트로 돌려주는 진입점. 실제 구현은 플랫폼별로 갈라진다
|
||||||
|
// (web은 file_picker의 웹 구현체에 pickFile(s)가 아직 없어서 dart:html로 직접 처리).
|
||||||
|
export 'excel_file_picker_io.dart'
|
||||||
|
if (dart.library.html) 'excel_file_picker_web.dart';
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// 📄 [비웹 플랫폼] file_picker로 엑셀 파일 하나를 고른다.
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
|
||||||
|
/// 사용자가 엑셀(.xlsx) 파일을 고르면 바이트를 돌려준다. 취소하면 null.
|
||||||
|
Future<Uint8List?> pickExcelFileBytes() async {
|
||||||
|
final files = await FilePicker.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['xlsx'],
|
||||||
|
);
|
||||||
|
if (files.isEmpty) return null;
|
||||||
|
return files.first.readAsBytes();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 📄 [웹 전용] file_picker의 웹 구현체에 pickFile(s)가 아직 없어서(UnimplementedError),
|
||||||
|
// 숨겨진 <input type="file">를 직접 만들어 처리한다.
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
|
/// 사용자가 엑셀(.xlsx) 파일을 고르면 바이트를 돌려준다. 취소하면 null.
|
||||||
|
Future<Uint8List?> pickExcelFileBytes() async {
|
||||||
|
final completer = Completer<web.File?>();
|
||||||
|
final input = web.HTMLInputElement()
|
||||||
|
..type = 'file'
|
||||||
|
..accept = '.xlsx'
|
||||||
|
..style.display = 'none';
|
||||||
|
// 문서에 실제로 붙어있지 않으면 일부 브라우저/자동화 환경에서 change 이벤트가
|
||||||
|
// 안정적으로 안 잡힌다 — 그래서 body에 잠깐 붙였다가 끝나면 반드시 제거한다.
|
||||||
|
web.document.body!.append(input);
|
||||||
|
|
||||||
|
input.onchange = (web.Event event) {
|
||||||
|
final file = input.files?.item(0);
|
||||||
|
input.remove();
|
||||||
|
completer.complete(file);
|
||||||
|
}.toJS;
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
final file = await completer.future;
|
||||||
|
if (file == null) return null;
|
||||||
|
|
||||||
|
final buffer = await file.arrayBuffer().toDart;
|
||||||
|
return buffer.toDart.asUint8List();
|
||||||
|
}
|
||||||
@@ -1,13 +1,115 @@
|
|||||||
// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태/엑셀 일괄등록) 담당 컨트롤러.
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'package:excel/excel.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../config.dart';
|
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 {
|
class TeacherStudentManagementController extends ChangeNotifier {
|
||||||
bool _isWorking = false;
|
bool _isWorking = false;
|
||||||
bool get isWorking => _isWorking;
|
bool get isWorking => _isWorking;
|
||||||
|
|
||||||
|
int _bulkTotal = 0;
|
||||||
|
int _bulkDone = 0;
|
||||||
|
int get bulkTotal => _bulkTotal;
|
||||||
|
int get bulkDone => _bulkDone;
|
||||||
|
|
||||||
|
/// 📄 엑셀 파일 바이트를 파싱한다. 1행은 머리글로 보고 건너뛰며,
|
||||||
|
/// A열=학번, B열=이름, C열=학년(선택, 숫자) 순서를 기대한다.
|
||||||
|
/// 학번이나 이름이 비어있는 줄은 조용히 무시한다.
|
||||||
|
List<BulkStudentRow> parseExcelBytes(Uint8List bytes) {
|
||||||
|
final excel = Excel.decodeBytes(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
_isWorking = false;
|
||||||
|
notifyListeners();
|
||||||
|
return BulkImportResult(successCount: successCount, failures: failures);
|
||||||
|
}
|
||||||
|
|
||||||
/// ➕ 학생 계정 추가. (성공여부, 메시지)를 반환한다 — UI가 성공했을 때만 입력칸을 비운다.
|
/// ➕ 학생 계정 추가. (성공여부, 메시지)를 반환한다 — UI가 성공했을 때만 입력칸을 비운다.
|
||||||
Future<(bool success, String message)> addStudentAccount({
|
Future<(bool success, String message)> addStudentAccount({
|
||||||
required String studentId,
|
required String studentId,
|
||||||
@@ -17,6 +119,28 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
|||||||
}) async {
|
}) async {
|
||||||
_isWorking = true;
|
_isWorking = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
return await _createStudentRequest(
|
||||||
|
studentId: studentId,
|
||||||
|
name: name,
|
||||||
|
password: password,
|
||||||
|
grade: grade,
|
||||||
|
);
|
||||||
|
} 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 {
|
try {
|
||||||
final url = Uri.parse('$baseUrl/api/users/create');
|
final url = Uri.parse('$baseUrl/api/users/create');
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
@@ -38,9 +162,6 @@ class TeacherStudentManagementController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (false, '🚨 네트워크 에러: $e');
|
return (false, '🚨 네트워크 에러: $e');
|
||||||
} finally {
|
|
||||||
_isWorking = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// 👥 교사용 학생 계정 관리 화면 (UI 전용). 신규 학생 계정 추가 폼과 강제 삭제 다이얼로그를 그린다.
|
// 👥 교사용 학생 계정 관리 화면 (UI 전용). 신규 학생 계정 추가 폼과 강제 삭제 다이얼로그를 그린다.
|
||||||
// 서버 통신/상태는 lib/function/teacher_student_management_controller.dart가 담당한다.
|
// 서버 통신/상태는 lib/function/teacher_student_management_controller.dart가 담당한다.
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:desktop_drop/desktop_drop.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../function/excel_file_picker.dart';
|
||||||
import '../function/teacher_student_management_controller.dart';
|
import '../function/teacher_student_management_controller.dart';
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -22,6 +25,7 @@ class _TeacherStudentManagementPageState
|
|||||||
final TextEditingController _addNameController = TextEditingController();
|
final TextEditingController _addNameController = TextEditingController();
|
||||||
final TextEditingController _addPwController = TextEditingController();
|
final TextEditingController _addPwController = TextEditingController();
|
||||||
int? _selectedGrade;
|
int? _selectedGrade;
|
||||||
|
bool _isDragging = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -63,6 +67,183 @@ class _TeacherStudentManagementPageState
|
|||||||
).showSnackBar(SnackBar(content: Text(message)));
|
).showSnackBar(SnackBar(content: Text(message)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📄 [파일 선택 버튼 동작] "파일 선택"으로 엑셀 고르기
|
||||||
|
Future<void> _pickExcelFile() async {
|
||||||
|
final bytes = await pickExcelFileBytes();
|
||||||
|
if (bytes == null) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
await _handleExcelBytes(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📄 엑셀 바이트를 파싱해서 미리보기 다이얼로그를 띄운다 (드래그/파일선택 공용).
|
||||||
|
Future<void> _handleExcelBytes(Uint8List bytes) async {
|
||||||
|
List<BulkStudentRow> rows;
|
||||||
|
try {
|
||||||
|
rows = _controller.parseExcelBytes(bytes);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('❌ 엑셀 파일을 읽는 데 실패했습니다: $e')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('⚠️ 유효한 학생 데이터를 찾지 못했습니다. (1행은 머리글로 건너뜁니다)'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showBulkPreviewDialog(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📋 [일괄 등록 확인 팝업] 엑셀에서 뽑아낸 명단을 미리 보여주고 확정받는다.
|
||||||
|
void _showBulkPreviewDialog(List<BulkStudentRow> rows) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
title: Text('📄 ${rows.length}명 확인됨'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'아래 명단으로 계정을 일괄 생성합니다 (초기 비밀번호: 1234).',
|
||||||
|
style: TextStyle(color: Colors.black54, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 300),
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: rows.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final row = rows[i];
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 14,
|
||||||
|
child: Text(
|
||||||
|
'${i + 1}',
|
||||||
|
style: const TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text('${row.name} (${row.studentId})'),
|
||||||
|
trailing: Text(
|
||||||
|
row.grade != null ? '${row.grade}학년' : '미배정',
|
||||||
|
style: TextStyle(color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_runBulkImport(rows);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text('${rows.length}명 일괄 등록'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 실제 일괄 등록 실행 + 진행 상황 표시 + 결과 요약 팝업.
|
||||||
|
Future<void> _runBulkImport(List<BulkStudentRow> rows) async {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => ListenableBuilder(
|
||||||
|
listenable: _controller,
|
||||||
|
builder: (context, _) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Text(
|
||||||
|
'등록 중... (${_controller.bulkDone}/${_controller.bulkTotal})',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await _controller.bulkAddStudents(rows);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context); // 진행 팝업 닫기
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
title: Text(
|
||||||
|
result.failures.isEmpty ? '✅ 일괄 등록 완료' : '⚠️ 일괄 등록 완료 (일부 실패)',
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'성공 ${result.successCount}명 / 실패 ${result.failures.length}명',
|
||||||
|
),
|
||||||
|
if (result.failures.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'실패 목록',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Text(
|
||||||
|
result.failures.join('\n'),
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('확인'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 🏫 [기존 학생 학년 지정/변경 다이얼로그]
|
// 🏫 [기존 학생 학년 지정/변경 다이얼로그]
|
||||||
void _showUpdateGradeDialog() {
|
void _showUpdateGradeDialog() {
|
||||||
final TextEditingController idController = TextEditingController();
|
final TextEditingController idController = TextEditingController();
|
||||||
@@ -377,6 +558,90 @@ class _TeacherStudentManagementPageState
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 36),
|
const SizedBox(height: 36),
|
||||||
|
|
||||||
|
// 🏷️ 인디케이터 바 (엑셀 일괄 등록)
|
||||||
|
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),
|
||||||
|
|
||||||
|
DropTarget(
|
||||||
|
onDragEntered: (_) => setState(() => _isDragging = true),
|
||||||
|
onDragExited: (_) => setState(() => _isDragging = false),
|
||||||
|
onDragDone: (details) async {
|
||||||
|
setState(() => _isDragging = false);
|
||||||
|
if (details.files.isEmpty) return;
|
||||||
|
final bytes = await details.files.first.readAsBytes();
|
||||||
|
if (!mounted) return;
|
||||||
|
await _handleExcelBytes(bytes);
|
||||||
|
},
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _pickExcelFile,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 28),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isDragging ? Colors.orange[50] : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: _isDragging
|
||||||
|
? Colors.orange
|
||||||
|
: Colors.grey.shade300,
|
||||||
|
width: _isDragging ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.upload_file_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: _isDragging
|
||||||
|
? Colors.orange
|
||||||
|
: Colors.grey[400],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_isDragging ? '여기에 놓으세요' : '엑셀 파일을 드래그하거나 눌러서 선택',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _isDragging
|
||||||
|
? Colors.orange[800]
|
||||||
|
: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'.xlsx · 1행은 머리글, A열=학번 B열=이름 C열=학년(선택)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[500],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 36),
|
||||||
|
|
||||||
// 🏷️ 인디케이터 바 (기존 학생 학년 지정/변경)
|
// 🏷️ 인디케이터 바 (기존 학생 학년 지정/변경)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <desktop_drop/desktop_drop_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) desktop_drop_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin");
|
||||||
|
desktop_drop_plugin_register_with_registrar(desktop_drop_registrar);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
desktop_drop
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -5,13 +5,17 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import desktop_drop
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
|
import file_picker_darwin
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
|||||||
+113
-1
@@ -9,6 +9,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.75"
|
version: "1.3.75"
|
||||||
|
android_file_picker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: android_file_picker
|
||||||
|
sha256: "1f111ed6bb33724ba782cc86357e3fd97f57051d55fc61adf33dcfc5049a1581"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.6.1"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -57,6 +73,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+5"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -73,6 +105,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.14"
|
version: "0.7.14"
|
||||||
|
desktop_drop:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: desktop_drop
|
||||||
|
sha256: c65d1f959ccf4bb6803f984303d03e1e0dd4c0f73a3a2eca2d1b46d48ea16999
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.2"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -89,6 +129,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.0"
|
version: "8.1.0"
|
||||||
|
equatable:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
excel:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: excel
|
||||||
|
sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.6"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -121,6 +177,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: file_picker
|
||||||
|
sha256: b7acb5d123cb398f6b4a8a38e43777545254f8fc1fabef3ee11cbbb56aece9c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.1.2"
|
||||||
|
file_picker_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_picker_darwin
|
||||||
|
sha256: "6e7cae501d48fd57a27911608db718ebd898e6ccf934bf09e33a8c0d0365bec0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.4"
|
||||||
|
file_picker_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_picker_linux
|
||||||
|
sha256: f0f01ed42967b7355f6f25c8b121ea531d1948e2a9b4b44f4b4de8489d7b04ae
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
|
file_picker_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_picker_platform_interface
|
||||||
|
sha256: "11ef1b5c14d9186b4788cc273dd8d5cb81bca847145060991a28234ff7916fc1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.0"
|
||||||
|
file_picker_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_picker_web
|
||||||
|
sha256: df472142f63c4557fdfbb375c81454ca1c251491069eefcdc6a866bc12f8750b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.3"
|
||||||
firebase_core:
|
firebase_core:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -517,6 +613,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
universal_platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: universal_platform
|
||||||
|
sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -550,7 +654,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
version: "15.2.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: web
|
name: web
|
||||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
@@ -573,6 +677,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
|
windows_file_picker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: windows_file_picker
|
||||||
|
sha256: "225f58e64c15c2d7b34fb8faf3ca188831967f26b5a723d7c3a7969e41c9b5a5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+4
-5
@@ -52,6 +52,10 @@ dependencies:
|
|||||||
|
|
||||||
# 📳 무단 반출 감지 시 진동 (백그라운드 서비스에서도 동작하도록 HapticFeedback 대신 사용)
|
# 📳 무단 반출 감지 시 진동 (백그라운드 서비스에서도 동작하도록 HapticFeedback 대신 사용)
|
||||||
vibration: ^3.2.0
|
vibration: ^3.2.0
|
||||||
|
excel: ^4.0.6
|
||||||
|
file_picker: ^12.1.2
|
||||||
|
desktop_drop: ^0.8.2
|
||||||
|
web: ^1.1.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -63,11 +67,6 @@ dev_dependencies:
|
|||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
|
||||||
|
|
||||||
# The following section is specific to Flutter packages.
|
|
||||||
flutter:
|
flutter:
|
||||||
|
|
||||||
# The following line ensures that the Material Icons font is
|
# The following line ensures that the Material Icons font is
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <desktop_drop/desktop_drop_plugin.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
DesktopDropPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
desktop_drop
|
||||||
firebase_core
|
firebase_core
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user