From 02df94289e1c92ff1229e50d8038a07c509faae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=ED=98=95=EC=84=9D?= Date: Wed, 30 Apr 2025 02:31:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=9C=EA=B0=84=EC=B4=88=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B0=B8=EB=A6=AC=EB=8D=B0=EC=9D=B4=ED=84=B0=20ui=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../identity/identity_verification_page.dart | 246 +++++++++++------- .../identity_verification_viewmodel.dart | 50 +++- .../identity/phone_verification_state.dart | 12 + 3 files changed, 211 insertions(+), 97 deletions(-) diff --git a/lib/auth/presentation/pages/identity/identity_verification_page.dart b/lib/auth/presentation/pages/identity/identity_verification_page.dart index 7e83b13..a80c1cb 100644 --- a/lib/auth/presentation/pages/identity/identity_verification_page.dart +++ b/lib/auth/presentation/pages/identity/identity_verification_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:code_l/auth/presentation/pages/identity/providers.dart'; import 'package:code_l/auth/presentation/pages/identity/widgets/identity_verification_app_bar.dart'; import 'package:code_l/auth/presentation/widgets/auth_confirm_button.dart'; @@ -18,22 +20,7 @@ class PhoneVerificationPage extends ConsumerWidget { return Scaffold( appBar: IdentityVerificationAppBar(), - bottomNavigationBar: Padding( - padding: const EdgeInsets.all(20.0), - child: AuthConfirmButton( - enabled: state.codeSent, - onPressed: () { - if (viewModel.formKey.currentState!.validate()) { - viewModel.verifyCode(() { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (context) => const LoginPage()), - ); - }); - } - }, - ), - ), + bottomNavigationBar: _buildBottomButton(context, viewModel, state), body: Padding( padding: const EdgeInsets.all(20), child: Column( @@ -45,88 +32,12 @@ class PhoneVerificationPage extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ SizedBox(height: AppGaps.gap40), - Text("휴대전화 번호\n인증이 필요합니다", style: AppTypography.header1), - Text( - "원활한 서비스 이용을 위해 번호인증을 해주세요", - style: AppTypography.body2.copyWith( - color: AppColors.grey600, - ), - ), + _buildHeader(), SizedBox(height: AppGaps.gap40), SizedBox(height: AppGaps.gap40), - TextFormField( - controller: viewModel.phoneController, - keyboardType: TextInputType.phone, - decoration: InputDecoration( - border: UnderlineInputBorder( - borderSide: BorderSide( - color: AppColors.grey400, - width: 0.6, - ), - ), - hintText: '휴대전화 번호', - suffixIcon: ElevatedButton( - onPressed: () { - if (viewModel.formKey.currentState!.validate()) { - viewModel.sendSmsCode(); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: AppColors.white, - textStyle: AppTypography.subtitle2.copyWith( - color: AppColors.white, - ), - shape: RoundedRectangleBorder(), - padding: const EdgeInsets.symmetric( - horizontal: AppGaps.gap12, - vertical: AppGaps.gap12, - ), - ), - child: const Text("인증 요청"), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return '휴대전화 번호를 입력하세요.'; - } - if (!RegExp( - r'^01[0-9]-?\d{3,4}-?\d{4}$', - ).hasMatch(value)) { - return '유효한 휴대전화 번호를 입력하세요.'; - } - return null; - }, - ), - + _buildPhoneInput(viewModel, state), const SizedBox(height: 15), - - if (state.codeSent) ...[ - const SizedBox(height: 20), - // 인증 코드 입력 필드 - TextFormField( - controller: viewModel.smsCodeController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - border: UnderlineInputBorder( - borderSide: BorderSide( - color: AppColors.grey400, - width: 0.6, - ), - ), - hintText: '인증번호를 입력하세요.', - ), - validator: (value) { - if (value == null || value.isEmpty) { - return '인증번호 입력'; - } - if (!RegExp(r'^\d{6}$').hasMatch(value)) { - return '6자리 인증번호를 입력하세요.'; - } - return null; - }, - ), - ], + if (state.codeSent) _buildSmsCodeInput(viewModel, state), ], ), ), @@ -135,4 +46,147 @@ class PhoneVerificationPage extends ConsumerWidget { ), ); } -} + + Widget _buildBottomButton(BuildContext context, dynamic viewModel, dynamic state) { + return Padding( + padding: const EdgeInsets.all(20.0), + child: AuthConfirmButton( + enabled: state.codeSent, + onPressed: () { + if (viewModel.formKey.currentState!.validate()) { + viewModel.verifyCode(() { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const LoginPage()), + ); + }); + } + }, + ), + ); + } + + Widget _buildHeader() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("휴대전화 번호\n인증이 필요합니다", style: AppTypography.header1), + Text( + "원활한 서비스 이용을 위해 번호인증을 해주세요", + style: AppTypography.body2.copyWith( + color: AppColors.grey600, + ), + ), + ], + ); + } + + Widget _buildPhoneInput(dynamic viewModel, dynamic state) { + return TextFormField( + controller: viewModel.phoneController, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + border: UnderlineInputBorder( + borderSide: BorderSide( + color: AppColors.grey400, + width: 0.6, + ), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: AppColors.grey400, + width: 0.6, + ), + ), + hintText: '휴대전화 번호', + suffixIcon: _buildSmsRequestButton(viewModel), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return '휴대전화 번호를 입력하세요.'; + } + if (!RegExp( + r'^01[0-9]-?\d{3,4}-?\d{4}$', + ).hasMatch(value)) { + return '유효한 휴대전화 번호를 입력하세요.'; + } + return null; + }, + ); + } + + Widget _buildSmsRequestButton(dynamic viewModel) { + return ElevatedButton( + onPressed: () { + viewModel.sendSmsCode(); + viewModel.startTimer(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.white, + textStyle: AppTypography.subtitle2.copyWith( + color: AppColors.white, + ), + shape: RoundedRectangleBorder(), + padding: const EdgeInsets.symmetric( + horizontal: AppGaps.gap12, + vertical: AppGaps.gap12, + ), + ), + child: const Text("인증 요청"), + ); + } + + Widget _buildSmsCodeInput(dynamic viewModel, dynamic state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: viewModel.smsCodeController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: UnderlineInputBorder( + borderSide: BorderSide( + color: AppColors.grey400, + width: 0.6, + ), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: AppColors.grey400, + width: 0.6, + ), + ), + hintText: '인증번호를 입력하세요.', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return '인증번호 입력'; + } + if (!RegExp(r'^\d{6}$').hasMatch(value)) { + return '6자리 인증번호를 입력하세요.'; + } + if (!state.isSmsValid) { + return '인증번호가 일치하지 않습니다.'; + } + return null; + }, + ), + SizedBox(height: AppGaps.gap4), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + state.codeExpired + ? '인증 시간이 만료되었습니다. 다시 시도해주세요.' + : state.elapsedTime, + style: AppTypography.caption3.copyWith( + color: state.codeExpired ? AppColors.error : AppColors.grey900, + ), + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/auth/presentation/pages/identity/identity_verification_viewmodel.dart b/lib/auth/presentation/pages/identity/identity_verification_viewmodel.dart index 4252b72..73d28f6 100644 --- a/lib/auth/presentation/pages/identity/identity_verification_viewmodel.dart +++ b/lib/auth/presentation/pages/identity/identity_verification_viewmodel.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:code_l/auth/presentation/pages/identity/providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -12,6 +14,49 @@ class PhoneVerificationViewModel extends Notifier { final formKey = GlobalKey(); final phoneController = TextEditingController(); final smsCodeController = TextEditingController(); + Timer? timer; + int remainingSeconds = 120; + + + String formatTime(int seconds) { + final minutes = (seconds ~/ 60).toString().padLeft(2, '0'); + final remainingSeconds = (seconds % 60).toString().padLeft(2, '0'); + return '$minutes:$remainingSeconds'; + } + +// 타이머 시작 + void startTimer() { + resetTimer(); // 초기화 + timer = Timer.periodic(const Duration(seconds: 1), (Timer t) { + if (remainingSeconds > 0) { + remainingSeconds--; // 1초 감소 + state = state.copyWith(elapsedTime: formatTime(remainingSeconds)); + } else { + stopTimer(); // 시간이 끝나면 타이머 중지 + state = state.copyWith(codeExpired: true); // 만료 상태로 업데이트 + } + }); + } + + // 타이머 중지 + void stopTimer() { + timer?.cancel(); + } + + // 타이머 초기화 + void resetTimer() { + remainingSeconds = 120; // 초기값으로 재설정 + timer?.cancel(); + state = state.copyWith( + elapsedTime: formatTime(remainingSeconds), + codeExpired: false, + ); + } + + @override + void dispose() { + timer?.cancel(); + } @override PhoneVerificationState build() { @@ -20,6 +65,7 @@ class PhoneVerificationViewModel extends Notifier { } Future sendSmsCode() async { + state = state.copyWith(codeExpired: false); // 인증 만료 상태 초기화 if (formKey.currentState!.validate()) { final number = PhoneNumber.fromLocal(phoneController.text); @@ -36,6 +82,7 @@ class PhoneVerificationViewModel extends Notifier { verificationId: verificationId, codeSent: true, ); + startTimer(); // 타이머 시작 }, ); @@ -53,8 +100,9 @@ class PhoneVerificationViewModel extends Notifier { Fluttertoast.showToast(msg: '인증이 완료되었습니다'); await _verifyPhoneNumberUseCase.saveUser(); onSuccess(); + state = state.copyWith(isSmsValid: true); } catch (e) { - Fluttertoast.showToast(msg: "인증번호가 올바르지 않습니다"); + state = state.copyWith(isSmsValid: false); } } } diff --git a/lib/auth/presentation/pages/identity/phone_verification_state.dart b/lib/auth/presentation/pages/identity/phone_verification_state.dart index 048a2b8..cac2e86 100644 --- a/lib/auth/presentation/pages/identity/phone_verification_state.dart +++ b/lib/auth/presentation/pages/identity/phone_verification_state.dart @@ -2,19 +2,31 @@ class PhoneVerificationState { final bool codeSent; final String verificationId; + final bool isSmsValid; + final bool codeExpired; + final String elapsedTime; PhoneVerificationState({ this.codeSent = false, this.verificationId = '', + this.isSmsValid = true, + this.codeExpired = false, + this.elapsedTime = '02:00', }); PhoneVerificationState copyWith({ bool? codeSent, String? verificationId, + bool? isSmsValid, + bool? codeExpired, + String? elapsedTime, }) { return PhoneVerificationState( codeSent: codeSent ?? this.codeSent, verificationId: verificationId ?? this.verificationId, + isSmsValid: isSmsValid ?? this.isSmsValid, + codeExpired: codeExpired ?? this.codeExpired, + elapsedTime: elapsedTime ?? this.elapsedTime, ); } }