프로필 꾸미기 미리보기 테두리/체험 버튼 추가, 확인 버튼 엔터 지원, 비밀번호 홀드 보기 추가

- 프로필 꾸미기: 미리보기 중인 큰 타일의 테두리도 테마 색으로 바뀌게 수정,
  미구매 템플릿에 "체험하기" 버튼을 따로 둬서 사기 전에도 눌러볼 수 있게 함
- 앱 전반의 확인/삭제/저장 같은 다이얼로그 기본 액션 버튼에 autofocus를 줘서
  엔터 키로도 눌리게 함 (텍스트 입력이 있는 다이얼로그는 입력창 쪽에
  autofocus + onSubmitted를 달아 입력 중 바로 엔터로 제출되게 처리)
- HoldToRevealPasswordField 위젯 추가 - 눈 아이콘을 누르고 있는 동안만
  비밀번호가 평문으로 보임. 로그인/간편인증/초기 비밀번호 변경 화면에 적용
This commit is contained in:
2026-10-01 05:52:57 +00:00
parent 6b53214913
commit edf0444b71
15 changed files with 203 additions and 75 deletions
+6 -6
View File
@@ -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(
+1
View File
@@ -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,
),
+1
View File
@@ -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)),
),
+2
View File
@@ -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('닫기'),
),
+4
View File
@@ -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)),
),
+2
View File
@@ -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(
'나가기',
+3
View File
@@ -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(),
+67
View File
@@ -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
View File
@@ -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(
+9
View File
@@ -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',
+3
View File
@@ -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("확인"),
),
+39 -19
View File
@@ -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('구매하기'),
),
),
),
],
),
],
),
),
+1
View File
@@ -58,6 +58,7 @@ class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
),
actions: [
TextButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext),
child: const Text('닫기'),
),
+16
View File
@@ -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,