542 lines
19 KiB
Dart
542 lines
19 KiB
Dart
// 🔑 로그인 화면. 학번/비밀번호 인증, 최초 로그인 비밀번호 변경, 권한(학생/교사/관리자)별 화면 분기를 담당한다.
|
|
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
import '../config.dart';
|
|
import 'student_dashboard.dart';
|
|
import 'teacher_dashboard.dart';
|
|
import 'admin_dashboard.dart';
|
|
import 'teacher_register_screen.dart';
|
|
|
|
// -------------------------------------------------------------
|
|
// 1. 로그인 화면 (LoginScreen)
|
|
// -------------------------------------------------------------
|
|
|
|
class LoginScreen extends StatefulWidget {
|
|
const LoginScreen({super.key});
|
|
|
|
@override
|
|
State<LoginScreen> createState() => _LoginScreenState();
|
|
}
|
|
|
|
class _LoginScreenState extends State<LoginScreen> {
|
|
final TextEditingController _idController = TextEditingController();
|
|
final TextEditingController _pwController = TextEditingController();
|
|
bool _isLoading = false;
|
|
|
|
Future<void> _login() async {
|
|
String currentInputId = _idController.text.trim();
|
|
String currentInputPw = _pwController.text.trim();
|
|
|
|
if (currentInputId.isEmpty || currentInputPw.isEmpty) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('⚠️ 학번과 비밀번호를 모두 입력해 주세요.')));
|
|
return;
|
|
}
|
|
|
|
// 🔥 [개발자 마스터 계정]
|
|
if (currentInputId == "2061") {
|
|
if (currentInputPw == "happy9642!") {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('👑 개발자 최고 권한으로 로그인되었습니다.')),
|
|
);
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const StudentDashboard(
|
|
studentId: "2061",
|
|
studentName: "훗춧가룻",
|
|
isDeviceMatched: true, // 🆕 [수정] 마스터 계정은 언제나 프리패스이므로 true 변경!
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
} else {
|
|
_showErrorDialog('개발자 계정의 마스터 비밀번호가 올바르지 않습니다.');
|
|
return;
|
|
}
|
|
}
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
String deviceUuid = await getDeviceUuid();
|
|
String fcmToken = "";
|
|
|
|
try {
|
|
fcmToken = kIsWeb
|
|
? await FirebaseMessaging.instance.getToken(
|
|
vapidKey: webVapidKey,
|
|
) ??
|
|
""
|
|
: await FirebaseMessaging.instance.getToken() ?? "";
|
|
print("발급된 FCM 토큰: $fcmToken");
|
|
} catch (e) {
|
|
print("FCM 토큰 가져오기 실패 (파이어베이스 미설정): $e");
|
|
}
|
|
|
|
final response = await http.post(
|
|
Uri.parse('$baseUrl/login'),
|
|
headers: {"Content-Type": "application/json"},
|
|
body: jsonEncode({
|
|
"studentId": currentInputId,
|
|
"password": currentInputPw,
|
|
"deviceUuid": deviceUuid,
|
|
"fcmToken": fcmToken,
|
|
}),
|
|
);
|
|
|
|
final resData = jsonDecode(utf8.decode(response.bodyBytes));
|
|
|
|
if (response.statusCode == 200) {
|
|
print("🚨 서버 응답 데이터: $resData");
|
|
|
|
String role = '';
|
|
String name = '';
|
|
String studentId = '';
|
|
int isFirstLogin = 0;
|
|
|
|
var userObj = resData['user'];
|
|
if (userObj != null) {
|
|
role = userObj['role']?.toString() ?? '';
|
|
name =
|
|
userObj['name']?.toString() ??
|
|
userObj['studentName']?.toString() ??
|
|
userObj['student_name']?.toString() ??
|
|
'';
|
|
studentId =
|
|
userObj['studentId']?.toString() ??
|
|
userObj['student_id']?.toString() ??
|
|
currentInputId;
|
|
|
|
var rawFirst = userObj['isFirstLogin'] ?? userObj['is_first_login'];
|
|
isFirstLogin = (rawFirst is bool)
|
|
? (rawFirst ? 1 : 0)
|
|
: (int.tryParse(rawFirst.toString()) ?? 0);
|
|
} else {
|
|
role = resData['role']?.toString() ?? '';
|
|
name =
|
|
resData['name']?.toString() ??
|
|
resData['studentName']?.toString() ??
|
|
'';
|
|
studentId = resData['studentId']?.toString() ?? currentInputId;
|
|
|
|
var rawFirst = resData['isFirstLogin'] ?? resData['is_first_login'];
|
|
isFirstLogin = (rawFirst is bool)
|
|
? (rawFirst ? 1 : 0)
|
|
: (int.tryParse(rawFirst.toString()) ?? 0);
|
|
}
|
|
|
|
if (name.trim().isEmpty) {
|
|
name = "알수없음";
|
|
}
|
|
|
|
// 🆕 [추가] 서버 응답 데이터에서 기기 일치 여부 추출하기
|
|
// (서버가 주는 Key 이름에 맞춰서 데이터를 가져옵니다. 아래 항목 중 맞는 게 알아서 들어감)
|
|
bool isDeviceMatched =
|
|
resData['isDeviceMatched'] ??
|
|
resData['isUuidMatched'] ??
|
|
resData['is_matched'] ??
|
|
(userObj != null
|
|
? (userObj['isDeviceMatched'] ?? userObj['is_matched'] ?? false)
|
|
: false);
|
|
|
|
if (isFirstLogin == 1) {
|
|
_showFirstLoginPasswordDialog(
|
|
studentId,
|
|
name,
|
|
role,
|
|
isDeviceMatched,
|
|
); // 🆕 매개변수 추가
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('✅ $name님 환영합니다!')));
|
|
_navigateBasedOnRole(
|
|
role,
|
|
studentId,
|
|
name,
|
|
isDeviceMatched,
|
|
); // 🆕 [수정] 기기 인증 결과 전달
|
|
} else {
|
|
String errorMsg = '로그인에 실패했습니다.';
|
|
if (resData is Map) {
|
|
errorMsg =
|
|
resData['detail']?.toString() ??
|
|
resData['message']?.toString() ??
|
|
errorMsg;
|
|
}
|
|
_showErrorDialog(errorMsg);
|
|
}
|
|
} catch (e) {
|
|
_showErrorDialog('서버와 연결할 수 없습니다. 서버 상태를 확인하세요!\n($e)');
|
|
} finally {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
// 🛠️ 초기 비밀번호 변경 팝업창 (기기 매칭 데이터 파라미터 추가)
|
|
void _showFirstLoginPasswordDialog(
|
|
String studentId,
|
|
String name,
|
|
String role,
|
|
bool isDeviceMatched, // 🆕 추가
|
|
) {
|
|
final TextEditingController newPwController = TextEditingController();
|
|
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (BuildContext dialogContext) {
|
|
bool isUpdating = false;
|
|
|
|
return StatefulBuilder(
|
|
builder: (context, setDialogState) {
|
|
return AlertDialog(
|
|
title: const Text(
|
|
'🔒 초기 비밀번호 변경',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text(
|
|
'보안을 위해 새로운 비밀번호를 설정해 주세요.',
|
|
style: TextStyle(color: Colors.redAccent),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: newPwController,
|
|
obscureText: true,
|
|
decoration: const InputDecoration(
|
|
labelText: '새 비밀번호',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.lock_reset),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
isUpdating
|
|
? const Padding(
|
|
padding: EdgeInsets.only(right: 20.0),
|
|
child: CircularProgressIndicator(),
|
|
)
|
|
: ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.indigo,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
onPressed: () async {
|
|
String newPassword = newPwController.text.trim();
|
|
if (newPassword.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('⚠️ 새 비밀번호를 입력해 주세요.'),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setDialogState(() => isUpdating = true);
|
|
|
|
try {
|
|
final response = await http.post(
|
|
Uri.parse('$baseUrl/api/users/change-password'),
|
|
headers: {"Content-Type": "application/json"},
|
|
body: jsonEncode({
|
|
"studentId": studentId,
|
|
"newPassword": newPassword,
|
|
}),
|
|
);
|
|
|
|
final resData = jsonDecode(
|
|
utf8.decode(response.bodyBytes),
|
|
);
|
|
setDialogState(() => isUpdating = false);
|
|
|
|
if (response.statusCode == 200 &&
|
|
resData['status'] == 'success') {
|
|
Navigator.pop(dialogContext);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('✅ 비밀번호가 변경되었습니다!'),
|
|
),
|
|
);
|
|
_navigateBasedOnRole(
|
|
role,
|
|
studentId,
|
|
name,
|
|
isDeviceMatched,
|
|
); // 🆕 수정
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('❌ 실패: ${resData['message']}'),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
setDialogState(() => isUpdating = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('서버 에러 발생: $e')),
|
|
);
|
|
}
|
|
},
|
|
child: const Text('변경하고 시작하기'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// 🆕 [수정] 권한별 화면 이동시 기기 일치 여부 파라미터(`isDeviceMatched`) 수신 및 대시보드 전달
|
|
void _navigateBasedOnRole(
|
|
String role,
|
|
String studentId,
|
|
String name,
|
|
bool isDeviceMatched,
|
|
) {
|
|
if (role == 'teacher') {
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const TeacherDashboard()),
|
|
);
|
|
} else if (role == 'admin') {
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const AdminDashboard()),
|
|
);
|
|
} else {
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => StudentDashboard(
|
|
studentId: studentId,
|
|
studentName: name,
|
|
isDeviceMatched:
|
|
isDeviceMatched, // 🆕 [수정] 더이상 false 고정이 아니라 서버 판단 결과 전송!
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _showErrorDialog(String message) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text(
|
|
'⚠️ 인증 실패',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
content: Text(message),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: const Text('확인'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showLegacyPasswordDialog(
|
|
String title,
|
|
String correctPassword,
|
|
Widget nextPage,
|
|
) {
|
|
final TextEditingController passwordController = TextEditingController();
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (BuildContext dialogContext) {
|
|
return AlertDialog(
|
|
title: Text('🔒 $title 권한 인증 (기존 방식)'),
|
|
content: TextField(
|
|
controller: passwordController,
|
|
obscureText: true,
|
|
keyboardType: TextInputType.number,
|
|
textInputAction: TextInputAction.done,
|
|
onSubmitted: (value) {
|
|
if (value == correctPassword) {
|
|
Navigator.pop(dialogContext);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => nextPage),
|
|
);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')),
|
|
);
|
|
}
|
|
},
|
|
decoration: const InputDecoration(
|
|
hintText: '비밀번호 4자리를 입력하세요',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
if (passwordController.text == correctPassword) {
|
|
Navigator.pop(dialogContext);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => nextPage),
|
|
);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('❌ 비밀번호가 올바르지 않습니다.')),
|
|
);
|
|
}
|
|
},
|
|
child: const Text('인증하기'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.grey[50],
|
|
body: Center(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.school, size: 80, color: Colors.indigo),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'백산고등학교 모니터',
|
|
style: TextStyle(
|
|
fontSize: 26,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.indigo,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'학생 편의 및 학교생활 도우미',
|
|
style: TextStyle(color: Colors.grey, fontSize: 15),
|
|
),
|
|
const SizedBox(height: 40),
|
|
TextField(
|
|
controller: _idController,
|
|
textInputAction: TextInputAction.next,
|
|
decoration: const InputDecoration(
|
|
labelText: '학번 또는 교직원 번호',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.person),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _pwController,
|
|
obscureText: true,
|
|
textInputAction: TextInputAction.done,
|
|
onSubmitted: (_) => _login(),
|
|
decoration: const InputDecoration(
|
|
labelText: '비밀번호',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.lock),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
_isLoading
|
|
? const CircularProgressIndicator()
|
|
: SizedBox(
|
|
width: double.infinity,
|
|
height: 55,
|
|
child: ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.indigo,
|
|
foregroundColor: Colors.white,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
onPressed: _login,
|
|
child: const Text(
|
|
'로그인',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
TextButton(
|
|
onPressed: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const TeacherRegisterScreen(),
|
|
),
|
|
),
|
|
child: const Text(
|
|
'👨🏫 선생님이신가요? 교사 회원가입 하기',
|
|
style: TextStyle(
|
|
color: Colors.indigo,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
const Divider(height: 40),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.gavel, size: 18, color: Colors.grey),
|
|
label: const Text(
|
|
'교사 간편인증',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
onPressed: () => _showLegacyPasswordDialog(
|
|
'선생님',
|
|
'1234',
|
|
const TeacherDashboard(),
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
icon: const Icon(
|
|
Icons.settings,
|
|
size: 18,
|
|
color: Colors.grey,
|
|
),
|
|
label: const Text(
|
|
'관리자 간편인증',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
onPressed: () => _showLegacyPasswordDialog(
|
|
'시스템 관리자',
|
|
'4936',
|
|
const AdminDashboard(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|