lib/ 코드를 UI(lib/ui/)와 기능(lib/function/)으로 분리
- 화면마다 위젯/스타일만 담당하는 UI 파일과, 서버통신/상태/파생로직만 담당하는 ChangeNotifier 컨트롤러 파일로 1:1 분리 (lib/screens/ -> lib/ui/ + lib/function/) - UI는 ListenableBuilder로 컨트롤러를 구독해서 재렌더링, 버튼은 컨트롤러 메서드만 호출 - 다이얼로그/스낵바 등 위젯 코드는 전부 UI 파일에 남기고, 컨트롤러는 결과값(성공여부+메시지) 또는 콜백으로만 UI와 통신 (BuildContext/Widget 의존성 없음) - 디자인/레이아웃은 기존과 완전히 동일하게 유지 (순수 코드 재배치) - change_password_screen.dart, debug_pocket_main.dart(미사용 파일)는 깨지지 않게 import 경로만 갱신하고 리팩터링은 보류
This commit is contained in:
@@ -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<AdminDashboard> createState() => _AdminDashboardState();
|
||||
}
|
||||
|
||||
class _AdminDashboardState extends State<AdminDashboard> {
|
||||
final AdminController _controller = AdminController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final LoginController _controller = LoginController();
|
||||
final TextEditingController _idController = TextEditingController();
|
||||
final TextEditingController _pwController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_idController.dispose();
|
||||
_pwController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<NfcPocketCheckInScreen> createState() => _NfcPocketCheckInScreenState();
|
||||
}
|
||||
|
||||
class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<NfcTagWriterScreen> createState() => _NfcTagWriterScreenState();
|
||||
}
|
||||
|
||||
class _NfcTagWriterScreenState extends State<NfcTagWriterScreen> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<StudentDashboard> createState() => _StudentDashboardState();
|
||||
}
|
||||
|
||||
class _StudentDashboardState extends State<StudentDashboard> {
|
||||
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<void> _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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<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']),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
// 📋 실시간 출석 현황 화면 (UI 전용). 위젯 빌드/레이아웃/스타일만 담당하고,
|
||||
// 서버 통신·상태·파생 로직은 lib/function/teacher_attendance_controller.dart가 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import '../function/teacher_attendance_controller.dart';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 📅 [서브 화면 1] 실시간 출석 확인 란 (StudentDashboard 카드 스타일 리스트화)
|
||||
// -----------------------------------------------------------------------------
|
||||
class TeacherAttendancePage extends StatefulWidget {
|
||||
const TeacherAttendancePage({super.key});
|
||||
|
||||
@override
|
||||
State<TeacherAttendancePage> createState() => _TeacherAttendancePageState();
|
||||
}
|
||||
|
||||
class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
final TeacherAttendanceController _controller = TeacherAttendanceController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 컨트롤러 액션을 실행하고, 결과 메시지를 스낵바로 보여준다.
|
||||
Future<void> _runAction(Future<String> Function() action) async {
|
||||
final message = await action();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
Future<void> _showAttendanceTimeDialog() async {
|
||||
final String? current = _controller.attendanceTime;
|
||||
final TimeOfDay initial = current != null
|
||||
? TimeOfDay(
|
||||
hour: int.parse(current.split(':')[0]),
|
||||
minute: int.parse(current.split(':')[1]),
|
||||
)
|
||||
: const TimeOfDay(hour: 19, minute: 0);
|
||||
|
||||
final TimeOfDay? picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: initial,
|
||||
helpText: '자습실 출석시간 지정',
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
final String formatted =
|
||||
'${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}';
|
||||
await _runAction(() => _controller.setAttendanceTime(formatted));
|
||||
}
|
||||
|
||||
void _showTestResetConfirmDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('🧪 테스트용 허용시간 초기화'),
|
||||
content: const Text(
|
||||
'하교 처리나 반출 허용 시간 설정으로 켜져 있는 모든 허용 시간대를 지금 즉시 해제합니다.\n'
|
||||
'(무단반출 감지 테스트할 때만 사용하세요)',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_runAction(() => _controller.resetTestPermissions());
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.grey[700],
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAllowDialog(String studentId, String studentName) {
|
||||
final controller = TextEditingController(text: "5");
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('$studentName 학생 반출 허용'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '허용 시간 (분)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final minutes = int.tryParse(controller.text.trim()) ?? 5;
|
||||
Navigator.pop(context);
|
||||
_runAction(() => _controller.allowRemoval(studentId, minutes));
|
||||
},
|
||||
child: const Text('허용하기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPermissionWindowDialog() {
|
||||
final controller = TextEditingController(text: "10");
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('⏰ 전체 학생 반출 허용 시간 설정'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'쉬는시간처럼 지금부터 일정 시간 동안 모든 학생의 반출을 자동으로 허용합니다.',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '지금부터 허용 시간 (분)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final minutes = int.tryParse(controller.text.trim()) ?? 10;
|
||||
Navigator.pop(context);
|
||||
_runAction(() => _controller.setPermissionWindow(minutes));
|
||||
},
|
||||
child: const Text('설정하기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 🏫 하교 처리: 시간 입력 없이 바로 전체 학생의 반출을 (사실상 무기한) 허용한다.
|
||||
void _showDismissalConfirmDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('🏫 하교 처리'),
|
||||
content: const Text(
|
||||
'지금부터 모든 학생의 반출이 자동으로 허용되며, 더 이상 무단반출 경고가 뜨지 않습니다.\n하교 처리하시겠습니까?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_runAction(() => _controller.dismissAll());
|
||||
},
|
||||
child: const Text('하교 처리'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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: (_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),
|
||||
],
|
||||
),
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 📱 모바일 레이아웃 (기존 카드 리스트)
|
||||
// -----------------------------------------------------------------------
|
||||
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
|
||||
? const Center(
|
||||
child: Text(
|
||||
'해당하는 학생이 없습니다.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
itemCount: filteredStudents.length,
|
||||
itemBuilder: (context, 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 && _controller.attendanceTime != null;
|
||||
final Color statusColor = hasViolation
|
||||
? Colors.red
|
||||
: (isPending
|
||||
? Colors.orange
|
||||
: (isComplete ? Colors.blue : Colors.red));
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: hasViolation ? Colors.red[50] : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: hasViolation
|
||||
? Border.all(color: Colors.red[300]!, width: 1.5)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
hasViolation
|
||||
? Icons.warning_amber_rounded
|
||||
: (isPending
|
||||
? Icons.hourglass_bottom_rounded
|
||||
: (isComplete
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.error_rounded)),
|
||||
color: statusColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${student['studentName']} 학생',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
hasViolation
|
||||
? '🚨 무단반출 감지! (${student['violationTime']})'
|
||||
: (isPending
|
||||
? '학번: ${student['studentId']} | ⏳ 출석 미완료 (제출: ${student['checkInTime']})'
|
||||
: (isComplete
|
||||
? '학번: ${student['studentId']} | 제출시간: ${student['checkInTime']}'
|
||||
: (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])),
|
||||
fontSize: emphasizeTime ? 14 : 12,
|
||||
fontWeight: (hasViolation || emphasizeTime)
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isCheckedIn && student['pocketNumber'] != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.blue.shade200,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
student['pocketNumber'],
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blueAccent,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasViolation)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _showAllowDialog(
|
||||
student['studentId'],
|
||||
student['studentName'],
|
||||
),
|
||||
icon: const Icon(Icons.check, size: 18),
|
||||
label: const Text('반출 허용'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[600],
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 🖥️ 데스크톱 레이아웃 (선생님이 교실 컴퓨터 브라우저로 접속했을 때)
|
||||
// -----------------------------------------------------------------------
|
||||
Widget _buildDesktopBody(int total, int checkedIn, int absent) {
|
||||
final filteredStudents = _controller.filteredStudents;
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1100),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _statTile(
|
||||
"전체 학생",
|
||||
"$total명",
|
||||
Icons.groups_rounded,
|
||||
Colors.blueGrey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _statTile(
|
||||
"출석 완료",
|
||||
"$checkedIn명",
|
||||
Icons.check_circle_rounded,
|
||||
Colors.green,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _statTile(
|
||||
"미출석",
|
||||
"$absent명",
|
||||
Icons.error_rounded,
|
||||
Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPermissionBanner(),
|
||||
_buildFilterChips(),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: filteredStudents.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'해당하는 학생이 없습니다.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: DataTable(
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
Colors.grey[50],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('상태')),
|
||||
DataColumn(label: Text('학번')),
|
||||
DataColumn(label: Text('이름')),
|
||||
DataColumn(label: Text('제출 시간')),
|
||||
DataColumn(label: Text('주머니 번호')),
|
||||
DataColumn(label: Text('작업')),
|
||||
],
|
||||
rows: filteredStudents.map((student) {
|
||||
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 && _controller.attendanceTime != null;
|
||||
final Color statusColor = hasViolation
|
||||
? Colors.red
|
||||
: (isPending
|
||||
? Colors.orange
|
||||
: (isComplete ? Colors.blue : Colors.red));
|
||||
return DataRow(
|
||||
color: hasViolation
|
||||
? WidgetStateProperty.all(Colors.red[50])
|
||||
: (isPending
|
||||
? WidgetStateProperty.all(
|
||||
Colors.orange[50],
|
||||
)
|
||||
: null),
|
||||
cells: [
|
||||
DataCell(
|
||||
Icon(
|
||||
hasViolation
|
||||
? Icons.warning_amber_rounded
|
||||
: (isPending
|
||||
? Icons.hourglass_bottom_rounded
|
||||
: (isComplete
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.error_rounded)),
|
||||
color: statusColor,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
DataCell(Text('${student['studentId']}')),
|
||||
DataCell(
|
||||
Text(
|
||||
'${student['studentName']}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
hasViolation
|
||||
? '🚨 무단반출 (${student['violationTime']})'
|
||||
: (isPending
|
||||
? '⏳ 출석 미완료 (제출: ${student['checkInTime']})'
|
||||
: (isComplete
|
||||
? '${student['checkInTime']}'
|
||||
: (student['hasEverCheckedIn'] ==
|
||||
true
|
||||
? '미제출'
|
||||
: '미등록'))),
|
||||
style: TextStyle(
|
||||
color: hasViolation
|
||||
? Colors.red[700]
|
||||
: (isPending
|
||||
? Colors.orange[800]
|
||||
: (isComplete
|
||||
? Colors.grey[700]
|
||||
: Colors.red[400])),
|
||||
fontSize: emphasizeTime ? 15 : 14,
|
||||
fontWeight: (hasViolation || emphasizeTime)
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
isCheckedIn && student['pocketNumber'] != null
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius:
|
||||
BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.blue.shade200,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
student['pocketNumber'],
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blueAccent,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text('-'),
|
||||
),
|
||||
DataCell(
|
||||
hasViolation
|
||||
? ElevatedButton.icon(
|
||||
onPressed: () => _showAllowDialog(
|
||||
student['studentId'],
|
||||
student['studentName'],
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
),
|
||||
label: const Text('반출 허용'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[600],
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text('-'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statTile(String label, String value, IconData icon, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 26),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 📊 상단 요약 카드 뷰 (전체 / 출석 완료 / 미출석)
|
||||
Widget _buildSummaryCards(int total, int checkedIn, int absent) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: const Color(0xFF1E293B),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_summaryCard("전체", "$total명", Colors.white70),
|
||||
_summaryCard("출석 완료", "$checkedIn명", Colors.greenAccent),
|
||||
_summaryCard("미출석", "$absent명", Colors.redAccent),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryCard(String title, String count, Color color) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
count,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔓 하교(12시간)/반출 허용 시간 설정으로 지금 전체 반출이 허용 중이면 눈에 띄게 배너로 알려준다.
|
||||
/// (허용 중일 땐 무단반출을 감지해도 대시보드에 뜨지 않기 때문에, 왜 안 뜨는지 헷갈리지 않게 하기 위함)
|
||||
Widget _buildPermissionBanner() {
|
||||
final String? until = _controller.globalPermissionUntil;
|
||||
if (until == null) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber[100],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.amber[400]!),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.lock_open_rounded, color: Colors.amber[800], size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'🔓 지금 전체 반출 허용 중입니다 ($until 까지) — 이 시간 동안은 무단반출 경고가 뜨지 않아요.',
|
||||
style: TextStyle(
|
||||
color: Colors.amber[900],
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔘 필터 칩버튼 (전체 / 출석자 / 미출석자)
|
||||
Widget _buildFilterChips() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text("전체"),
|
||||
selected: _controller.filterType == "ALL",
|
||||
onSelected: (_) => _controller.setFilter("ALL"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
label: const Text("🟢 출석자"),
|
||||
selected: _controller.filterType == "CHECKED_IN",
|
||||
onSelected: (_) => _controller.setFilter("CHECKED_IN"),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
label: const Text("🔴 미출석자"),
|
||||
selected: _controller.filterType == "ABSENT",
|
||||
onSelected: (_) => _controller.setFilter("ABSENT"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// 👨🏫 교사 대시보드. 실시간 출석 확인, 학생 계정 관리 화면으로 가는 메뉴만 담당한다.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'login_screen.dart';
|
||||
import 'teacher_attendance_page.dart';
|
||||
import 'teacher_student_management_page.dart';
|
||||
|
||||
// ==========================================
|
||||
// 👨🏫 4. 선생님 대시보드 (기존 3초 타이머 완벽 유지)
|
||||
// ==========================================
|
||||
class TeacherDashboard extends StatefulWidget {
|
||||
final String? teacherId;
|
||||
final String? teacherName;
|
||||
|
||||
const TeacherDashboard({super.key, this.teacherId, this.teacherName});
|
||||
|
||||
@override
|
||||
State<TeacherDashboard> createState() => _TeacherDashboardState();
|
||||
}
|
||||
|
||||
class _TeacherDashboardState extends State<TeacherDashboard> {
|
||||
// 🧼 [수정] 사용하지 않던 _isLoading 변수를 삭제하여 경고를 완벽히 해결했습니다!
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 다른 화면에서 null이 넘어왔을 때를 대비한 안전망 방탄 코드
|
||||
final String displayName = widget.teacherName ?? "간편인증 선생";
|
||||
final String displayId = widget.teacherId ?? "간편인증";
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[100],
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'👨🏫 TEACHER PORTAL',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2),
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: Colors.green[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: [
|
||||
// 💳 [상단 배너 가이드] StudentDashboard와 100% 일치하는 프로필 카드 레이아웃
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green[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: Colors.green[50],
|
||||
child: Icon(
|
||||
Icons.admin_panel_settings_rounded,
|
||||
size: 32,
|
||||
color: Colors.green[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'$displayName 님',
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'교직원 번호: $displayId | 교사 권한 활성화됨',
|
||||
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: Colors.green[700],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'스마트 교사용 관리 메뉴',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 📊 [그리드 레이아웃 메뉴] 시후의 카드 컴포넌트 스타일 적용
|
||||
// 🖥️ 데스크톱 브라우저에서 카드가 지나치게 커지지 않도록 최대 너비를 제한한다.
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 700),
|
||||
child: 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: [
|
||||
_buildModernCard(
|
||||
icon: Icons.assignment_turned_in_rounded,
|
||||
title: '실시간 출석 확인',
|
||||
subtitle: '학생 제출 로그 모니터링',
|
||||
color: Colors.blue,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const TeacherAttendancePage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildModernCard(
|
||||
icon: Icons.manage_accounts_rounded,
|
||||
title: '학생 계정 관리',
|
||||
subtitle: '계정 추가 및 강제 리셋',
|
||||
color: Colors.orange,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
const TeacherStudentManagementPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 💎 [시후 대시보드 전용 카드 위젯 이식 완료]
|
||||
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: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<TeacherRegisterScreen> createState() => _TeacherRegisterScreenState();
|
||||
}
|
||||
|
||||
class _TeacherRegisterScreenState extends State<TeacherRegisterScreen> {
|
||||
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<void> _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('교사 계정 생성하기'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<TeacherStudentManagementPage> createState() =>
|
||||
_TeacherStudentManagementPageState();
|
||||
}
|
||||
|
||||
class _TeacherStudentManagementPageState
|
||||
extends State<TeacherStudentManagementPage> {
|
||||
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<void> _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<void> _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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user