- 폰처럼 작은 화면에서 6칸 고정 격자 때문에 레이아웃이 깨지는 문제로, 타일 드래그 이동/크기조절 기능을 kTileEditingEnabled 플래그로 임시 비활성화 (관련 코드는 그대로 유지 - 작은 화면 대응 방안 마련 후 플래그만 켜면 재사용 가능) - 비활성화 상태에서는 화면 폭에 따라 열이 자동으로 줄어드는 기본 그리드 사용 - 설정 화면의 "UI 편집" 항목은 비활성화 표시로 안내 - 로그인 화면에 "로그인 상태 유지" 체크박스 추가 (기본 체크됨, 해제 시 세션 저장 안 함) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
473 lines
17 KiB
Dart
473 lines
17 KiB
Dart
// 🔑 로그인 화면 (UI 전용). 입력폼/다이얼로그/화면 이동만 담당한다.
|
|
// 인증 로직/서버 통신은 lib/function/login_controller.dart가 담당한다.
|
|
import 'package:flutter/material.dart';
|
|
import '../config.dart' show schoolName;
|
|
import '../function/login_controller.dart';
|
|
import '../function/session_store.dart';
|
|
import '../theme/app_palette.dart';
|
|
import 'main_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();
|
|
bool _keepLoggedIn = true;
|
|
|
|
@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:
|
|
if (_keepLoggedIn) {
|
|
await SessionStore.save(
|
|
SavedSession(
|
|
userId: result.studentId!,
|
|
userName: result.name!,
|
|
role: 'student',
|
|
isDeviceMatched: true,
|
|
),
|
|
);
|
|
} else {
|
|
await SessionStore.clear();
|
|
}
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('개발자 최고 권한으로 로그인되었습니다.')));
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => MainDashboard(
|
|
userId: result.studentId!,
|
|
userName: result.name!,
|
|
role: 'student',
|
|
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: AppPalette.ink,
|
|
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`) 수신 및 대시보드 전달
|
|
Future<void> _navigateBasedOnRole(
|
|
String role,
|
|
String studentId,
|
|
String name,
|
|
bool isDeviceMatched,
|
|
) async {
|
|
if (_keepLoggedIn) {
|
|
await SessionStore.save(
|
|
SavedSession(
|
|
userId: studentId,
|
|
userName: name,
|
|
role: role,
|
|
isDeviceMatched: isDeviceMatched,
|
|
),
|
|
);
|
|
} else {
|
|
await SessionStore.clear();
|
|
}
|
|
if (!mounted) return;
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => MainDashboard(
|
|
userId: studentId,
|
|
userName: name,
|
|
role: role,
|
|
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: AppPalette.mist,
|
|
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: AppPalette.ink),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'$schoolName 모니터',
|
|
style: const TextStyle(
|
|
fontSize: 26,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppPalette.ink,
|
|
),
|
|
),
|
|
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: 8),
|
|
InkWell(
|
|
onTap: () =>
|
|
setState(() => _keepLoggedIn = !_keepLoggedIn),
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Checkbox(
|
|
value: _keepLoggedIn,
|
|
activeColor: AppPalette.ink,
|
|
onChanged: (value) =>
|
|
setState(() => _keepLoggedIn = value ?? true),
|
|
),
|
|
const Text(
|
|
'로그인 상태 유지',
|
|
style: TextStyle(color: AppPalette.ink),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_controller.isLoading
|
|
? const CircularProgressIndicator()
|
|
: SizedBox(
|
|
width: double.infinity,
|
|
height: 55,
|
|
child: ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppPalette.ink,
|
|
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: AppPalette.ink,
|
|
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 MainDashboard(role: 'teacher'),
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
icon: const Icon(
|
|
Icons.settings,
|
|
size: 18,
|
|
color: Colors.grey,
|
|
),
|
|
label: const Text(
|
|
'관리자 간편인증',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
onPressed: () => _showLegacyPasswordDialog(
|
|
'시스템 관리자',
|
|
'4936',
|
|
const MainDashboard(role: 'admin'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|