프로필 꾸미기 미리보기 테두리/체험 버튼 추가, 확인 버튼 엔터 지원, 비밀번호 홀드 보기 추가
- 프로필 꾸미기: 미리보기 중인 큰 타일의 테두리도 테마 색으로 바뀌게 수정, 미구매 템플릿에 "체험하기" 버튼을 따로 둬서 사기 전에도 눌러볼 수 있게 함 - 앱 전반의 확인/삭제/저장 같은 다이얼로그 기본 액션 버튼에 autofocus를 줘서 엔터 키로도 눌리게 함 (텍스트 입력이 있는 다이얼로그는 입력창 쪽에 autofocus + onSubmitted를 달아 입력 중 바로 엔터로 제출되게 처리) - HoldToRevealPasswordField 위젯 추가 - 눈 아이콘을 누르고 있는 동안만 비밀번호가 평문으로 보임. 로그인/간편인증/초기 비밀번호 변경 화면에 적용
This commit is contained in:
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
|
||||
import '../config.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../ui/app_notice.dart';
|
||||
import '../ui/hold_to_reveal_password_field.dart';
|
||||
import '../ui/login_screen.dart';
|
||||
|
||||
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
|
||||
@@ -75,13 +76,12 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
HoldToRevealPasswordField(
|
||||
controller: _pwController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '새로운 비밀번호 입력',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
labelText: '새로운 비밀번호 입력',
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _updatePassword(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
|
||||
@@ -53,6 +53,7 @@ class _AdminDashboardState extends State<AdminDashboard> {
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.danger,
|
||||
),
|
||||
|
||||
@@ -139,6 +139,7 @@ class _BoardPostDetailScreenState extends State<BoardPostDetailScreen> {
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)),
|
||||
),
|
||||
|
||||
@@ -77,6 +77,7 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
@@ -208,6 +209,7 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
|
||||
@@ -47,7 +47,10 @@ class _FriendsScreenState extends State<FriendsScreen> {
|
||||
title: const Text('친구 추가'),
|
||||
content: TextField(
|
||||
controller: idController,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => Navigator.pop(dialogContext, true),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '친구 학번',
|
||||
border: OutlineInputBorder(),
|
||||
@@ -99,6 +102,7 @@ class _FriendsScreenState extends State<FriendsScreen> {
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)),
|
||||
),
|
||||
|
||||
@@ -158,6 +158,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
@@ -195,6 +196,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text(
|
||||
'나가기',
|
||||
|
||||
@@ -57,7 +57,10 @@ class _GroupListScreenState extends State<GroupListScreen> {
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
autofocus: true,
|
||||
maxLength: 20,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => Navigator.pop(dialogContext, true),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '그룹 이름',
|
||||
border: OutlineInputBorder(),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// 👁️ 비밀번호 입력창 - 오른쪽 눈 아이콘을 누르고 있는 동안만 평문으로 보여준다.
|
||||
// 토글형(눌러서 계속 보기) 대신 "홀드"로 만든 이유: 손을 떼는 순간 바로 다시 가려져서
|
||||
// 누가 옆에서 보더라도 짧게 확인만 하고 끝낼 수 있다.
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class HoldToRevealPasswordField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String labelText;
|
||||
final String? hintText;
|
||||
final IconData prefixIcon;
|
||||
final bool autofocus;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
|
||||
const HoldToRevealPasswordField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.labelText,
|
||||
this.hintText,
|
||||
this.prefixIcon = Icons.lock,
|
||||
this.autofocus = false,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.onSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HoldToRevealPasswordField> createState() =>
|
||||
_HoldToRevealPasswordFieldState();
|
||||
}
|
||||
|
||||
class _HoldToRevealPasswordFieldState extends State<HoldToRevealPasswordField> {
|
||||
bool _revealed = false;
|
||||
|
||||
void _setRevealed(bool value) {
|
||||
if (_revealed != value) setState(() => _revealed = value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: widget.controller,
|
||||
obscureText: !_revealed,
|
||||
autofocus: widget.autofocus,
|
||||
keyboardType: widget.keyboardType,
|
||||
textInputAction: widget.textInputAction,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.labelText,
|
||||
hintText: widget.hintText,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: Icon(widget.prefixIcon),
|
||||
suffixIcon: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => _setRevealed(true),
|
||||
onTapUp: (_) => _setRevealed(false),
|
||||
onTapCancel: () => _setRevealed(false),
|
||||
child: Icon(
|
||||
_revealed ? Icons.visibility_rounded : Icons.visibility_off_rounded,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+45
-50
@@ -6,6 +6,7 @@ import '../function/login_controller.dart';
|
||||
import '../function/session_store.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import 'app_notice.dart';
|
||||
import 'hold_to_reveal_password_field.dart';
|
||||
import 'main_dashboard.dart';
|
||||
import 'teacher_register_screen.dart';
|
||||
|
||||
@@ -112,6 +113,36 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
Future<void> submit() async {
|
||||
String newPassword = newPwController.text.trim();
|
||||
if (newPassword.isEmpty) {
|
||||
AppNotice.show(context, '새 비밀번호를 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogState(() => isUpdating = true);
|
||||
|
||||
final (success, message) = await _controller.changePassword(
|
||||
studentId: studentId,
|
||||
newPassword: newPassword,
|
||||
);
|
||||
setDialogState(() => isUpdating = false);
|
||||
|
||||
if (success) {
|
||||
Navigator.pop(dialogContext);
|
||||
AppNotice.show(context, message);
|
||||
_navigateBasedOnRole(
|
||||
role,
|
||||
studentId,
|
||||
name,
|
||||
isDeviceMatched,
|
||||
grade,
|
||||
);
|
||||
} else {
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'초기 비밀번호 변경',
|
||||
@@ -125,14 +156,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
style: TextStyle(color: AppPalette.danger),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
HoldToRevealPasswordField(
|
||||
controller: newPwController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '새 비밀번호',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.lock_reset),
|
||||
),
|
||||
autofocus: true,
|
||||
labelText: '새 비밀번호',
|
||||
prefixIcon: Icons.lock_reset,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -147,36 +177,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () async {
|
||||
String newPassword = newPwController.text.trim();
|
||||
if (newPassword.isEmpty) {
|
||||
AppNotice.show(context, '새 비밀번호를 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogState(() => isUpdating = true);
|
||||
|
||||
final (success, message) = await _controller
|
||||
.changePassword(
|
||||
studentId: studentId,
|
||||
newPassword: newPassword,
|
||||
);
|
||||
setDialogState(() => isUpdating = false);
|
||||
|
||||
if (success) {
|
||||
Navigator.pop(dialogContext);
|
||||
AppNotice.show(context, message);
|
||||
_navigateBasedOnRole(
|
||||
role,
|
||||
studentId,
|
||||
name,
|
||||
isDeviceMatched,
|
||||
grade,
|
||||
);
|
||||
} else {
|
||||
AppNotice.show(context, message);
|
||||
}
|
||||
},
|
||||
onPressed: submit,
|
||||
child: const Text('변경하고 시작하기'),
|
||||
),
|
||||
],
|
||||
@@ -234,6 +235,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
@@ -254,9 +256,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text('$title 권한 인증 (기존 방식)'),
|
||||
content: TextField(
|
||||
content: HoldToRevealPasswordField(
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
labelText: '비밀번호',
|
||||
hintText: '비밀번호 4자리를 입력하세요',
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (value) {
|
||||
@@ -270,10 +274,6 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
|
||||
}
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
hintText: '비밀번호 4자리를 입력하세요',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -341,16 +341,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
HoldToRevealPasswordField(
|
||||
controller: _pwController,
|
||||
obscureText: true,
|
||||
labelText: '비밀번호',
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _login(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '비밀번호',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.lock),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
InkWell(
|
||||
|
||||
@@ -157,6 +157,15 @@ class _MainDashboardState extends State<MainDashboard> {
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: idController,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (value) {
|
||||
final inputId = value.trim();
|
||||
if (inputId.isNotEmpty) {
|
||||
Navigator.pop(context);
|
||||
_deleteUser(inputId);
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: '학번 또는 교직원 번호',
|
||||
hintText: '예: 201101 또는 T1001',
|
||||
|
||||
@@ -86,6 +86,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("확인"),
|
||||
),
|
||||
@@ -114,6 +115,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_controller.beginIosProximityWatch(pocketNumber);
|
||||
@@ -139,6 +141,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("확인"),
|
||||
),
|
||||
|
||||
@@ -295,7 +295,10 @@ class _CategoryTile extends StatelessWidget {
|
||||
)
|
||||
: null,
|
||||
border: Border.all(
|
||||
color: selected ? AppPalette.ink : AppPalette.sage,
|
||||
color: hasPreview
|
||||
? previewSecondary!
|
||||
: (selected ? AppPalette.ink : AppPalette.sage),
|
||||
width: hasPreview ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -517,23 +520,38 @@ class _TemplateTile extends StatelessWidget {
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: template.equipped
|
||||
? OutlinedButton(
|
||||
onPressed: null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppPalette.approved,
|
||||
side: const BorderSide(color: AppPalette.approved),
|
||||
),
|
||||
child: const Text('장착중'),
|
||||
)
|
||||
: template.owned
|
||||
? OutlinedButton(
|
||||
onPressed: onEquip,
|
||||
child: const Text('장착하기'),
|
||||
)
|
||||
: ElevatedButton(
|
||||
if (template.equipped)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppPalette.approved,
|
||||
side: const BorderSide(color: AppPalette.approved),
|
||||
),
|
||||
child: const Text('장착중'),
|
||||
),
|
||||
)
|
||||
else if (template.owned)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: onEquip,
|
||||
child: const Text('장착하기'),
|
||||
),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onPreview,
|
||||
child: const Text('체험하기'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: canAfford ? onPurchase : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.ink,
|
||||
@@ -542,7 +560,9 @@ class _TemplateTile extends StatelessWidget {
|
||||
),
|
||||
child: const Text('구매하기'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -58,6 +58,7 @@ class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
|
||||
@@ -75,6 +75,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_runAction(() => _controller.resetTestPermissions());
|
||||
@@ -98,7 +99,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
title: Text('$studentName 학생 반출 허용'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (value) {
|
||||
final minutes = int.tryParse(value.trim()) ?? 5;
|
||||
Navigator.pop(context);
|
||||
_runAction(() => _controller.allowRemoval(studentId, minutes));
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: '허용 시간 (분)',
|
||||
border: OutlineInputBorder(),
|
||||
@@ -139,7 +147,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (value) {
|
||||
final minutes = int.tryParse(value.trim()) ?? 10;
|
||||
Navigator.pop(context);
|
||||
_runAction(() => _controller.setPermissionWindow(minutes));
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: '지금부터 허용 시간 (분)',
|
||||
border: OutlineInputBorder(),
|
||||
@@ -180,6 +195,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_runAction(() => _controller.dismissAll());
|
||||
|
||||
@@ -150,6 +150,7 @@ class _TeacherStudentManagementPageState
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_runBulkImport(rows);
|
||||
@@ -236,6 +237,7 @@ class _TeacherStudentManagementPageState
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('확인'),
|
||||
),
|
||||
@@ -296,6 +298,7 @@ class _TeacherStudentManagementPageState
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.ink,
|
||||
foregroundColor: AppPalette.paper,
|
||||
@@ -341,6 +344,7 @@ class _TeacherStudentManagementPageState
|
||||
child: const Text('취소', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
autofocus: true,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppPalette.danger,
|
||||
foregroundColor: Colors.white,
|
||||
|
||||
Reference in New Issue
Block a user