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

- 프로필 꾸미기: 미리보기 중인 큰 타일의 테두리도 테마 색으로 바뀌게 수정,
  미구매 템플릿에 "체험하기" 버튼을 따로 둬서 사기 전에도 눌러볼 수 있게 함
- 앱 전반의 확인/삭제/저장 같은 다이얼로그 기본 액션 버튼에 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 '../config.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import '../ui/app_notice.dart'; import '../ui/app_notice.dart';
import '../ui/hold_to_reveal_password_field.dart';
import '../ui/login_screen.dart'; import '../ui/login_screen.dart';
// 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요. // 💡 main.dart 파일의 최하단(다른 클래스 중괄호 밖)에 붙여넣으세요.
@@ -75,13 +76,12 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
style: TextStyle(color: Colors.grey), style: TextStyle(color: Colors.grey),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
TextField( HoldToRevealPasswordField(
controller: _pwController, controller: _pwController,
obscureText: true, autofocus: true,
decoration: const InputDecoration( labelText: '새로운 비밀번호 입력',
labelText: '새로운 비밀번호 입력', textInputAction: TextInputAction.done,
border: OutlineInputBorder(), onSubmitted: (_) => _updatePassword(),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( SizedBox(
+1
View File
@@ -53,6 +53,7 @@ class _AdminDashboardState extends State<AdminDashboard> {
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.danger, backgroundColor: AppPalette.danger,
), ),
+1
View File
@@ -139,6 +139,7 @@ class _BoardPostDetailScreenState extends State<BoardPostDetailScreen> {
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)), 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)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
@@ -208,6 +209,7 @@ class _DeviceCheckoutLedgerPageState extends State<DeviceCheckoutLedgerPage> {
), ),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
child: const Text('닫기'), child: const Text('닫기'),
), ),
+4
View File
@@ -47,7 +47,10 @@ class _FriendsScreenState extends State<FriendsScreen> {
title: const Text('친구 추가'), title: const Text('친구 추가'),
content: TextField( content: TextField(
controller: idController, controller: idController,
autofocus: true,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
onSubmitted: (_) => Navigator.pop(dialogContext, true),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: '친구 학번', labelText: '친구 학번',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -99,6 +102,7 @@ class _FriendsScreenState extends State<FriendsScreen> {
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('삭제', style: TextStyle(color: AppPalette.danger)), 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)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
@@ -195,6 +196,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
child: const Text( child: const Text(
'나가기', '나가기',
+3
View File
@@ -57,7 +57,10 @@ class _GroupListScreenState extends State<GroupListScreen> {
children: [ children: [
TextField( TextField(
controller: nameController, controller: nameController,
autofocus: true,
maxLength: 20, maxLength: 20,
textInputAction: TextInputAction.done,
onSubmitted: (_) => Navigator.pop(dialogContext, true),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: '그룹 이름', labelText: '그룹 이름',
border: OutlineInputBorder(), 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 '../function/session_store.dart';
import '../theme/app_palette.dart'; import '../theme/app_palette.dart';
import 'app_notice.dart'; import 'app_notice.dart';
import 'hold_to_reveal_password_field.dart';
import 'main_dashboard.dart'; import 'main_dashboard.dart';
import 'teacher_register_screen.dart'; import 'teacher_register_screen.dart';
@@ -112,6 +113,36 @@ class _LoginScreenState extends State<LoginScreen> {
return StatefulBuilder( return StatefulBuilder(
builder: (context, setDialogState) { 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( return AlertDialog(
title: const Text( title: const Text(
'초기 비밀번호 변경', '초기 비밀번호 변경',
@@ -125,14 +156,13 @@ class _LoginScreenState extends State<LoginScreen> {
style: TextStyle(color: AppPalette.danger), style: TextStyle(color: AppPalette.danger),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( HoldToRevealPasswordField(
controller: newPwController, controller: newPwController,
obscureText: true, autofocus: true,
decoration: const InputDecoration( labelText: '새 비밀번호',
labelText: '새 비밀번호', prefixIcon: Icons.lock_reset,
border: OutlineInputBorder(), textInputAction: TextInputAction.done,
prefixIcon: Icon(Icons.lock_reset), onSubmitted: (_) => submit(),
),
), ),
], ],
), ),
@@ -147,36 +177,7 @@ class _LoginScreenState extends State<LoginScreen> {
backgroundColor: AppPalette.ink, backgroundColor: AppPalette.ink,
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
onPressed: () async { onPressed: submit,
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);
}
},
child: const Text('변경하고 시작하기'), child: const Text('변경하고 시작하기'),
), ),
], ],
@@ -234,6 +235,7 @@ class _LoginScreenState extends State<LoginScreen> {
content: Text(message), content: Text(message),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
child: const Text('확인'), child: const Text('확인'),
), ),
@@ -254,9 +256,11 @@ class _LoginScreenState extends State<LoginScreen> {
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: Text('$title 권한 인증 (기존 방식)'), title: Text('$title 권한 인증 (기존 방식)'),
content: TextField( content: HoldToRevealPasswordField(
controller: passwordController, controller: passwordController,
obscureText: true, autofocus: true,
labelText: '비밀번호',
hintText: '비밀번호 4자리를 입력하세요',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onSubmitted: (value) { onSubmitted: (value) {
@@ -270,10 +274,6 @@ class _LoginScreenState extends State<LoginScreen> {
AppNotice.show(context, '비밀번호가 올바르지 않습니다.'); AppNotice.show(context, '비밀번호가 올바르지 않습니다.');
} }
}, },
decoration: const InputDecoration(
hintText: '비밀번호 4자리를 입력하세요',
border: OutlineInputBorder(),
),
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -341,16 +341,11 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( HoldToRevealPasswordField(
controller: _pwController, controller: _pwController,
obscureText: true, labelText: '비밀번호',
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onSubmitted: (_) => _login(), onSubmitted: (_) => _login(),
decoration: const InputDecoration(
labelText: '비밀번호',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
InkWell( InkWell(
+9
View File
@@ -157,6 +157,15 @@ class _MainDashboardState extends State<MainDashboard> {
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: idController, controller: idController,
autofocus: true,
textInputAction: TextInputAction.done,
onSubmitted: (value) {
final inputId = value.trim();
if (inputId.isNotEmpty) {
Navigator.pop(context);
_deleteUser(inputId);
}
},
decoration: InputDecoration( decoration: InputDecoration(
labelText: '학번 또는 교직원 번호', labelText: '학번 또는 교직원 번호',
hintText: '예: 201101 또는 T1001', hintText: '예: 201101 또는 T1001',
+3
View File
@@ -86,6 +86,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text("확인"), child: const Text("확인"),
), ),
@@ -114,6 +115,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
_controller.beginIosProximityWatch(pocketNumber); _controller.beginIosProximityWatch(pocketNumber);
@@ -139,6 +141,7 @@ class _NfcPocketCheckInScreenState extends State<NfcPocketCheckInScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text("확인"), child: const Text("확인"),
), ),
+39 -19
View File
@@ -295,7 +295,10 @@ class _CategoryTile extends StatelessWidget {
) )
: null, : null,
border: Border.all( border: Border.all(
color: selected ? AppPalette.ink : AppPalette.sage, color: hasPreview
? previewSecondary!
: (selected ? AppPalette.ink : AppPalette.sage),
width: hasPreview ? 2 : 1,
), ),
), ),
child: Column( child: Column(
@@ -517,23 +520,38 @@ class _TemplateTile extends StatelessWidget {
style: TextStyle(fontSize: 12, color: Colors.grey[600]), style: TextStyle(fontSize: 12, color: Colors.grey[600]),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( if (template.equipped)
width: double.infinity, SizedBox(
child: template.equipped width: double.infinity,
? OutlinedButton( child: OutlinedButton(
onPressed: null, onPressed: null,
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: AppPalette.approved, foregroundColor: AppPalette.approved,
side: const BorderSide(color: AppPalette.approved), side: const BorderSide(color: AppPalette.approved),
), ),
child: const Text('장착중'), child: const Text('장착중'),
) ),
: template.owned )
? OutlinedButton( else if (template.owned)
onPressed: onEquip, SizedBox(
child: const Text('장착하기'), width: double.infinity,
) child: OutlinedButton(
: ElevatedButton( 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, onPressed: canAfford ? onPurchase : null,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: AppPalette.ink,
@@ -542,7 +560,9 @@ class _TemplateTile extends StatelessWidget {
), ),
child: const Text('구매하기'), child: const Text('구매하기'),
), ),
), ),
],
),
], ],
), ),
), ),
+1
View File
@@ -58,6 +58,7 @@ class _StudyCalendarScreenState extends State<StudyCalendarScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
child: const Text('닫기'), child: const Text('닫기'),
), ),
+16
View File
@@ -75,6 +75,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
child: const Text('취소'), child: const Text('취소'),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
_runAction(() => _controller.resetTestPermissions()); _runAction(() => _controller.resetTestPermissions());
@@ -98,7 +99,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
title: Text('$studentName 학생 반출 허용'), title: Text('$studentName 학생 반출 허용'),
content: TextField( content: TextField(
controller: controller, controller: controller,
autofocus: true,
keyboardType: TextInputType.number, 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( decoration: const InputDecoration(
labelText: '허용 시간 (분)', labelText: '허용 시간 (분)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -139,7 +147,14 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: controller, controller: controller,
autofocus: true,
keyboardType: TextInputType.number, 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( decoration: const InputDecoration(
labelText: '지금부터 허용 시간 (분)', labelText: '지금부터 허용 시간 (분)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -180,6 +195,7 @@ class _TeacherAttendancePageState extends State<TeacherAttendancePage> {
child: const Text('취소'), child: const Text('취소'),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
_runAction(() => _controller.dismissAll()); _runAction(() => _controller.dismissAll());
@@ -150,6 +150,7 @@ class _TeacherStudentManagementPageState
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
_runBulkImport(rows); _runBulkImport(rows);
@@ -236,6 +237,7 @@ class _TeacherStudentManagementPageState
), ),
actions: [ actions: [
TextButton( TextButton(
autofocus: true,
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('확인'), child: const Text('확인'),
), ),
@@ -296,6 +298,7 @@ class _TeacherStudentManagementPageState
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.ink, backgroundColor: AppPalette.ink,
foregroundColor: AppPalette.paper, foregroundColor: AppPalette.paper,
@@ -341,6 +344,7 @@ class _TeacherStudentManagementPageState
child: const Text('취소', style: TextStyle(color: Colors.grey)), child: const Text('취소', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
autofocus: true,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppPalette.danger, backgroundColor: AppPalette.danger,
foregroundColor: Colors.white, foregroundColor: Colors.white,