From d29db54afb3c5c038c03b2f84762df79dd8ba2cc Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Thu, 26 Jun 2025 16:49:37 +0530 Subject: [PATCH 01/21] feat: Implemented forgot password and verify OTP functionality This commit introduces the following changes: - Added a forgot password feature, allowing users to reset their passwords. - Implemented a verify OTP (One-Time Password) screen for enhanced security. - Updated the authentication repository to include methods for forgot password and OTP verification. - Added relevant translations for the new features. - Integrated the forgot password flow into the sign-in screen. - Updated API endpoints for forgot password and OTP verification. - Added necessary dependencies, including `pinput` for OTP input. --- .../lib/app/config/api_endpoints.dart | 2 + apps/app_core/lib/app/routes/app_router.dart | 10 +- .../auth/model/auth_request_model.dart | 18 ++ .../auth/repository/auth_repository.dart | 118 ++++----- .../auth/sign_in/screens/sign_in_screen.dart | 44 ++-- .../bloc/forgot_password_bloc.dart | 39 +++ .../bloc/forgot_password_event.dart | 21 ++ .../bloc/forgot_password_state.dart | 32 +++ .../screens/forgot_password_screen.dart | 107 ++++++++ .../verify_otp/bloc/verify_otp_bloc.dart | 91 +++++++ .../verify_otp/bloc/verify_otp_event.dart | 17 ++ .../verify_otp/bloc/verify_otp_state.dart | 52 ++++ .../verify_otp/screens/verify_otp_screen.dart | 239 ++++++++++++++++++ apps/app_core/pubspec.yaml | 2 + .../app_translations/assets/i18n/en.i18n.json | 10 +- 15 files changed, 716 insertions(+), 86 deletions(-) create mode 100644 apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart create mode 100644 apps/app_core/lib/modules/forgot_password/bloc/forgot_password_event.dart create mode 100644 apps/app_core/lib/modules/forgot_password/bloc/forgot_password_state.dart create mode 100644 apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart create mode 100644 apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart create mode 100644 apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart create mode 100644 apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart create mode 100644 apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart diff --git a/apps/app_core/lib/app/config/api_endpoints.dart b/apps/app_core/lib/app/config/api_endpoints.dart index c6fa26c..08bba8e 100644 --- a/apps/app_core/lib/app/config/api_endpoints.dart +++ b/apps/app_core/lib/app/config/api_endpoints.dart @@ -1,6 +1,8 @@ class ApiEndpoints { static const login = '/api/v1/login'; static const signup = '/api/register'; + static const forgotPassword = '/api/forgotPassword'; + static const verifyOTP = '/api/verifyOTP'; static const profile = '/api/users'; static const logout = '/api/users'; static const socialLogin = '/auth/socialLogin/'; diff --git a/apps/app_core/lib/app/routes/app_router.dart b/apps/app_core/lib/app/routes/app_router.dart index 5a4a0b2..98b487e 100644 --- a/apps/app_core/lib/app/routes/app_router.dart +++ b/apps/app_core/lib/app/routes/app_router.dart @@ -4,11 +4,13 @@ import 'package:app_core/modules/auth/sign_in/screens/sign_in_screen.dart'; import 'package:app_core/modules/auth/sign_up/screens/sign_up_screen.dart'; import 'package:app_core/modules/bottom_navigation_bar.dart'; import 'package:app_core/modules/change_password/screen/change_password_screen.dart'; +import 'package:app_core/modules/forgot_password/screens/forgot_password_screen.dart'; import 'package:app_core/modules/home/screen/home_screen.dart'; import 'package:app_core/modules/profile/screen/edit_profile_screen.dart'; import 'package:app_core/modules/profile/screen/profile_screen.dart'; import 'package:app_core/modules/splash/splash_screen.dart'; import 'package:app_core/modules/subscription/screen/subscription_screen.dart'; +import 'package:app_core/modules/verify_otp/screens/verify_otp_screen.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/cupertino.dart'; @@ -22,6 +24,8 @@ class AppRouter extends RootStackRouter { AutoRoute(page: SubscriptionRoute.page), AutoRoute(initial: true, page: SplashRoute.page, path: '/'), AutoRoute(page: SignInRoute.page), + AutoRoute(page: ForgotPasswordRoute.page), + AutoRoute(page: VerifyOTPRoute.page), AutoRoute( page: BottomNavigationBarRoute.page, guards: [AuthGuard()], @@ -32,11 +36,7 @@ class AppRouter extends RootStackRouter { path: 'account', children: [ AutoRoute(page: ProfileRoute.page), - AutoRoute( - page: ChangePasswordRoute.page, - path: 'change-password', - meta: const {'hideNavBar': true}, - ), + AutoRoute(page: ChangePasswordRoute.page, path: 'change-password', meta: const {'hideNavBar': true}), ], ), ], diff --git a/apps/app_core/lib/modules/auth/model/auth_request_model.dart b/apps/app_core/lib/modules/auth/model/auth_request_model.dart index 5044351..c82895b 100644 --- a/apps/app_core/lib/modules/auth/model/auth_request_model.dart +++ b/apps/app_core/lib/modules/auth/model/auth_request_model.dart @@ -10,6 +10,10 @@ class AuthRequestModel { this.oneSignalPlayerId, }); + AuthRequestModel.verifyOTP({required this.email, required this.token}); + + AuthRequestModel.forgotPassword({required this.email}); + String? email; String? name; String? password; @@ -18,6 +22,7 @@ class AuthRequestModel { String? providerId; String? providerToken; String? oneSignalPlayerId; + String? token; Map toMap() { final map = {}; @@ -28,6 +33,13 @@ class AuthRequestModel { return map; } + Map toVerifyOTPMap() { + final map = {}; + map['email'] = email; + map['token'] = token; + return map; + } + Map toSocialSignInMap() { final map = {}; map['name'] = name; @@ -40,4 +52,10 @@ class AuthRequestModel { map['oneSignalPlayerId'] = oneSignalPlayerId; return map; } + + Map toForgotPasswordMap() { + final map = {}; + map['email'] = email; + return map; + } } diff --git a/apps/app_core/lib/modules/auth/repository/auth_repository.dart b/apps/app_core/lib/modules/auth/repository/auth_repository.dart index f4b792d..70ed223 100644 --- a/apps/app_core/lib/modules/auth/repository/auth_repository.dart +++ b/apps/app_core/lib/modules/auth/repository/auth_repository.dart @@ -19,9 +19,11 @@ abstract interface class IAuthRepository { TaskEither logout(); - TaskEither socialLogin({ - required AuthRequestModel requestModel, - }); + TaskEither forgotPassword(AuthRequestModel authRequestModel); + + TaskEither socialLogin({required AuthRequestModel requestModel}); + + TaskEither verifyOTP(AuthRequestModel authRequestModel); } // ignore: comment_references @@ -31,48 +33,28 @@ class AuthRepository implements IAuthRepository { const AuthRepository(); @override - TaskEither login( - AuthRequestModel authRequestModel, - ) => makeLoginRequest(authRequestModel) + TaskEither login(AuthRequestModel authRequestModel) => makeLoginRequest(authRequestModel) .chainEither(RepositoryUtils.checkStatusCode) .chainEither( (response) => RepositoryUtils.mapToModel(() { - return AuthResponseModel.fromMap( - response.data as Map, - ); + return AuthResponseModel.fromMap(response.data as Map); }), ) .flatMap(saveUserToLocal); - TaskEither makeLoginRequest( - AuthRequestModel authRequestModel, - ) => userApiClient.request( + TaskEither makeLoginRequest(AuthRequestModel authRequestModel) => userApiClient.request( requestType: RequestType.post, path: ApiEndpoints.login, body: authRequestModel.toMap(), - options: Options( - headers: { - 'x-api-key': 'reqres-free-v1', - 'Content-Type': 'application/json', - }, - ), + options: Options(headers: {'x-api-key': 'reqres-free-v1', 'Content-Type': 'application/json'}), ); - TaskEither saveUserToLocal( - AuthResponseModel authResponseModel, - ) => getIt().setUserData( - UserModel( - name: 'user name', - email: 'user email', - profilePicUrl: '', - id: int.parse(authResponseModel.id), - ), + TaskEither saveUserToLocal(AuthResponseModel authResponseModel) => getIt().setUserData( + UserModel(name: 'user name', email: 'user email', profilePicUrl: '', id: int.parse(authResponseModel.id)), ); @override - TaskEither signup( - AuthRequestModel authRequestModel, - ) => makeSignUpRequest(authRequestModel) + TaskEither signup(AuthRequestModel authRequestModel) => makeSignUpRequest(authRequestModel) .chainEither(RepositoryUtils.checkStatusCode) .chainEither( (r) => RepositoryUtils.mapToModel(() { @@ -82,27 +64,20 @@ class AuthRepository implements IAuthRepository { // return AuthResponseModel.fromMap( // r.data as Map, // ); - return AuthResponseModel( - email: 'eve.holt@reqres.in', - id: (r.data as Map)['id'].toString(), - ); + return AuthResponseModel(email: 'eve.holt@reqres.in', id: (r.data as Map)['id'].toString()); }), ) .flatMap(saveUserToLocal); - TaskEither makeSignUpRequest( - AuthRequestModel authRequestModel, - ) => userApiClient.request( + TaskEither makeSignUpRequest(AuthRequestModel authRequestModel) => userApiClient.request( requestType: RequestType.post, path: ApiEndpoints.signup, body: authRequestModel.toMap(), options: Options(headers: {'Content-Type': 'application/json'}), ); - TaskEither _clearHiveData() => TaskEither.tryCatch( - () => getIt().logout().run(), - (error, stackTrace) => APIFailure(), - ); + TaskEither _clearHiveData() => + TaskEither.tryCatch(() => getIt().logout().run(), (error, stackTrace) => APIFailure()); @override TaskEither logout() => makeLogoutRequest().flatMap( @@ -111,14 +86,11 @@ class AuthRepository implements IAuthRepository { }), ); - TaskEither _getNotificationId() => - TaskEither.tryCatch(() { - return getIt() - .getNotificationSubscriptionId(); - }, APIFailure.new); + TaskEither _getNotificationId() => TaskEither.tryCatch(() { + return getIt().getNotificationSubscriptionId(); + }, APIFailure.new); - TaskEither - makeLogoutRequest() => _getNotificationId().flatMap( + TaskEither makeLogoutRequest() => _getNotificationId().flatMap( (playerID) => userApiClient.request( requestType: RequestType.delete, @@ -130,26 +102,54 @@ class AuthRepository implements IAuthRepository { ); @override - TaskEither socialLogin({ - required AuthRequestModel requestModel, - }) => makeSocialLoginRequest(requestModel: requestModel) + TaskEither socialLogin({required AuthRequestModel requestModel}) => makeSocialLoginRequest( + requestModel: requestModel, + ) .chainEither(RepositoryUtils.checkStatusCode) .chainEither( - (response) => RepositoryUtils.mapToModel( - () => AuthResponseModel.fromMap( - response.data as Map, - ), - ), + (response) => + RepositoryUtils.mapToModel(() => AuthResponseModel.fromMap(response.data as Map)), ) .flatMap(saveUserToLocal); - TaskEither makeSocialLoginRequest({ - required AuthRequestModel requestModel, - }) { + TaskEither makeSocialLoginRequest({required AuthRequestModel requestModel}) { return userApiClient.request( requestType: RequestType.post, path: ApiEndpoints.socialLogin, body: requestModel.toSocialSignInMap(), ); } + + @override + TaskEither forgotPassword(AuthRequestModel authRequestModel) => makeForgotPasswordRequest(authRequestModel) + .chainEither(RepositoryUtils.checkStatusCode) + .chainEither( + (response) => RepositoryUtils.mapToModel(() { + return response.data; + }), + ) + .map((_) {}); + + TaskEither makeForgotPasswordRequest(AuthRequestModel authRequestModel) => userApiClient.request( + requestType: RequestType.post, + path: ApiEndpoints.forgotPassword, + body: authRequestModel.toForgotPasswordMap(), + options: Options(headers: {'x-api-key': 'reqres-free-v1', 'Content-Type': 'application/json'}), + ); + + @override + TaskEither verifyOTP(AuthRequestModel authRequestModel) => makeVerifyOTPRequest(authRequestModel) + .chainEither(RepositoryUtils.checkStatusCode) + .chainEither( + (response) => RepositoryUtils.mapToModel(() { + return AuthResponseModel.fromMap(response.data as Map); + }), + ); + + TaskEither makeVerifyOTPRequest(AuthRequestModel authRequestModel) => userApiClient.request( + requestType: RequestType.post, + path: ApiEndpoints.verifyOTP, + body: authRequestModel.toVerifyOTPMap(), + options: Options(headers: {'Content-Type': 'application/json'}), + ); } diff --git a/apps/app_core/lib/modules/auth/sign_in/screens/sign_in_screen.dart b/apps/app_core/lib/modules/auth/sign_in/screens/sign_in_screen.dart index 94dd557..2fd5c22 100644 --- a/apps/app_core/lib/modules/auth/sign_in/screens/sign_in_screen.dart +++ b/apps/app_core/lib/modules/auth/sign_in/screens/sign_in_screen.dart @@ -27,12 +27,7 @@ class SignInPage extends StatelessWidget implements AutoRouteWrapper { providers: [RepositoryProvider(create: (context) => const AuthRepository())], child: MultiBlocProvider( providers: [ - BlocProvider( - create: - (context) => SignInBloc( - authenticationRepository: RepositoryProvider.of(context), - ), - ), + BlocProvider(create: (context) => SignInBloc(authenticationRepository: RepositoryProvider.of(context))), ], child: this, ), @@ -61,21 +56,32 @@ class SignInPage extends StatelessWidget implements AutoRouteWrapper { children: [ VSpace.xxxxlarge80(), VSpace.large24(), - const SlideAndFadeAnimationWrapper( - delay: 100, - child: Center(child: FlutterLogo(size: 100)), - ), + const SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: FlutterLogo(size: 100))), VSpace.xxlarge40(), VSpace.large24(), - SlideAndFadeAnimationWrapper( - delay: 200, - child: AppText.XL(text: context.t.sign_in), - ), + SlideAndFadeAnimationWrapper(delay: 200, child: AppText.XL(text: context.t.sign_in)), VSpace.large24(), SlideAndFadeAnimationWrapper(delay: 300, child: _EmailInput()), VSpace.large24(), SlideAndFadeAnimationWrapper(delay: 400, child: _PasswordInput()), VSpace.large24(), + AnimatedGestureDetector( + onTap: () { + context.pushRoute(const ForgotPasswordRoute()); + }, + child: SlideAndFadeAnimationWrapper( + delay: 200, + child: Align( + alignment: Alignment.topRight, + child: AppText.regular10( + fontSize: 14, + text: context.t.forgot_password, + color: context.colorScheme.primary400, + ), + ), + ), + ), + VSpace.large24(), SlideAndFadeAnimationWrapper(delay: 400, child: _UserConsentWidget()), VSpace.xxlarge40(), const SlideAndFadeAnimationWrapper(delay: 500, child: _LoginButton()), @@ -84,8 +90,7 @@ class SignInPage extends StatelessWidget implements AutoRouteWrapper { VSpace.large24(), const SlideAndFadeAnimationWrapper(delay: 600, child: _ContinueWithGoogleButton()), VSpace.large24(), - if (Platform.isIOS) - const SlideAndFadeAnimationWrapper(delay: 600, child: _ContinueWithAppleButton()), + if (Platform.isIOS) const SlideAndFadeAnimationWrapper(delay: 600, child: _ContinueWithAppleButton()), ], ), ), @@ -125,8 +130,7 @@ class _PasswordInput extends StatelessWidget { label: context.t.password, textInputAction: TextInputAction.done, onChanged: (password) => context.read().add(SignInPasswordChanged(password)), - errorText: - state.password.displayError != null ? context.t.common_validation_password : null, + errorText: state.password.displayError != null ? context.t.common_validation_password : null, autofillHints: const [AutofillHints.password], ); }, @@ -143,9 +147,7 @@ class _UserConsentWidget extends StatelessWidget { return UserConsentWidget( value: isUserConsent, onCheckBoxValueChanged: (userConsent) { - context.read().add( - SignInUserConsentChangedEvent(userConsent: userConsent ?? false), - ); + context.read().add(SignInUserConsentChangedEvent(userConsent: userConsent ?? false)); }, onTermsAndConditionTap: () => launchUrl( diff --git a/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart new file mode 100644 index 0000000..8885c21 --- /dev/null +++ b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:app_core/core/domain/validators/login_validators.dart'; +import 'package:app_core/modules/auth/model/auth_request_model.dart'; +import 'package:app_core/modules/auth/repository/auth_repository.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; +import 'package:fpdart/fpdart.dart'; + +part 'forgot_password_event.dart'; +part 'forgot_password_state.dart'; + +class ForgotPasswordBloc extends Bloc { + ForgotPasswordBloc({required IAuthRepository authenticationRepository}) + : _authenticationRepository = authenticationRepository, + super(const ForgotPasswordState()) { + on(_onEmailChanged); + on(_onSubmitted); + } + + final IAuthRepository _authenticationRepository; + + void _onEmailChanged(ForgotPasswordEmailChanged event, Emitter emit) { + final email = EmailValidator.dirty(event.email); + emit(state.copyWith(email: email, isValid: Formz.validate([email]))); + } + + Future _onSubmitted(ForgotPasswordSubmitted event, Emitter emit) async { + final email = EmailValidator.dirty(state.email.value); + emit(state.copyWith(email: email, isValid: Formz.validate([email]))); + if (state.isValid) { + emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); + await _authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: state.email.value.trim())).run(); + emit(state.copyWith(status: FormzSubmissionStatus.success)); + } + return unit; + } +} diff --git a/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_event.dart b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_event.dart new file mode 100644 index 0000000..8242159 --- /dev/null +++ b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_event.dart @@ -0,0 +1,21 @@ +part of 'forgot_password_bloc.dart'; + +sealed class ForgotPasswordEvent extends Equatable { + const ForgotPasswordEvent(); + + @override + List get props => []; +} + +final class ForgotPasswordEmailChanged extends ForgotPasswordEvent { + const ForgotPasswordEmailChanged(this.email); + + final String email; + + @override + List get props => [email]; +} + +final class ForgotPasswordSubmitted extends ForgotPasswordEvent { + const ForgotPasswordSubmitted(); +} diff --git a/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_state.dart b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_state.dart new file mode 100644 index 0000000..705d78c --- /dev/null +++ b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_state.dart @@ -0,0 +1,32 @@ +part of 'forgot_password_bloc.dart'; + +final class ForgotPasswordState extends Equatable { + const ForgotPasswordState({ + this.status = FormzSubmissionStatus.initial, + this.email = const EmailValidator.pure(), + this.isValid = false, + this.errorMessage = '', + }); + + ForgotPasswordState copyWith({ + EmailValidator? email, + bool? isValid, + FormzSubmissionStatus? status, + String? errorMessage, + }) { + return ForgotPasswordState( + email: email ?? this.email, + isValid: isValid ?? this.isValid, + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + final FormzSubmissionStatus status; + final EmailValidator email; + final bool isValid; + final String errorMessage; + + @override + List get props => [status, email, isValid]; +} diff --git a/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart b/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart new file mode 100644 index 0000000..d891668 --- /dev/null +++ b/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart @@ -0,0 +1,107 @@ +import 'package:app_core/app/routes/app_router.dart'; +import 'package:app_core/core/presentation/widgets/app_snackbar.dart'; +import 'package:app_core/modules/auth/repository/auth_repository.dart'; +import 'package:app_core/modules/forgot_password/bloc/forgot_password_bloc.dart'; +import 'package:app_translations/app_translations.dart'; +import 'package:app_ui/app_ui.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; + +@RoutePage() +class ForgotPasswordPage extends StatelessWidget implements AutoRouteWrapper { + const ForgotPasswordPage({super.key}); + + @override + Widget wrappedRoute(BuildContext context) { + return RepositoryProvider( + create: (context) => const AuthRepository(), + child: BlocProvider( + create: (context) => ForgotPasswordBloc(authenticationRepository: RepositoryProvider.of(context)), + child: this, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + body: BlocListener( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, state) async { + if (state.status.isFailure) { + showAppSnackbar(context, state.errorMessage); + } else if (state.status.isSuccess) { + showAppSnackbar(context, context.t.reset_password_mail_sent); + await context.pushRoute(VerifyOTPRoute(emailAddress: state.email.value)); + } + }, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: Insets.large24), + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + VSpace.xxxlarge66(), + SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: Assets.images.logo.image(width: 100))), + VSpace.large24(), + SlideAndFadeAnimationWrapper( + delay: 200, + child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), + ), + VSpace.large24(), + SlideAndFadeAnimationWrapper(delay: 300, child: _EmailInput()), + VSpace.xxlarge40(), + const SlideAndFadeAnimationWrapper(delay: 500, child: _ForgotPasswordButton()), + VSpace.large24(), + ], + ), + ), + ), + ), + ); + } +} + +class _EmailInput extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => previous.email != current.email, + builder: (context, state) { + return AppTextField( + textInputAction: TextInputAction.done, + initialValue: state.email.value, + label: context.t.email, + keyboardType: TextInputType.emailAddress, + onChanged: (email) => context.read().add(ForgotPasswordEmailChanged(email)), + errorText: state.email.displayError != null ? context.t.common_validation_email : null, + autofillHints: const [AutofillHints.email], + ); + }, + ); + } +} + +class _ForgotPasswordButton extends StatelessWidget { + const _ForgotPasswordButton(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return AppButton( + isLoading: state.status.isInProgress, + text: context.t.reset_password, + onPressed: () { + TextInput.finishAutofillContext(); + context.read().add(const ForgotPasswordSubmitted()); + }, + isExpanded: true, + ); + }, + ); + } +} diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart new file mode 100644 index 0000000..c8c6726 --- /dev/null +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:api_client/api_client.dart'; +import 'package:app_core/core/domain/validators/login_validators.dart'; +import 'package:app_core/modules/auth/model/auth_request_model.dart'; +import 'package:app_core/modules/auth/repository/auth_repository.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fpdart/fpdart.dart'; + +part 'verify_otp_event.dart'; +part 'verify_otp_state.dart'; + +class VerifyOTPBloc extends Bloc { + VerifyOTPBloc(this.authenticationRepository, this.emailAddress) : super(const VerifyOTPState()) { + on(_onVerifyButtonPressed); + on(_onVerifyOTPChanged); + on(_onResendEmail); + } + + final AuthRepository authenticationRepository; + final String emailAddress; + + Future _onVerifyButtonPressed(VerifyButtonPressed event, Emitter emit) async { + emit(state.copyWith(statusForVerifyOTP: ApiStatus.loading, statusForResendOTP: ApiStatus.initial)); + final verifyOTPEither = + await authenticationRepository.verifyOTP(AuthRequestModel.verifyOTP(email: emailAddress, token: state.otp)).run(); + + verifyOTPEither.fold( + (failure) { + emit( + state.copyWith( + statusForVerifyOTP: ApiStatus.error, + statusForResendOTP: ApiStatus.initial, + errorMessage: failure.message, + ), + ); + }, + (success) { + emit(state.copyWith(statusForVerifyOTP: ApiStatus.loaded, statusForResendOTP: ApiStatus.initial)); + }, + ); + return unit; + } + + Future _onVerifyOTPChanged(VerifyOTPChanged event, Emitter emit) async { + if (event.otp.length == 6) { + emit( + state.copyWith( + otpIsValid: true, + otp: event.otp, + statusForVerifyOTP: ApiStatus.initial, + statusForResendOTP: ApiStatus.initial, + ), + ); + } else { + emit( + state.copyWith( + otpIsValid: false, + otp: event.otp, + statusForVerifyOTP: ApiStatus.initial, + statusForResendOTP: ApiStatus.initial, + ), + ); + } + return unit; + } + + Future _onResendEmail(ResendEmailEvent event, Emitter emit) async { + emit( + state.copyWith(statusForVerifyOTP: ApiStatus.initial, statusForResendOTP: ApiStatus.loading, otp: '', otpIsValid: false), + ); + final response = await authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: emailAddress)).run(); + + response.fold( + (failure) { + emit( + state.copyWith( + statusForResendOTP: ApiStatus.error, + statusForVerifyOTP: ApiStatus.initial, + errorMessage: failure.message, + ), + ); + }, + (success) { + emit(state.copyWith(statusForVerifyOTP: ApiStatus.initial, statusForResendOTP: ApiStatus.loaded)); + }, + ); + return unit; + } +} diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart new file mode 100644 index 0000000..3d9329c --- /dev/null +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart @@ -0,0 +1,17 @@ +part of 'verify_otp_bloc.dart'; + +abstract class VerifyOTPEvent {} + +class VerifyOTPChanged extends VerifyOTPEvent { + VerifyOTPChanged(this.otp); + final String otp; +} + +class EmailAddressChanged extends VerifyOTPEvent { + EmailAddressChanged(this.email); + final String email; +} + +class VerifyButtonPressed extends VerifyOTPEvent {} + +class ResendEmailEvent extends VerifyOTPEvent {} diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart new file mode 100644 index 0000000..b9913a9 --- /dev/null +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart @@ -0,0 +1,52 @@ +part of 'verify_otp_bloc.dart'; + +final class VerifyOTPState extends Equatable { + const VerifyOTPState({ + this.statusForResendOTP = ApiStatus.initial, + this.statusForVerifyOTP = ApiStatus.initial, + this.email = const EmailValidator.pure(), + this.isValid = false, + this.errorMessage = '', + this.otp = '', + this.otpIsValid = false, + }); + + VerifyOTPState copyWith({ + EmailValidator? email, + bool? otpIsValid, + bool? isValid, + ApiStatus? statusForResendOTP, + ApiStatus? statusForVerifyOTP, + String? errorMessage, + String? otp, + }) { + return VerifyOTPState( + email: email ?? this.email, + otpIsValid: otpIsValid ?? this.otpIsValid, + isValid: isValid ?? this.isValid, + statusForResendOTP: statusForResendOTP ?? this.statusForResendOTP, + statusForVerifyOTP: statusForVerifyOTP ?? this.statusForVerifyOTP, + errorMessage: errorMessage ?? '', + otp: otp ?? this.otp, + ); + } + + final ApiStatus statusForResendOTP; + final ApiStatus statusForVerifyOTP; + final EmailValidator email; + final bool otpIsValid; + final bool isValid; + final String errorMessage; + final String otp; + + @override + List get props => [ + statusForResendOTP, + email, + otp, + otpIsValid, + isValid, + errorMessage, + statusForVerifyOTP, + ]; +} diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart new file mode 100644 index 0000000..1dd7f78 --- /dev/null +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -0,0 +1,239 @@ +import 'dart:async'; + +import 'package:api_client/api_client.dart'; +import 'package:app_core/app/routes/app_router.dart'; +import 'package:app_core/core/presentation/widgets/app_snackbar.dart'; +import 'package:app_core/modules/auth/repository/auth_repository.dart'; +import 'package:app_core/modules/verify_otp/bloc/verify_otp_bloc.dart'; +import 'package:app_translations/app_translations.dart'; +import 'package:app_ui/app_ui.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinput/pinput.dart'; + +@RoutePage() +class VerifyOTPScreen extends StatefulWidget implements AutoRouteWrapper { + const VerifyOTPScreen({super.key, this.emailAddress}); + + final String? emailAddress; + + @override + State createState() => _VerifyOTPScreenState(); + + @override + Widget wrappedRoute(BuildContext context) { + return RepositoryProvider( + create: (context) => const AuthRepository(), + child: BlocProvider( + lazy: false, + create: (context) => VerifyOTPBloc(RepositoryProvider.of(context), emailAddress ?? ''), + child: this, + ), + ); + } +} + +class _VerifyOTPScreenState extends State with TickerProviderStateMixin { + late final TextEditingController pinController; + late final FocusNode focusNode; + late final GlobalKey formKey; + + Timer? _timer; + int _secondsRemaining = 30; + bool _isTimerRunning = true; + + @override + void dispose() { + _timer?.cancel(); + pinController.dispose(); + focusNode.dispose(); + super.dispose(); + } + + @override + void initState() { + formKey = GlobalKey(); + pinController = TextEditingController(); + focusNode = FocusNode(); + _startTimer(); + super.initState(); + } + + void _startTimer() { + setState(() { + _secondsRemaining = 30; + _isTimerRunning = true; + }); + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_secondsRemaining > 0) { + setState(() { + _secondsRemaining--; + }); + } else { + setState(() { + _isTimerRunning = false; + }); + _timer?.cancel(); + } + }); + } + + void _onResendOTP(BuildContext context) { + final email = widget.emailAddress; + if (email == null || email.isEmpty) { + showAppSnackbar(context, 'Email address is missing. Cannot resend OTP.'); + return; + } + debugPrint('Resending OTP to email: $email'); + + pinController.clear(); + FocusScope.of(context).unfocus(); + context.read().add(ResendEmailEvent()); + _startTimer(); + } + + void _onVerifyOTP(BuildContext contextBuild, VerifyOTPState state) { + TextInput.finishAutofillContext(); + FocusScope.of(context).unfocus(); + // Static check for OTP + if (state.otp == '222222') { + pinController.clear(); + showAppSnackbar(contextBuild, 'OTP verified successfully!'); + contextBuild.maybePop(); + if (mounted) { + contextBuild.pushRoute(const ChangePasswordRoute()); + } + } else { + showAppSnackbar(contextBuild, 'Invalid OTP'); + } + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + appBar: CustomAppBar( + backgroundColor: context.colorScheme.white, + automaticallyImplyLeading: true, + title: context.t.verify_otp, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(Insets.small12), + child: BlocConsumer( + listener: (BuildContext context, VerifyOTPState state) { + if (state.statusForResendOTP == ApiStatus.error || state.statusForVerifyOTP == ApiStatus.error) { + final errorMessage = state.errorMessage; + pinController.clear(); + showAppSnackbar(context, errorMessage); + } + if (state.statusForResendOTP == ApiStatus.loaded) { + showAppSnackbar(context, context.t.otp_send_to_email); + } + // Remove API success navigation, handled in static check + }, + builder: (context, state) { + return SingleChildScrollView( + child: Column( + children: [ + VSpace.large24(), + SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: Assets.images.logo.image(width: 100))), + VSpace.large24(), + SlideAndFadeAnimationWrapper( + delay: 200, + child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), + ), + VSpace.large24(), + AppTextField( + initialValue: widget.emailAddress, + label: context.t.email, + textInputAction: TextInputAction.done, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + ), + VSpace.medium16(), + Padding(padding: const EdgeInsets.all(Insets.small12), child: AppText.sSemiBold(text: context.t.enter_otp)), + VSpace.small12(), + BlocBuilder( + builder: + (context, state) => Pinput( + length: 6, + controller: pinController, + focusNode: focusNode, + separatorBuilder: (index) => HSpace.xxsmall4(), + validator: (value) { + return value == '222222' ? null : 'Pin is incorrect'; + }, + onCompleted: (pin) { + debugPrint('onCompleted: $pin'); + }, + onChanged: (value) { + context.read().add(VerifyOTPChanged(value)); + }, + ), + ), + VSpace.xsmall8(), + if (_isTimerRunning) + AppText( + text: '00:${_secondsRemaining.toString().padLeft(2, '0')}', + style: context.textTheme?.sSemiBold.copyWith(color: context.colorScheme.primary400), + ), + VSpace.small12(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppText( + text: "Didn't receive the verification OTP?", + style: context.textTheme?.xsRegular.copyWith(color: context.colorScheme.black), + ), + AppButton( + text: context.t.resend_otp, + buttonType: ButtonType.text, + textColor: context.colorScheme.primary400, + onPressed: _isTimerRunning ? null : () => _onResendOTP(context), + ), + const SizedBox(width: 8), + ], + ), + VSpace.large24(), + BlocBuilder( + builder: + (contextBuild, state) => Visibility( + visible: state.otpIsValid, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.large24), + child: AppButton( + isExpanded: true, + text: context.t.verify_otp, + isLoading: state.statusForVerifyOTP == ApiStatus.loading, + onPressed: () => _onVerifyOTP(contextBuild, state), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ), + ); + } +} + +class Countdown extends AnimatedWidget { + Countdown({super.key, this.animation}) : super(listenable: animation!); + final Animation? animation; + + @override + Widget build(BuildContext context) { + final clockTimer = Duration(seconds: animation!.value); + + final timerText = '${clockTimer.inMinutes.remainder(60)}:${clockTimer.inSeconds.remainder(60).toString().padLeft(2, '0')}'; + + return AppText(text: timerText); + } +} diff --git a/apps/app_core/pubspec.yaml b/apps/app_core/pubspec.yaml index 622508f..436777a 100644 --- a/apps/app_core/pubspec.yaml +++ b/apps/app_core/pubspec.yaml @@ -121,6 +121,8 @@ dev_dependencies: flutter_launcher_icons: ^0.14.3 + pinput: ^5.0.1 + flutter_gen: output: lib/gen/ line_length: 80 diff --git a/packages/app_translations/assets/i18n/en.i18n.json b/packages/app_translations/assets/i18n/en.i18n.json index 83a146e..5d60b87 100644 --- a/packages/app_translations/assets/i18n/en.i18n.json +++ b/packages/app_translations/assets/i18n/en.i18n.json @@ -54,5 +54,13 @@ "terms_and_condition" : "Terms and Condition", "privacy_policy" : "Privacy Policy", "and": "and", - "please_accept_terms" : "Please accept the Terms & Conditions and Privacy Policy to continue." + "please_accept_terms" : "Please accept the Terms & Conditions and Privacy Policy to continue.", + "reset_password_mail_sent": "Reset password mail sent", + "welcome": "Welcome", + "reset_password": "Reset Password", + "go_back": "Go Back", + "enter_otp": "Enter OTP", + "verify_otp": "Verify OTP", + "resend_otp": "Resend OTP", + "otp_send_to_email": "OTP sent to your email" } \ No newline at end of file From 6ed300f6b61181f8a5a3cc2e81b193cd99cdcbaf Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Thu, 26 Jun 2025 17:21:33 +0530 Subject: [PATCH 02/21] Fix: Moved pinput dependency from dev_dependencies to dependencies - Updated ForgotPasswordBloc to handle success and failure cases. - Included errorMessage in ForgotPasswordState props. - Added static OTP validation in VerifyOTPScreen. - Updated VerifyOTPState and VerifyOTPEvent to include props for better state management and event handling. - Removed skeletonizer dependency override from widgetbook. --- .../bloc/forgot_password_bloc.dart | 8 ++++-- .../bloc/forgot_password_state.dart | 9 ++----- .../verify_otp/bloc/verify_otp_event.dart | 26 ++++++++++++++----- .../verify_otp/bloc/verify_otp_state.dart | 12 ++------- .../verify_otp/screens/verify_otp_screen.dart | 1 + apps/app_core/pubspec.yaml | 3 ++- packages/widgetbook/pubspec_overrides.yaml | 3 +-- 7 files changed, 34 insertions(+), 28 deletions(-) diff --git a/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart index 8885c21..5e119e3 100644 --- a/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart +++ b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart @@ -31,8 +31,12 @@ class ForgotPasswordBloc extends Bloc emit(state.copyWith(email: email, isValid: Formz.validate([email]))); if (state.isValid) { emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); - await _authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: state.email.value.trim())).run(); - emit(state.copyWith(status: FormzSubmissionStatus.success)); + final result = + await _authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: state.email.value.trim())).run(); + result.fold( + (failure) => emit(state.copyWith(status: FormzSubmissionStatus.failure)), + (success) => emit(state.copyWith(status: FormzSubmissionStatus.success)), + ); } return unit; } diff --git a/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_state.dart b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_state.dart index 705d78c..b9d336c 100644 --- a/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_state.dart +++ b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_state.dart @@ -8,12 +8,7 @@ final class ForgotPasswordState extends Equatable { this.errorMessage = '', }); - ForgotPasswordState copyWith({ - EmailValidator? email, - bool? isValid, - FormzSubmissionStatus? status, - String? errorMessage, - }) { + ForgotPasswordState copyWith({EmailValidator? email, bool? isValid, FormzSubmissionStatus? status, String? errorMessage}) { return ForgotPasswordState( email: email ?? this.email, isValid: isValid ?? this.isValid, @@ -28,5 +23,5 @@ final class ForgotPasswordState extends Equatable { final String errorMessage; @override - List get props => [status, email, isValid]; + List get props => [status, email, isValid, errorMessage]; } diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart index 3d9329c..77447ac 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart @@ -1,17 +1,31 @@ part of 'verify_otp_bloc.dart'; -abstract class VerifyOTPEvent {} +sealed class VerifyOTPEvent extends Equatable { + const VerifyOTPEvent(); -class VerifyOTPChanged extends VerifyOTPEvent { - VerifyOTPChanged(this.otp); + @override + List get props => []; +} + +final class VerifyOTPChanged extends VerifyOTPEvent { + const VerifyOTPChanged(this.otp); final String otp; + + @override + List get props => [otp]; } class EmailAddressChanged extends VerifyOTPEvent { - EmailAddressChanged(this.email); + const EmailAddressChanged(this.email); final String email; + @override + List get props => [email]; } -class VerifyButtonPressed extends VerifyOTPEvent {} +class VerifyButtonPressed extends VerifyOTPEvent { + const VerifyButtonPressed(); +} -class ResendEmailEvent extends VerifyOTPEvent {} +class ResendEmailEvent extends VerifyOTPEvent { + const ResendEmailEvent(); +} diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart index b9913a9..29a0b10 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart @@ -26,7 +26,7 @@ final class VerifyOTPState extends Equatable { isValid: isValid ?? this.isValid, statusForResendOTP: statusForResendOTP ?? this.statusForResendOTP, statusForVerifyOTP: statusForVerifyOTP ?? this.statusForVerifyOTP, - errorMessage: errorMessage ?? '', + errorMessage: errorMessage ?? this.errorMessage, otp: otp ?? this.otp, ); } @@ -40,13 +40,5 @@ final class VerifyOTPState extends Equatable { final String otp; @override - List get props => [ - statusForResendOTP, - email, - otp, - otpIsValid, - isValid, - errorMessage, - statusForVerifyOTP, - ]; + List get props => [statusForResendOTP, email, otp, otpIsValid, isValid, errorMessage, statusForVerifyOTP]; } diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index 1dd7f78..0b98453 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -164,6 +164,7 @@ class _VerifyOTPScreenState extends State with TickerProviderSt focusNode: focusNode, separatorBuilder: (index) => HSpace.xxsmall4(), validator: (value) { + //For now we added static validation return value == '222222' ? null : 'Pin is incorrect'; }, onCompleted: (pin) { diff --git a/apps/app_core/pubspec.yaml b/apps/app_core/pubspec.yaml index 436777a..469cf1e 100644 --- a/apps/app_core/pubspec.yaml +++ b/apps/app_core/pubspec.yaml @@ -89,6 +89,7 @@ dependencies: # Launch URL url_launcher: ^6.3.1 + pinput: ^5.0.1 dependency_overrides: web: ^1.0.0 @@ -121,7 +122,7 @@ dev_dependencies: flutter_launcher_icons: ^0.14.3 - pinput: ^5.0.1 + flutter_gen: output: lib/gen/ diff --git a/packages/widgetbook/pubspec_overrides.yaml b/packages/widgetbook/pubspec_overrides.yaml index 32cda75..1cf53a3 100644 --- a/packages/widgetbook/pubspec_overrides.yaml +++ b/packages/widgetbook/pubspec_overrides.yaml @@ -1,5 +1,4 @@ -# melos_managed_dependency_overrides: app_ui,skeletonizer +# melos_managed_dependency_overrides: app_ui dependency_overrides: app_ui: path: ../app_ui - skeletonizer: ^2.0.0-pre From 34353749c40864eec69ad50dcd1f45d979cea12f Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Thu, 26 Jun 2025 17:21:55 +0530 Subject: [PATCH 03/21] Add const keyword for ResendEmailEvent in VerifyOTPBloc --- .../lib/modules/verify_otp/screens/verify_otp_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index 0b98453..30caae5 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -91,7 +91,7 @@ class _VerifyOTPScreenState extends State with TickerProviderSt pinController.clear(); FocusScope.of(context).unfocus(); - context.read().add(ResendEmailEvent()); + context.read().add(const ResendEmailEvent()); _startTimer(); } From e4ce88ed438f68b9005fc73f59e73e1adf68b008 Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Thu, 26 Jun 2025 17:56:43 +0530 Subject: [PATCH 04/21] - Added `did_not_receive_otp` key to `en.i18n.json`. --- .../lib/modules/verify_otp/screens/verify_otp_screen.dart | 2 +- packages/app_translations/assets/i18n/en.i18n.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index 30caae5..b37380b 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -186,7 +186,7 @@ class _VerifyOTPScreenState extends State with TickerProviderSt mainAxisAlignment: MainAxisAlignment.center, children: [ AppText( - text: "Didn't receive the verification OTP?", + text: context.t.did_not_receive_otp, style: context.textTheme?.xsRegular.copyWith(color: context.colorScheme.black), ), AppButton( diff --git a/packages/app_translations/assets/i18n/en.i18n.json b/packages/app_translations/assets/i18n/en.i18n.json index 5d60b87..4191bcb 100644 --- a/packages/app_translations/assets/i18n/en.i18n.json +++ b/packages/app_translations/assets/i18n/en.i18n.json @@ -62,5 +62,6 @@ "enter_otp": "Enter OTP", "verify_otp": "Verify OTP", "resend_otp": "Resend OTP", - "otp_send_to_email": "OTP sent to your email" + "otp_send_to_email": "OTP sent to your email", + "did_not_receive_otp": "Didn't receive the verification OTP?" } \ No newline at end of file From 7a86f522af6126dc947dd3245307902274817d74 Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Thu, 26 Jun 2025 18:06:42 +0530 Subject: [PATCH 05/21] Refactor: Improve OTP verification and UI elements - Updated API endpoints for forgot password and verify OTP. - Set email field to read-only on the verify OTP screen. - Removed unused `Countdown` widget. - Standardized OTP length to 6 digits in `VerifyOTPBloc`. - Added `readOnly` property to `AppTextField` widget. --- .../lib/app/config/api_endpoints.dart | 4 +- .../verify_otp/bloc/verify_otp_bloc.dart | 3 +- .../verify_otp/screens/verify_otp_screen.dart | 22 +-------- .../src/widgets/molecules/app_textfield.dart | 45 ++++++++----------- 4 files changed, 24 insertions(+), 50 deletions(-) diff --git a/apps/app_core/lib/app/config/api_endpoints.dart b/apps/app_core/lib/app/config/api_endpoints.dart index 08bba8e..65305fb 100644 --- a/apps/app_core/lib/app/config/api_endpoints.dart +++ b/apps/app_core/lib/app/config/api_endpoints.dart @@ -1,8 +1,8 @@ class ApiEndpoints { static const login = '/api/v1/login'; static const signup = '/api/register'; - static const forgotPassword = '/api/forgotPassword'; - static const verifyOTP = '/api/verifyOTP'; + static const forgotPassword = '/api/v1/forgot-password'; + static const verifyOTP = '/api/v1/verify-otp'; static const profile = '/api/users'; static const logout = '/api/users'; static const socialLogin = '/auth/socialLogin/'; diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart index c8c6726..95313f2 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart @@ -43,8 +43,9 @@ class VerifyOTPBloc extends Bloc { return unit; } + static const int _otpLength = 6; Future _onVerifyOTPChanged(VerifyOTPChanged event, Emitter emit) async { - if (event.otp.length == 6) { + if (event.otp.length == _otpLength) { emit( state.copyWith( otpIsValid: true, diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index b37380b..e3ad73c 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -146,13 +146,7 @@ class _VerifyOTPScreenState extends State with TickerProviderSt child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), ), VSpace.large24(), - AppTextField( - initialValue: widget.emailAddress, - label: context.t.email, - textInputAction: TextInputAction.done, - keyboardType: TextInputType.emailAddress, - autofillHints: const [AutofillHints.email], - ), + AppTextField(initialValue: widget.emailAddress, label: context.t.email, readOnly: true), VSpace.medium16(), Padding(padding: const EdgeInsets.all(Insets.small12), child: AppText.sSemiBold(text: context.t.enter_otp)), VSpace.small12(), @@ -224,17 +218,3 @@ class _VerifyOTPScreenState extends State with TickerProviderSt ); } } - -class Countdown extends AnimatedWidget { - Countdown({super.key, this.animation}) : super(listenable: animation!); - final Animation? animation; - - @override - Widget build(BuildContext context) { - final clockTimer = Duration(seconds: animation!.value); - - final timerText = '${clockTimer.inMinutes.remainder(60)}:${clockTimer.inSeconds.remainder(60).toString().padLeft(2, '0')}'; - - return AppText(text: timerText); - } -} diff --git a/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart b/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart index f6a39ae..2516965 100644 --- a/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart +++ b/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart @@ -9,6 +9,7 @@ class AppTextField extends StatefulWidget { this.textInputAction = TextInputAction.next, this.showLabel = true, this.hintText, + this.readOnly, this.keyboardType, this.initialValue, this.onChanged, @@ -20,8 +21,8 @@ class AppTextField extends StatefulWidget { this.contentPadding, this.autofillHints, this.hintTextBelowTextField, - }) : isPasswordField = false, - isObscureText = false; + }) : isPasswordField = false, + isObscureText = false; const AppTextField.password({ required this.label, @@ -37,15 +38,17 @@ class AppTextField extends StatefulWidget { this.backgroundColor, this.minLines, this.focusNode, + this.readOnly, this.autofillHints, this.hintTextBelowTextField, this.contentPadding, - }) : isPasswordField = true, - isObscureText = true; + }) : isPasswordField = true, + isObscureText = true; final String label; final String? initialValue; final String? hintText; + final bool? readOnly; final String? errorText; final String? hintTextBelowTextField; final TextInputAction? textInputAction; @@ -85,10 +88,7 @@ class _AppTextFieldState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (widget.showLabel) ...[ - AppText.xsSemiBold(text: widget.label), - VSpace.xsmall8(), - ], + if (widget.showLabel) ...[AppText.xsSemiBold(text: widget.label), VSpace.xsmall8()], TextFormField( initialValue: widget.initialValue, cursorColor: context.colorScheme.black, @@ -98,6 +98,7 @@ class _AppTextFieldState extends State { validator: widget.validator, obscureText: isObscureText, onChanged: widget.onChanged, + readOnly: widget.readOnly ?? false, autofillHints: widget.autofillHints, focusNode: widget.focusNode, decoration: InputDecoration( @@ -110,29 +111,21 @@ class _AppTextFieldState extends State { borderRadius: BorderRadius.circular(Insets.xsmall8), borderSide: BorderSide(color: context.colorScheme.primary400), ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(Insets.xsmall8), - borderSide: BorderSide.none, - ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(Insets.xsmall8), borderSide: BorderSide.none), errorText: widget.errorText, - suffixIcon: widget.isPasswordField - ? IconButton( - splashColor: context.colorScheme.primary50, - onPressed: toggleObscureText, - icon: Icon( - isObscureText ? Icons.visibility_off : Icons.visibility, - color: context.colorScheme.grey700, - ), - ) - : null, + suffixIcon: + widget.isPasswordField + ? IconButton( + splashColor: context.colorScheme.primary50, + onPressed: toggleObscureText, + icon: Icon(isObscureText ? Icons.visibility_off : Icons.visibility, color: context.colorScheme.grey700), + ) + : null, ), minLines: widget.minLines, maxLines: widget.minLines ?? 0 + 1, ), - if (widget.hintTextBelowTextField != null) ...[ - VSpace.xsmall8(), - AppText.xsRegular(text: widget.hintTextBelowTextField), - ], + if (widget.hintTextBelowTextField != null) ...[VSpace.xsmall8(), AppText.xsRegular(text: widget.hintTextBelowTextField)], ], ); } From 9b690e441cd06a25faf12f19d5876108680b001d Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Thu, 26 Jun 2025 18:12:34 +0530 Subject: [PATCH 06/21] Fix: Added mounted checks in VerifyOTPScreen timer callbacks --- .../modules/verify_otp/screens/verify_otp_screen.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index e3ad73c..8ee6b25 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -62,6 +62,7 @@ class _VerifyOTPScreenState extends State with TickerProviderSt } void _startTimer() { + if (!mounted) return; setState(() { _secondsRemaining = 30; _isTimerRunning = true; @@ -69,10 +70,18 @@ class _VerifyOTPScreenState extends State with TickerProviderSt _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), (timer) { if (_secondsRemaining > 0) { + if (!mounted) { + timer.cancel(); + return; + } setState(() { _secondsRemaining--; }); } else { + if (!mounted) { + timer.cancel(); + return; + } setState(() { _isTimerRunning = false; }); From 6e7ed9b43e158e10a97df3a3c314601eeb97679b Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Thu, 26 Jun 2025 18:34:30 +0530 Subject: [PATCH 07/21] Update COUNTER Incremented the counter to 2. --- BOILERPLATE/COUNTER | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BOILERPLATE/COUNTER b/BOILERPLATE/COUNTER index a3be6c6..e72e2db 100644 --- a/BOILERPLATE/COUNTER +++ b/BOILERPLATE/COUNTER @@ -1,2 +1,2 @@ # Increment this counter to push your code again -1 \ No newline at end of file +2 \ No newline at end of file From 1bc92405fd173a1434af2f8da3b03ca7159575f2 Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Thu, 26 Jun 2025 18:36:35 +0530 Subject: [PATCH 08/21] Updated app_textfield.dart - Removed unnecessary space between the left and right EdgeInsets. --- packages/app_ui/lib/src/widgets/molecules/app_textfield.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart b/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart index 0509270..857dc3b 100644 --- a/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart +++ b/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart @@ -109,8 +109,7 @@ class _AppTextFieldState extends State { filled: true, fillColor: widget.backgroundColor ?? context.colorScheme.grey100, hintText: widget.hintText, - contentPadding: widget.contentPadding ?? - const EdgeInsets.only(left: Insets.small12, right: Insets.small12), + contentPadding: widget.contentPadding ?? const EdgeInsets.only(left: Insets.small12, right: Insets.small12), errorMaxLines: 2, focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(Insets.xsmall8), From a5934fa53d8999ec992bd1e2e103c54edc6371f8 Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Thu, 26 Jun 2025 19:49:00 +0530 Subject: [PATCH 09/21] Feat: Add AppTimer widget and integrate with OTP verification This commit introduces a new `AppTimer` widget and integrates it into the OTP verification screen. Key changes: - Added `AppTimer` widget to `app_ui` for displaying a countdown timer. - Integrated `AppTimer` into `VerifyOTPScreen` to show the OTP resend countdown. - Refactored `VerifyOTPScreen` to use `AppTimer` and removed manual timer logic. - Updated `VerifyOTPBloc` to use `LengthValidator` for OTP validation. - Modified navigation from `ForgotPasswordScreen` to `VerifyOTPScreen` to use `replaceRoute` instead of `pushRoute`. - Ensured OTP input is cleared and focus is removed when resending OTP. - Displayed error messages using `SnackbarType.failed` for invalid OTP. - Updated Pinput widget to show error text based on `state.otp.error`. - Allowed only digits in OTP input using `FilteringTextInputFormatter.digitsOnly`. --- .../screens/forgot_password_screen.dart | 2 +- .../verify_otp/bloc/verify_otp_bloc.dart | 33 +++------ .../verify_otp/bloc/verify_otp_state.dart | 25 +++---- .../verify_otp/screens/verify_otp_screen.dart | 42 +++--------- .../lib/src/widgets/molecules/app_timer.dart | 67 +++++++++++++++++++ .../lib/src/widgets/molecules/molecules.dart | 1 + 6 files changed, 98 insertions(+), 72 deletions(-) create mode 100644 packages/app_ui/lib/src/widgets/molecules/app_timer.dart diff --git a/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart b/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart index d891668..88d91f8 100644 --- a/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart +++ b/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart @@ -35,7 +35,7 @@ class ForgotPasswordPage extends StatelessWidget implements AutoRouteWrapper { showAppSnackbar(context, state.errorMessage); } else if (state.status.isSuccess) { showAppSnackbar(context, context.t.reset_password_mail_sent); - await context.pushRoute(VerifyOTPRoute(emailAddress: state.email.value)); + await context.replaceRoute(VerifyOTPRoute(emailAddress: state.email.value)); } }, child: SingleChildScrollView( diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart index 95313f2..a63531c 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart @@ -1,15 +1,15 @@ import 'dart:async'; import 'package:api_client/api_client.dart'; -import 'package:app_core/core/domain/validators/login_validators.dart'; +import 'package:app_core/core/domain/validators/length_validator.dart'; import 'package:app_core/modules/auth/model/auth_request_model.dart'; import 'package:app_core/modules/auth/repository/auth_repository.dart'; +import 'package:app_core/modules/verify_otp/bloc/verify_otp_state.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fpdart/fpdart.dart'; part 'verify_otp_event.dart'; -part 'verify_otp_state.dart'; class VerifyOTPBloc extends Bloc { VerifyOTPBloc(this.authenticationRepository, this.emailAddress) : super(const VerifyOTPState()) { @@ -24,7 +24,7 @@ class VerifyOTPBloc extends Bloc { Future _onVerifyButtonPressed(VerifyButtonPressed event, Emitter emit) async { emit(state.copyWith(statusForVerifyOTP: ApiStatus.loading, statusForResendOTP: ApiStatus.initial)); final verifyOTPEither = - await authenticationRepository.verifyOTP(AuthRequestModel.verifyOTP(email: emailAddress, token: state.otp)).run(); + await authenticationRepository.verifyOTP(AuthRequestModel.verifyOTP(email: emailAddress, token: state.otp.value)).run(); verifyOTPEither.fold( (failure) { @@ -45,31 +45,18 @@ class VerifyOTPBloc extends Bloc { static const int _otpLength = 6; Future _onVerifyOTPChanged(VerifyOTPChanged event, Emitter emit) async { - if (event.otp.length == _otpLength) { - emit( - state.copyWith( - otpIsValid: true, - otp: event.otp, - statusForVerifyOTP: ApiStatus.initial, - statusForResendOTP: ApiStatus.initial, - ), - ); - } else { - emit( - state.copyWith( - otpIsValid: false, - otp: event.otp, - statusForVerifyOTP: ApiStatus.initial, - statusForResendOTP: ApiStatus.initial, - ), - ); - } + final otp = LengthValidator.dirty(_otpLength, event.otp); + emit(state.copyWith(otp: otp, statusForVerifyOTP: ApiStatus.initial, statusForResendOTP: ApiStatus.initial)); return unit; } Future _onResendEmail(ResendEmailEvent event, Emitter emit) async { emit( - state.copyWith(statusForVerifyOTP: ApiStatus.initial, statusForResendOTP: ApiStatus.loading, otp: '', otpIsValid: false), + state.copyWith( + statusForVerifyOTP: ApiStatus.initial, + statusForResendOTP: ApiStatus.loading, + otp: const LengthValidator.pure(_otpLength), + ), ); final response = await authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: emailAddress)).run(); diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart index 29a0b10..316b1e8 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart @@ -1,44 +1,41 @@ -part of 'verify_otp_bloc.dart'; +import 'package:api_client/api_client.dart'; +import 'package:app_core/core/domain/validators/email_validator.dart'; +import 'package:app_core/core/domain/validators/length_validator.dart'; +import 'package:equatable/equatable.dart'; final class VerifyOTPState extends Equatable { const VerifyOTPState({ this.statusForResendOTP = ApiStatus.initial, this.statusForVerifyOTP = ApiStatus.initial, this.email = const EmailValidator.pure(), - this.isValid = false, this.errorMessage = '', - this.otp = '', - this.otpIsValid = false, + this.otp = const LengthValidator.pure(6), }); VerifyOTPState copyWith({ EmailValidator? email, - bool? otpIsValid, - bool? isValid, + LengthValidator? otp, ApiStatus? statusForResendOTP, ApiStatus? statusForVerifyOTP, String? errorMessage, - String? otp, }) { return VerifyOTPState( email: email ?? this.email, - otpIsValid: otpIsValid ?? this.otpIsValid, - isValid: isValid ?? this.isValid, + otp: otp ?? this.otp, statusForResendOTP: statusForResendOTP ?? this.statusForResendOTP, statusForVerifyOTP: statusForVerifyOTP ?? this.statusForVerifyOTP, errorMessage: errorMessage ?? this.errorMessage, - otp: otp ?? this.otp, ); } final ApiStatus statusForResendOTP; final ApiStatus statusForVerifyOTP; final EmailValidator email; - final bool otpIsValid; - final bool isValid; + final LengthValidator otp; final String errorMessage; - final String otp; + + bool get isValid => otp.isValid; @override - List get props => [statusForResendOTP, email, otp, otpIsValid, isValid, errorMessage, statusForVerifyOTP]; + List get props => [statusForResendOTP, email, otp, errorMessage, statusForVerifyOTP]; } diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index 8ee6b25..6911967 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -5,6 +5,7 @@ import 'package:app_core/app/routes/app_router.dart'; import 'package:app_core/core/presentation/widgets/app_snackbar.dart'; import 'package:app_core/modules/auth/repository/auth_repository.dart'; import 'package:app_core/modules/verify_otp/bloc/verify_otp_bloc.dart'; +import 'package:app_core/modules/verify_otp/bloc/verify_otp_state.dart'; import 'package:app_translations/app_translations.dart'; import 'package:app_ui/app_ui.dart'; import 'package:auto_route/auto_route.dart'; @@ -36,8 +37,6 @@ class VerifyOTPScreen extends StatefulWidget implements AutoRouteWrapper { } class _VerifyOTPScreenState extends State with TickerProviderStateMixin { - late final TextEditingController pinController; - late final FocusNode focusNode; late final GlobalKey formKey; Timer? _timer; @@ -47,16 +46,12 @@ class _VerifyOTPScreenState extends State with TickerProviderSt @override void dispose() { _timer?.cancel(); - pinController.dispose(); - focusNode.dispose(); super.dispose(); } @override void initState() { formKey = GlobalKey(); - pinController = TextEditingController(); - focusNode = FocusNode(); _startTimer(); super.initState(); } @@ -91,14 +86,6 @@ class _VerifyOTPScreenState extends State with TickerProviderSt } void _onResendOTP(BuildContext context) { - final email = widget.emailAddress; - if (email == null || email.isEmpty) { - showAppSnackbar(context, 'Email address is missing. Cannot resend OTP.'); - return; - } - debugPrint('Resending OTP to email: $email'); - - pinController.clear(); FocusScope.of(context).unfocus(); context.read().add(const ResendEmailEvent()); _startTimer(); @@ -108,15 +95,14 @@ class _VerifyOTPScreenState extends State with TickerProviderSt TextInput.finishAutofillContext(); FocusScope.of(context).unfocus(); // Static check for OTP - if (state.otp == '222222') { - pinController.clear(); + if (state.otp.value == '222222') { showAppSnackbar(contextBuild, 'OTP verified successfully!'); contextBuild.maybePop(); if (mounted) { - contextBuild.pushRoute(const ChangePasswordRoute()); + contextBuild.replaceRoute(const ChangePasswordRoute()); } } else { - showAppSnackbar(contextBuild, 'Invalid OTP'); + showAppSnackbar(contextBuild, 'Invalid OTP', type: SnackbarType.failed); } } @@ -135,7 +121,6 @@ class _VerifyOTPScreenState extends State with TickerProviderSt listener: (BuildContext context, VerifyOTPState state) { if (state.statusForResendOTP == ApiStatus.error || state.statusForVerifyOTP == ApiStatus.error) { final errorMessage = state.errorMessage; - pinController.clear(); showAppSnackbar(context, errorMessage); } if (state.statusForResendOTP == ApiStatus.loaded) { @@ -163,27 +148,16 @@ class _VerifyOTPScreenState extends State with TickerProviderSt builder: (context, state) => Pinput( length: 6, - controller: pinController, - focusNode: focusNode, separatorBuilder: (index) => HSpace.xxsmall4(), - validator: (value) { - //For now we added static validation - return value == '222222' ? null : 'Pin is incorrect'; - }, - onCompleted: (pin) { - debugPrint('onCompleted: $pin'); - }, + errorText: state.otp.error != null ? 'Pin is incorrect' : null, onChanged: (value) { context.read().add(VerifyOTPChanged(value)); }, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], ), ), VSpace.xsmall8(), - if (_isTimerRunning) - AppText( - text: '00:${_secondsRemaining.toString().padLeft(2, '0')}', - style: context.textTheme?.sSemiBold.copyWith(color: context.colorScheme.primary400), - ), + if (_isTimerRunning) AppTimer(seconds: 30, onTick: (remaining) {}, onFinished: () {}), VSpace.small12(), Row( mainAxisAlignment: MainAxisAlignment.center, @@ -205,7 +179,7 @@ class _VerifyOTPScreenState extends State with TickerProviderSt BlocBuilder( builder: (contextBuild, state) => Visibility( - visible: state.otpIsValid, + visible: state.isValid, child: Padding( padding: const EdgeInsets.symmetric(horizontal: Insets.large24), child: AppButton( diff --git a/packages/app_ui/lib/src/widgets/molecules/app_timer.dart b/packages/app_ui/lib/src/widgets/molecules/app_timer.dart new file mode 100644 index 0000000..8ee7499 --- /dev/null +++ b/packages/app_ui/lib/src/widgets/molecules/app_timer.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:app_ui/app_ui.dart'; +import 'package:flutter/material.dart'; + +class AppTimer extends StatefulWidget { + const AppTimer({required this.seconds, super.key, this.onTick, this.onFinished, this.textStyle}); + final int seconds; + final void Function(int secondsRemaining)? onTick; + final VoidCallback? onFinished; + final TextStyle? textStyle; + + @override + State createState() => _AppTimerState(); +} + +class _AppTimerState extends State with TickerProviderStateMixin { + late int _secondsRemaining; + Timer? _timer; + + @override + void initState() { + super.initState(); + _secondsRemaining = widget.seconds; + _startTimer(); + } + + @override + void didUpdateWidget(covariant AppTimer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.seconds != widget.seconds) { + _timer?.cancel(); + _secondsRemaining = widget.seconds; + _startTimer(); + } + } + + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_secondsRemaining > 0) { + setState(() { + _secondsRemaining--; + }); + if (widget.onTick != null) widget.onTick?.call(_secondsRemaining); + } else { + timer.cancel(); + if (widget.onFinished != null) widget.onFinished?.call(); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final timerText = '00:${_secondsRemaining.toString().padLeft(2, '0')}'; + return AppText( + text: timerText, + style: widget.textStyle ?? context.textTheme?.sSemiBold.copyWith(color: context.colorScheme.primary400), + ); + } +} diff --git a/packages/app_ui/lib/src/widgets/molecules/molecules.dart b/packages/app_ui/lib/src/widgets/molecules/molecules.dart index 545f7ed..fce2c9c 100644 --- a/packages/app_ui/lib/src/widgets/molecules/molecules.dart +++ b/packages/app_ui/lib/src/widgets/molecules/molecules.dart @@ -7,6 +7,7 @@ export 'app_network_image.dart'; export 'app_profile_image.dart'; export 'app_refresh_indicator.dart'; export 'app_textfield.dart'; +export 'app_timer.dart'; export 'empty_ui.dart'; export 'no_internet_widget.dart'; export 'user_concent_widget.dart'; From d3750e0cc172f1406d0fe8bcdb12663c1ebbb424 Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Fri, 27 Jun 2025 11:19:30 +0530 Subject: [PATCH 10/21] Refactor: Streamline AppTimer and Verify OTP UI - Removed unused `formKey` from `VerifyOTPScreen`. - Simplified `AppTimer` widget by removing `onTick` and `textStyle` parameters. - Updated `VerifyOTPState`'s `isValid` getter to include email validation. --- .../modules/verify_otp/bloc/verify_otp_state.dart | 2 +- .../verify_otp/screens/verify_otp_screen.dart | 5 +---- .../app_ui/lib/src/widgets/molecules/app_timer.dart | 13 ++++--------- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart index 316b1e8..7c50a65 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart @@ -34,7 +34,7 @@ final class VerifyOTPState extends Equatable { final LengthValidator otp; final String errorMessage; - bool get isValid => otp.isValid; + bool get isValid => otp.isValid && email.isValid; @override List get props => [statusForResendOTP, email, otp, errorMessage, statusForVerifyOTP]; diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index 6911967..53c22b2 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -37,8 +37,6 @@ class VerifyOTPScreen extends StatefulWidget implements AutoRouteWrapper { } class _VerifyOTPScreenState extends State with TickerProviderStateMixin { - late final GlobalKey formKey; - Timer? _timer; int _secondsRemaining = 30; bool _isTimerRunning = true; @@ -51,7 +49,6 @@ class _VerifyOTPScreenState extends State with TickerProviderSt @override void initState() { - formKey = GlobalKey(); _startTimer(); super.initState(); } @@ -157,7 +154,7 @@ class _VerifyOTPScreenState extends State with TickerProviderSt ), ), VSpace.xsmall8(), - if (_isTimerRunning) AppTimer(seconds: 30, onTick: (remaining) {}, onFinished: () {}), + if (_isTimerRunning) AppTimer(seconds: 30, onFinished: () {}), VSpace.small12(), Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/packages/app_ui/lib/src/widgets/molecules/app_timer.dart b/packages/app_ui/lib/src/widgets/molecules/app_timer.dart index 8ee7499..88c87eb 100644 --- a/packages/app_ui/lib/src/widgets/molecules/app_timer.dart +++ b/packages/app_ui/lib/src/widgets/molecules/app_timer.dart @@ -4,17 +4,16 @@ import 'package:app_ui/app_ui.dart'; import 'package:flutter/material.dart'; class AppTimer extends StatefulWidget { - const AppTimer({required this.seconds, super.key, this.onTick, this.onFinished, this.textStyle}); + const AppTimer({required this.seconds, super.key, this.onFinished}); final int seconds; - final void Function(int secondsRemaining)? onTick; + final VoidCallback? onFinished; - final TextStyle? textStyle; @override State createState() => _AppTimerState(); } -class _AppTimerState extends State with TickerProviderStateMixin { +class _AppTimerState extends State { late int _secondsRemaining; Timer? _timer; @@ -42,7 +41,6 @@ class _AppTimerState extends State with TickerProviderStateMixin { setState(() { _secondsRemaining--; }); - if (widget.onTick != null) widget.onTick?.call(_secondsRemaining); } else { timer.cancel(); if (widget.onFinished != null) widget.onFinished?.call(); @@ -59,9 +57,6 @@ class _AppTimerState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { final timerText = '00:${_secondsRemaining.toString().padLeft(2, '0')}'; - return AppText( - text: timerText, - style: widget.textStyle ?? context.textTheme?.sSemiBold.copyWith(color: context.colorScheme.primary400), - ); + return AppText(text: timerText, style: context.textTheme?.sSemiBold.copyWith(color: context.colorScheme.primary400)); } } From 878f64bac6a44ed5b87dc73d51b30137486d1579 Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Fri, 27 Jun 2025 12:15:05 +0530 Subject: [PATCH 11/21] Fix: Prevent `AppTimer` from starting if initial seconds is 0 The `AppTimer` widget was modified to prevent the timer from starting if the initial `seconds` value is 0. Additionally, the `onFinished` callback is now always called when the timer finishes, even if it's null. --- packages/app_ui/lib/src/widgets/molecules/app_timer.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app_ui/lib/src/widgets/molecules/app_timer.dart b/packages/app_ui/lib/src/widgets/molecules/app_timer.dart index 88c87eb..0ec79fb 100644 --- a/packages/app_ui/lib/src/widgets/molecules/app_timer.dart +++ b/packages/app_ui/lib/src/widgets/molecules/app_timer.dart @@ -35,6 +35,7 @@ class _AppTimerState extends State { } void _startTimer() { + if (widget.seconds == 0) return; _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), (timer) { if (_secondsRemaining > 0) { @@ -43,7 +44,7 @@ class _AppTimerState extends State { }); } else { timer.cancel(); - if (widget.onFinished != null) widget.onFinished?.call(); + widget.onFinished?.call(); } }); } From d3595cb6c7fa93e63514f2ad9ae85b2bb28efded Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Fri, 27 Jun 2025 13:09:44 +0530 Subject: [PATCH 12/21] Fix: AppTimer displays minutes and seconds The `AppTimer` widget now correctly formats the remaining time to show both minutes and seconds (MM:SS) instead of just seconds. --- packages/app_ui/lib/src/widgets/molecules/app_timer.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app_ui/lib/src/widgets/molecules/app_timer.dart b/packages/app_ui/lib/src/widgets/molecules/app_timer.dart index 0ec79fb..6e8a8e4 100644 --- a/packages/app_ui/lib/src/widgets/molecules/app_timer.dart +++ b/packages/app_ui/lib/src/widgets/molecules/app_timer.dart @@ -57,7 +57,9 @@ class _AppTimerState extends State { @override Widget build(BuildContext context) { - final timerText = '00:${_secondsRemaining.toString().padLeft(2, '0')}'; + final minutes = _secondsRemaining ~/ 60; + final seconds = _secondsRemaining % 60; + final timerText = '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; return AppText(text: timerText, style: context.textTheme?.sSemiBold.copyWith(color: context.colorScheme.primary400)); } } From cd4c5935ec9fa2b7e9bf44a93d04b882aeb7f76c Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Fri, 27 Jun 2025 14:22:39 +0530 Subject: [PATCH 13/21] Refactor: Improve OTP verification and UI - Added `pin_incorrect` localization. - Made `VerifyOTPEvent` subclasses final. - Added assertion for non-negative seconds in `AppTimer`. - Used localized error message for incorrect PIN in `VerifyOTPScreen`. --- .../lib/modules/verify_otp/bloc/verify_otp_event.dart | 6 +++--- .../lib/modules/verify_otp/screens/verify_otp_screen.dart | 2 +- packages/app_translations/assets/i18n/en.i18n.json | 3 ++- packages/app_ui/lib/src/widgets/molecules/app_timer.dart | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart index 77447ac..f6cde1e 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart @@ -15,17 +15,17 @@ final class VerifyOTPChanged extends VerifyOTPEvent { List get props => [otp]; } -class EmailAddressChanged extends VerifyOTPEvent { +final class EmailAddressChanged extends VerifyOTPEvent { const EmailAddressChanged(this.email); final String email; @override List get props => [email]; } -class VerifyButtonPressed extends VerifyOTPEvent { +final class VerifyButtonPressed extends VerifyOTPEvent { const VerifyButtonPressed(); } -class ResendEmailEvent extends VerifyOTPEvent { +final class ResendEmailEvent extends VerifyOTPEvent { const ResendEmailEvent(); } diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index 53c22b2..89fa5d1 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -146,7 +146,7 @@ class _VerifyOTPScreenState extends State with TickerProviderSt (context, state) => Pinput( length: 6, separatorBuilder: (index) => HSpace.xxsmall4(), - errorText: state.otp.error != null ? 'Pin is incorrect' : null, + errorText: state.otp.error != null ? context.t.pin_incorrect : null, onChanged: (value) { context.read().add(VerifyOTPChanged(value)); }, diff --git a/packages/app_translations/assets/i18n/en.i18n.json b/packages/app_translations/assets/i18n/en.i18n.json index 4191bcb..1e0c8c4 100644 --- a/packages/app_translations/assets/i18n/en.i18n.json +++ b/packages/app_translations/assets/i18n/en.i18n.json @@ -63,5 +63,6 @@ "verify_otp": "Verify OTP", "resend_otp": "Resend OTP", "otp_send_to_email": "OTP sent to your email", - "did_not_receive_otp": "Didn't receive the verification OTP?" + "did_not_receive_otp": "Didn't receive the verification OTP?", + "pin_incorrect": "Pin is incorrect" } \ No newline at end of file diff --git a/packages/app_ui/lib/src/widgets/molecules/app_timer.dart b/packages/app_ui/lib/src/widgets/molecules/app_timer.dart index 6e8a8e4..af029e9 100644 --- a/packages/app_ui/lib/src/widgets/molecules/app_timer.dart +++ b/packages/app_ui/lib/src/widgets/molecules/app_timer.dart @@ -4,7 +4,7 @@ import 'package:app_ui/app_ui.dart'; import 'package:flutter/material.dart'; class AppTimer extends StatefulWidget { - const AppTimer({required this.seconds, super.key, this.onFinished}); + const AppTimer({required this.seconds, super.key, this.onFinished}) : assert(seconds >= 0, 'seconds must be non-negative'); final int seconds; final VoidCallback? onFinished; From 84584fdcfde72fafb124845993376ca2bf45372a Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Fri, 27 Jun 2025 16:11:50 +0530 Subject: [PATCH 14/21] Refactor: Update OTP verification and forgot password functionality - Modified `VerifyOTPBloc` to initialize email via `SetEmailEvent` instead of constructor. - Renamed API status fields in `VerifyOTPState` for clarity (e.g., `statusForResendOTP` to `resendOtpStatus`). - Removed redundant `BlocBuilder` widgets in `VerifyOTPScreen`. - Trimmed email input in `ForgotPasswordBloc` before validation and submission. - Changed `AuthRepository.forgotPassword` return type from `TaskEither` to `TaskEither`. - Replaced `SingleChildScrollView` with `ListView` in `ForgotPasswordScreen` and removed `AutofillGroup`. --- .../auth/repository/auth_repository.dart | 6 +-- .../bloc/forgot_password_bloc.dart | 4 +- .../screens/forgot_password_screen.dart | 34 ++++++------ .../verify_otp/bloc/verify_otp_bloc.dart | 48 ++++++++--------- .../verify_otp/bloc/verify_otp_event.dart | 8 +++ .../verify_otp/bloc/verify_otp_state.dart | 18 +++---- .../verify_otp/screens/verify_otp_screen.dart | 52 ++++++++----------- 7 files changed, 80 insertions(+), 90 deletions(-) diff --git a/apps/app_core/lib/modules/auth/repository/auth_repository.dart b/apps/app_core/lib/modules/auth/repository/auth_repository.dart index 70ed223..26ba9ef 100644 --- a/apps/app_core/lib/modules/auth/repository/auth_repository.dart +++ b/apps/app_core/lib/modules/auth/repository/auth_repository.dart @@ -19,7 +19,7 @@ abstract interface class IAuthRepository { TaskEither logout(); - TaskEither forgotPassword(AuthRequestModel authRequestModel); + TaskEither forgotPassword(AuthRequestModel authRequestModel); TaskEither socialLogin({required AuthRequestModel requestModel}); @@ -121,14 +121,14 @@ class AuthRepository implements IAuthRepository { } @override - TaskEither forgotPassword(AuthRequestModel authRequestModel) => makeForgotPasswordRequest(authRequestModel) + TaskEither forgotPassword(AuthRequestModel authRequestModel) => makeForgotPasswordRequest(authRequestModel) .chainEither(RepositoryUtils.checkStatusCode) .chainEither( (response) => RepositoryUtils.mapToModel(() { return response.data; }), ) - .map((_) {}); + .map((_) => unit); TaskEither makeForgotPasswordRequest(AuthRequestModel authRequestModel) => userApiClient.request( requestType: RequestType.post, diff --git a/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart index 5e119e3..58efaba 100644 --- a/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart +++ b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart @@ -22,7 +22,7 @@ class ForgotPasswordBloc extends Bloc final IAuthRepository _authenticationRepository; void _onEmailChanged(ForgotPasswordEmailChanged event, Emitter emit) { - final email = EmailValidator.dirty(event.email); + final email = EmailValidator.dirty(event.email.trim()); emit(state.copyWith(email: email, isValid: Formz.validate([email]))); } @@ -32,7 +32,7 @@ class ForgotPasswordBloc extends Bloc if (state.isValid) { emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); final result = - await _authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: state.email.value.trim())).run(); + await _authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: state.email.value)).run(); result.fold( (failure) => emit(state.copyWith(status: FormzSubmissionStatus.failure)), (success) => emit(state.copyWith(status: FormzSubmissionStatus.success)), diff --git a/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart b/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart index 88d91f8..429cd4d 100644 --- a/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart +++ b/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart @@ -38,27 +38,23 @@ class ForgotPasswordPage extends StatelessWidget implements AutoRouteWrapper { await context.replaceRoute(VerifyOTPRoute(emailAddress: state.email.value)); } }, - child: SingleChildScrollView( + child: ListView( padding: const EdgeInsets.symmetric(horizontal: Insets.large24), - child: AutofillGroup( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - VSpace.xxxlarge66(), - SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: Assets.images.logo.image(width: 100))), - VSpace.large24(), - SlideAndFadeAnimationWrapper( - delay: 200, - child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), - ), - VSpace.large24(), - SlideAndFadeAnimationWrapper(delay: 300, child: _EmailInput()), - VSpace.xxlarge40(), - const SlideAndFadeAnimationWrapper(delay: 500, child: _ForgotPasswordButton()), - VSpace.large24(), - ], + + children: [ + VSpace.xxxlarge66(), + SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: Assets.images.logo.image(width: 100))), + VSpace.large24(), + SlideAndFadeAnimationWrapper( + delay: 200, + child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), ), - ), + VSpace.large24(), + SlideAndFadeAnimationWrapper(delay: 300, child: _EmailInput()), + VSpace.xxlarge40(), + const SlideAndFadeAnimationWrapper(delay: 500, child: _ForgotPasswordButton()), + VSpace.large24(), + ], ), ), ); diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart index a63531c..70a74e6 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:api_client/api_client.dart'; +import 'package:app_core/core/domain/validators/email_validator.dart'; import 'package:app_core/core/domain/validators/length_validator.dart'; import 'package:app_core/modules/auth/model/auth_request_model.dart'; import 'package:app_core/modules/auth/repository/auth_repository.dart'; @@ -12,66 +13,61 @@ import 'package:fpdart/fpdart.dart'; part 'verify_otp_event.dart'; class VerifyOTPBloc extends Bloc { - VerifyOTPBloc(this.authenticationRepository, this.emailAddress) : super(const VerifyOTPState()) { + VerifyOTPBloc(this.authenticationRepository) : super(const VerifyOTPState()) { + on(_onSetEmail); on(_onVerifyButtonPressed); on(_onVerifyOTPChanged); on(_onResendEmail); } final AuthRepository authenticationRepository; - final String emailAddress; + + void _onSetEmail(SetEmailEvent event, Emitter emit) { + emit(state.copyWith(email: EmailValidator.dirty(event.email))); + } Future _onVerifyButtonPressed(VerifyButtonPressed event, Emitter emit) async { - emit(state.copyWith(statusForVerifyOTP: ApiStatus.loading, statusForResendOTP: ApiStatus.initial)); + emit(state.copyWith(verifyOtpStatus: ApiStatus.loading, resendOtpStatus: ApiStatus.initial)); final verifyOTPEither = - await authenticationRepository.verifyOTP(AuthRequestModel.verifyOTP(email: emailAddress, token: state.otp.value)).run(); + await authenticationRepository + .verifyOTP(AuthRequestModel.verifyOTP(email: state.email.value, token: state.otp.value)) + .run(); verifyOTPEither.fold( (failure) { - emit( - state.copyWith( - statusForVerifyOTP: ApiStatus.error, - statusForResendOTP: ApiStatus.initial, - errorMessage: failure.message, - ), - ); + emit(state.copyWith(verifyOtpStatus: ApiStatus.error, resendOtpStatus: ApiStatus.initial, errorMessage: failure.message)); }, (success) { - emit(state.copyWith(statusForVerifyOTP: ApiStatus.loaded, statusForResendOTP: ApiStatus.initial)); + emit(state.copyWith(verifyOtpStatus: ApiStatus.loaded, resendOtpStatus: ApiStatus.initial)); }, ); return unit; } - static const int _otpLength = 6; + final int _otpLength = 6; Future _onVerifyOTPChanged(VerifyOTPChanged event, Emitter emit) async { final otp = LengthValidator.dirty(_otpLength, event.otp); - emit(state.copyWith(otp: otp, statusForVerifyOTP: ApiStatus.initial, statusForResendOTP: ApiStatus.initial)); + emit(state.copyWith(otp: otp, verifyOtpStatus: ApiStatus.initial, resendOtpStatus: ApiStatus.initial)); return unit; } Future _onResendEmail(ResendEmailEvent event, Emitter emit) async { emit( state.copyWith( - statusForVerifyOTP: ApiStatus.initial, - statusForResendOTP: ApiStatus.loading, - otp: const LengthValidator.pure(_otpLength), + verifyOtpStatus: ApiStatus.initial, + resendOtpStatus: ApiStatus.loading, + otp: LengthValidator.pure(_otpLength), ), ); - final response = await authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: emailAddress)).run(); + final response = + await authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: state.email.value)).run(); response.fold( (failure) { - emit( - state.copyWith( - statusForResendOTP: ApiStatus.error, - statusForVerifyOTP: ApiStatus.initial, - errorMessage: failure.message, - ), - ); + emit(state.copyWith(resendOtpStatus: ApiStatus.error, verifyOtpStatus: ApiStatus.initial, errorMessage: failure.message)); }, (success) { - emit(state.copyWith(statusForVerifyOTP: ApiStatus.initial, statusForResendOTP: ApiStatus.loaded)); + emit(state.copyWith(verifyOtpStatus: ApiStatus.initial, resendOtpStatus: ApiStatus.loaded)); }, ); return unit; diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart index f6cde1e..f2e145f 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart @@ -29,3 +29,11 @@ final class VerifyButtonPressed extends VerifyOTPEvent { final class ResendEmailEvent extends VerifyOTPEvent { const ResendEmailEvent(); } + +class SetEmailEvent extends VerifyOTPEvent { + final String email; + const SetEmailEvent(this.email); + + @override + List get props => [email]; +} diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart index 7c50a65..7139166 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart @@ -5,8 +5,8 @@ import 'package:equatable/equatable.dart'; final class VerifyOTPState extends Equatable { const VerifyOTPState({ - this.statusForResendOTP = ApiStatus.initial, - this.statusForVerifyOTP = ApiStatus.initial, + this.resendOtpStatus = ApiStatus.initial, + this.verifyOtpStatus = ApiStatus.initial, this.email = const EmailValidator.pure(), this.errorMessage = '', this.otp = const LengthValidator.pure(6), @@ -15,21 +15,21 @@ final class VerifyOTPState extends Equatable { VerifyOTPState copyWith({ EmailValidator? email, LengthValidator? otp, - ApiStatus? statusForResendOTP, - ApiStatus? statusForVerifyOTP, + ApiStatus? resendOtpStatus, + ApiStatus? verifyOtpStatus, String? errorMessage, }) { return VerifyOTPState( email: email ?? this.email, otp: otp ?? this.otp, - statusForResendOTP: statusForResendOTP ?? this.statusForResendOTP, - statusForVerifyOTP: statusForVerifyOTP ?? this.statusForVerifyOTP, + resendOtpStatus: resendOtpStatus ?? this.resendOtpStatus, + verifyOtpStatus: verifyOtpStatus ?? this.verifyOtpStatus, errorMessage: errorMessage ?? this.errorMessage, ); } - final ApiStatus statusForResendOTP; - final ApiStatus statusForVerifyOTP; + final ApiStatus resendOtpStatus; + final ApiStatus verifyOtpStatus; final EmailValidator email; final LengthValidator otp; final String errorMessage; @@ -37,5 +37,5 @@ final class VerifyOTPState extends Equatable { bool get isValid => otp.isValid && email.isValid; @override - List get props => [statusForResendOTP, email, otp, errorMessage, statusForVerifyOTP]; + List get props => [resendOtpStatus, email, otp, errorMessage, verifyOtpStatus]; } diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index 89fa5d1..b3de6d1 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -28,8 +28,8 @@ class VerifyOTPScreen extends StatefulWidget implements AutoRouteWrapper { return RepositoryProvider( create: (context) => const AuthRepository(), child: BlocProvider( - lazy: false, - create: (context) => VerifyOTPBloc(RepositoryProvider.of(context), emailAddress ?? ''), + create: + (context) => VerifyOTPBloc(RepositoryProvider.of(context))..add(SetEmailEvent(emailAddress ?? '')), child: this, ), ); @@ -116,14 +116,13 @@ class _VerifyOTPScreenState extends State with TickerProviderSt padding: const EdgeInsets.all(Insets.small12), child: BlocConsumer( listener: (BuildContext context, VerifyOTPState state) { - if (state.statusForResendOTP == ApiStatus.error || state.statusForVerifyOTP == ApiStatus.error) { + if (state.resendOtpStatus == ApiStatus.error || state.verifyOtpStatus == ApiStatus.error) { final errorMessage = state.errorMessage; showAppSnackbar(context, errorMessage); } - if (state.statusForResendOTP == ApiStatus.loaded) { + if (state.resendOtpStatus == ApiStatus.loaded) { showAppSnackbar(context, context.t.otp_send_to_email); } - // Remove API success navigation, handled in static check }, builder: (context, state) { return SingleChildScrollView( @@ -141,17 +140,14 @@ class _VerifyOTPScreenState extends State with TickerProviderSt VSpace.medium16(), Padding(padding: const EdgeInsets.all(Insets.small12), child: AppText.sSemiBold(text: context.t.enter_otp)), VSpace.small12(), - BlocBuilder( - builder: - (context, state) => Pinput( - length: 6, - separatorBuilder: (index) => HSpace.xxsmall4(), - errorText: state.otp.error != null ? context.t.pin_incorrect : null, - onChanged: (value) { - context.read().add(VerifyOTPChanged(value)); - }, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - ), + Pinput( + length: 6, + separatorBuilder: (index) => HSpace.xxsmall4(), + errorText: state.otp.error != null ? context.t.pin_incorrect : null, + onChanged: (value) { + context.read().add(VerifyOTPChanged(value)); + }, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], ), VSpace.xsmall8(), if (_isTimerRunning) AppTimer(seconds: 30, onFinished: () {}), @@ -169,24 +165,18 @@ class _VerifyOTPScreenState extends State with TickerProviderSt textColor: context.colorScheme.primary400, onPressed: _isTimerRunning ? null : () => _onResendOTP(context), ), - const SizedBox(width: 8), + HSpace.xsmall8(), ], ), VSpace.large24(), - BlocBuilder( - builder: - (contextBuild, state) => Visibility( - visible: state.isValid, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Insets.large24), - child: AppButton( - isExpanded: true, - text: context.t.verify_otp, - isLoading: state.statusForVerifyOTP == ApiStatus.loading, - onPressed: () => _onVerifyOTP(contextBuild, state), - ), - ), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.large24), + child: AppButton( + isExpanded: true, + text: context.t.verify_otp, + isLoading: state.verifyOtpStatus == ApiStatus.loading, + onPressed: () => _onVerifyOTP(context, state), + ), ), ], ), From 2843236eb9582a108afa4af7394edfe71bcc8935 Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Fri, 27 Jun 2025 16:43:25 +0530 Subject: [PATCH 15/21] Refactor: Standardize email property in SetEmailEvent Moved the `email` property declaration after the constructor in `SetEmailEvent` for consistency with other event classes. --- apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart index f2e145f..480cd70 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart @@ -31,8 +31,8 @@ final class ResendEmailEvent extends VerifyOTPEvent { } class SetEmailEvent extends VerifyOTPEvent { - final String email; const SetEmailEvent(this.email); + final String email; @override List get props => [email]; From 76986cc5596b901a61b676a98a323055e2b63ec8 Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Fri, 27 Jun 2025 17:35:11 +0530 Subject: [PATCH 16/21] Refactor: Update VerifyOTPScreen and related BLoC - Made `emailAddress` a required parameter in `VerifyOTPScreen`. - Removed local timer management from `VerifyOTPScreen` and moved timer logic to `VerifyOTPBloc`. - Simplified OTP verification logic in `VerifyOTPScreen` and `VerifyOTPBloc` to use a static OTP for now. - Replaced `SingleChildScrollView` with `ListView` in `VerifyOTPScreen`. - Updated `VerifyOTPState` to include `isTimerRunning` and changed `email` type to `String`. - Added `StartTimerEvent` and `TimerFinishedEvent` to `VerifyOTPEvent`. - Updated `VerifyOTPBloc` to handle new timer events and use the string email. - Renamed `readOnly` to `isReadOnly` in `AppTextField`. --- .../verify_otp/bloc/verify_otp_bloc.dart | 39 ++-- .../verify_otp/bloc/verify_otp_event.dart | 8 + .../verify_otp/bloc/verify_otp_state.dart | 16 +- .../verify_otp/screens/verify_otp_screen.dart | 198 +++++++----------- .../src/widgets/molecules/app_textfield.dart | 8 +- 5 files changed, 118 insertions(+), 151 deletions(-) diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart index 70a74e6..cb63515 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:api_client/api_client.dart'; -import 'package:app_core/core/domain/validators/email_validator.dart'; import 'package:app_core/core/domain/validators/length_validator.dart'; import 'package:app_core/modules/auth/model/auth_request_model.dart'; import 'package:app_core/modules/auth/repository/auth_repository.dart'; @@ -18,29 +17,36 @@ class VerifyOTPBloc extends Bloc { on(_onVerifyButtonPressed); on(_onVerifyOTPChanged); on(_onResendEmail); + on((event, emit) { + emit(state.copyWith(isTimerRunning: true)); + }); + on((event, emit) { + emit(state.copyWith(isTimerRunning: false)); + }); } final AuthRepository authenticationRepository; void _onSetEmail(SetEmailEvent event, Emitter emit) { - emit(state.copyWith(email: EmailValidator.dirty(event.email))); + emit(state.copyWith(email: event.email)); } Future _onVerifyButtonPressed(VerifyButtonPressed event, Emitter emit) async { emit(state.copyWith(verifyOtpStatus: ApiStatus.loading, resendOtpStatus: ApiStatus.initial)); - final verifyOTPEither = - await authenticationRepository - .verifyOTP(AuthRequestModel.verifyOTP(email: state.email.value, token: state.otp.value)) - .run(); - - verifyOTPEither.fold( - (failure) { - emit(state.copyWith(verifyOtpStatus: ApiStatus.error, resendOtpStatus: ApiStatus.initial, errorMessage: failure.message)); - }, - (success) { - emit(state.copyWith(verifyOtpStatus: ApiStatus.loaded, resendOtpStatus: ApiStatus.initial)); - }, - ); + // Static OTP check for now + if (state.otp.value == '222222') { + emit(state.copyWith( + verifyOtpStatus: ApiStatus.loaded, + resendOtpStatus: ApiStatus.initial, + errorMessage: 'OTP verified successfully!', + )); + } else { + emit(state.copyWith( + verifyOtpStatus: ApiStatus.error, + resendOtpStatus: ApiStatus.initial, + errorMessage: 'Invalid OTP', + )); + } return unit; } @@ -60,7 +66,7 @@ class VerifyOTPBloc extends Bloc { ), ); final response = - await authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: state.email.value)).run(); + await authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: state.email)).run(); response.fold( (failure) { @@ -68,6 +74,7 @@ class VerifyOTPBloc extends Bloc { }, (success) { emit(state.copyWith(verifyOtpStatus: ApiStatus.initial, resendOtpStatus: ApiStatus.loaded)); + add(const StartTimerEvent()); }, ); return unit; diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart index 480cd70..645945f 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart @@ -37,3 +37,11 @@ class SetEmailEvent extends VerifyOTPEvent { @override List get props => [email]; } + +class StartTimerEvent extends VerifyOTPEvent { + const StartTimerEvent(); +} + +class TimerFinishedEvent extends VerifyOTPEvent { + const TimerFinishedEvent(); +} diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart index 7139166..77725b3 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart @@ -7,17 +7,19 @@ final class VerifyOTPState extends Equatable { const VerifyOTPState({ this.resendOtpStatus = ApiStatus.initial, this.verifyOtpStatus = ApiStatus.initial, - this.email = const EmailValidator.pure(), + this.email = '', this.errorMessage = '', this.otp = const LengthValidator.pure(6), + this.isTimerRunning = true, }); VerifyOTPState copyWith({ - EmailValidator? email, + String? email, LengthValidator? otp, ApiStatus? resendOtpStatus, ApiStatus? verifyOtpStatus, String? errorMessage, + bool? isTimerRunning, }) { return VerifyOTPState( email: email ?? this.email, @@ -25,17 +27,19 @@ final class VerifyOTPState extends Equatable { resendOtpStatus: resendOtpStatus ?? this.resendOtpStatus, verifyOtpStatus: verifyOtpStatus ?? this.verifyOtpStatus, errorMessage: errorMessage ?? this.errorMessage, + isTimerRunning: isTimerRunning ?? this.isTimerRunning, ); } final ApiStatus resendOtpStatus; final ApiStatus verifyOtpStatus; - final EmailValidator email; - final LengthValidator otp; + final String email; final String errorMessage; + final LengthValidator otp; + final bool isTimerRunning; - bool get isValid => otp.isValid && email.isValid; + bool get isValid => otp.isValid; @override - List get props => [resendOtpStatus, email, otp, errorMessage, verifyOtpStatus]; + List get props => [resendOtpStatus, email, otp, errorMessage, verifyOtpStatus, isTimerRunning]; } diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index b3de6d1..817007d 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:api_client/api_client.dart'; import 'package:app_core/app/routes/app_router.dart'; import 'package:app_core/core/presentation/widgets/app_snackbar.dart'; @@ -16,9 +14,9 @@ import 'package:pinput/pinput.dart'; @RoutePage() class VerifyOTPScreen extends StatefulWidget implements AutoRouteWrapper { - const VerifyOTPScreen({super.key, this.emailAddress}); + const VerifyOTPScreen({required this.emailAddress, super.key}); - final String? emailAddress; + final String emailAddress; @override State createState() => _VerifyOTPScreenState(); @@ -28,8 +26,7 @@ class VerifyOTPScreen extends StatefulWidget implements AutoRouteWrapper { return RepositoryProvider( create: (context) => const AuthRepository(), child: BlocProvider( - create: - (context) => VerifyOTPBloc(RepositoryProvider.of(context))..add(SetEmailEvent(emailAddress ?? '')), + create: (context) => VerifyOTPBloc(RepositoryProvider.of(context))..add(SetEmailEvent(emailAddress)), child: this, ), ); @@ -37,72 +34,11 @@ class VerifyOTPScreen extends StatefulWidget implements AutoRouteWrapper { } class _VerifyOTPScreenState extends State with TickerProviderStateMixin { - Timer? _timer; - int _secondsRemaining = 30; - bool _isTimerRunning = true; - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - @override void initState() { - _startTimer(); super.initState(); } - void _startTimer() { - if (!mounted) return; - setState(() { - _secondsRemaining = 30; - _isTimerRunning = true; - }); - _timer?.cancel(); - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (_secondsRemaining > 0) { - if (!mounted) { - timer.cancel(); - return; - } - setState(() { - _secondsRemaining--; - }); - } else { - if (!mounted) { - timer.cancel(); - return; - } - setState(() { - _isTimerRunning = false; - }); - _timer?.cancel(); - } - }); - } - - void _onResendOTP(BuildContext context) { - FocusScope.of(context).unfocus(); - context.read().add(const ResendEmailEvent()); - _startTimer(); - } - - void _onVerifyOTP(BuildContext contextBuild, VerifyOTPState state) { - TextInput.finishAutofillContext(); - FocusScope.of(context).unfocus(); - // Static check for OTP - if (state.otp.value == '222222') { - showAppSnackbar(contextBuild, 'OTP verified successfully!'); - contextBuild.maybePop(); - if (mounted) { - contextBuild.replaceRoute(const ChangePasswordRoute()); - } - } else { - showAppSnackbar(contextBuild, 'Invalid OTP', type: SnackbarType.failed); - } - } - @override Widget build(BuildContext context) { return AppScaffold( @@ -115,71 +51,83 @@ class _VerifyOTPScreenState extends State with TickerProviderSt child: Padding( padding: const EdgeInsets.all(Insets.small12), child: BlocConsumer( - listener: (BuildContext context, VerifyOTPState state) { - if (state.resendOtpStatus == ApiStatus.error || state.verifyOtpStatus == ApiStatus.error) { - final errorMessage = state.errorMessage; - showAppSnackbar(context, errorMessage); - } - if (state.resendOtpStatus == ApiStatus.loaded) { - showAppSnackbar(context, context.t.otp_send_to_email); + listener: (context, state) { + if (state.verifyOtpStatus == ApiStatus.loaded && state.otp.value == '222222') { + showAppSnackbar(context, 'OTP verified successfully!'); + context.replaceRoute(const ChangePasswordRoute()); + } else if (state.verifyOtpStatus == ApiStatus.error) { + showAppSnackbar(context, 'Invalid OTP', type: SnackbarType.failed); } }, builder: (context, state) { - return SingleChildScrollView( - child: Column( - children: [ - VSpace.large24(), - SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: Assets.images.logo.image(width: 100))), - VSpace.large24(), - SlideAndFadeAnimationWrapper( - delay: 200, - child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), - ), - VSpace.large24(), - AppTextField(initialValue: widget.emailAddress, label: context.t.email, readOnly: true), - VSpace.medium16(), - Padding(padding: const EdgeInsets.all(Insets.small12), child: AppText.sSemiBold(text: context.t.enter_otp)), - VSpace.small12(), - Pinput( - length: 6, - separatorBuilder: (index) => HSpace.xxsmall4(), - errorText: state.otp.error != null ? context.t.pin_incorrect : null, - onChanged: (value) { - context.read().add(VerifyOTPChanged(value)); - }, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], + return ListView( + children: [ + VSpace.large24(), + SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: Assets.images.logo.image(width: 100))), + VSpace.large24(), + SlideAndFadeAnimationWrapper( + delay: 200, + child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), + ), + VSpace.large24(), + AppTextField(initialValue: widget.emailAddress, label: context.t.email, isReadOnly: true), + VSpace.medium16(), + Center( + child: Padding( + padding: const EdgeInsets.all(Insets.small12), + child: AppText.sSemiBold(text: context.t.enter_otp), ), - VSpace.xsmall8(), - if (_isTimerRunning) AppTimer(seconds: 30, onFinished: () {}), - VSpace.small12(), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppText( - text: context.t.did_not_receive_otp, - style: context.textTheme?.xsRegular.copyWith(color: context.colorScheme.black), - ), - AppButton( - text: context.t.resend_otp, - buttonType: ButtonType.text, - textColor: context.colorScheme.primary400, - onPressed: _isTimerRunning ? null : () => _onResendOTP(context), - ), - HSpace.xsmall8(), - ], + ), + VSpace.small12(), + Pinput( + length: 6, + separatorBuilder: (index) => HSpace.xxsmall4(), + errorText: state.otp.error != null ? context.t.pin_incorrect : null, + onChanged: (value) { + context.read().add(VerifyOTPChanged(value)); + }, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + VSpace.xsmall8(), + if (state.isTimerRunning) + Center( + child: AppTimer( + seconds: 30, + onFinished: () { + context.read().add(const TimerFinishedEvent()); + }, + ), ), - VSpace.large24(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Insets.large24), - child: AppButton( - isExpanded: true, - text: context.t.verify_otp, - isLoading: state.verifyOtpStatus == ApiStatus.loading, - onPressed: () => _onVerifyOTP(context, state), + VSpace.small12(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppText.xsRegular(color: context.colorScheme.black, text: context.t.did_not_receive_otp), + AppButton( + text: context.t.resend_otp, + buttonType: ButtonType.text, + textColor: context.colorScheme.primary400, + onPressed: state.isTimerRunning + ? null + : () { + FocusScope.of(context).unfocus(); + context.read().add(const ResendEmailEvent()); + }, ), + HSpace.xsmall8(), + ], + ), + VSpace.large24(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.large24), + child: AppButton( + isExpanded: true, + text: context.t.verify_otp, + isLoading: state.verifyOtpStatus == ApiStatus.loading, + onPressed: () => context.read().add(const VerifyButtonPressed()), ), - ], - ), + ), + ], ); }, ), diff --git a/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart b/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart index 857dc3b..b3c96c2 100644 --- a/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart +++ b/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart @@ -9,7 +9,7 @@ class AppTextField extends StatefulWidget { this.textInputAction = TextInputAction.next, this.showLabel = true, this.hintText, - this.readOnly, + this.isReadOnly, this.keyboardType, this.initialValue, this.onChanged, @@ -39,7 +39,7 @@ class AppTextField extends StatefulWidget { this.backgroundColor, this.minLines, this.focusNode, - this.readOnly, + this.isReadOnly, this.autofillHints, this.hintTextBelowTextField, this.contentPadding, @@ -50,7 +50,7 @@ class AppTextField extends StatefulWidget { final String label; final String? initialValue; final String? hintText; - final bool? readOnly; + final bool? isReadOnly; final String? errorText; final String? hintTextBelowTextField; final TextInputAction? textInputAction; @@ -101,7 +101,7 @@ class _AppTextFieldState extends State { validator: widget.validator, obscureText: isObscureText, onChanged: widget.onChanged, - readOnly: widget.readOnly ?? false, + readOnly: widget.isReadOnly ?? false, autofillHints: widget.autofillHints, focusNode: widget.focusNode, maxLength: widget.maxLength, From c20d64a4400288afb3587bf529acdfb6e1804f92 Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Fri, 27 Jun 2025 17:35:25 +0530 Subject: [PATCH 17/21] Refactor: Removed unused `EmailValidator` import --- apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart index 77725b3..59d9906 100644 --- a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart @@ -1,5 +1,4 @@ import 'package:api_client/api_client.dart'; -import 'package:app_core/core/domain/validators/email_validator.dart'; import 'package:app_core/core/domain/validators/length_validator.dart'; import 'package:equatable/equatable.dart'; From d01a50e5c2832afde5de069bf371bcd7b86c98af Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Fri, 27 Jun 2025 17:40:14 +0530 Subject: [PATCH 18/21] Remove unused initState from VerifyOTPScreen --- .../verify_otp/screens/verify_otp_screen.dart | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index 817007d..2d9e1cb 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -34,11 +34,6 @@ class VerifyOTPScreen extends StatefulWidget implements AutoRouteWrapper { } class _VerifyOTPScreenState extends State with TickerProviderStateMixin { - @override - void initState() { - super.initState(); - } - @override Widget build(BuildContext context) { return AppScaffold( @@ -107,12 +102,13 @@ class _VerifyOTPScreenState extends State with TickerProviderSt text: context.t.resend_otp, buttonType: ButtonType.text, textColor: context.colorScheme.primary400, - onPressed: state.isTimerRunning - ? null - : () { - FocusScope.of(context).unfocus(); - context.read().add(const ResendEmailEvent()); - }, + onPressed: + state.isTimerRunning + ? null + : () { + FocusScope.of(context).unfocus(); + context.read().add(const ResendEmailEvent()); + }, ), HSpace.xsmall8(), ], From f84714a061b88b3e822fa7cbca50a4dd2655eacf Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Fri, 27 Jun 2025 17:43:50 +0530 Subject: [PATCH 19/21] Fix: Adjusted button padding on Verify OTP screen The padding for the "Verify OTP" button on the `VerifyOTPScreen` has been updated. --- .../verify_otp/screens/verify_otp_screen.dart | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index 2d9e1cb..c7c7d14 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -114,14 +114,12 @@ class _VerifyOTPScreenState extends State with TickerProviderSt ], ), VSpace.large24(), - Padding( + AppButton( + isExpanded: true, padding: const EdgeInsets.symmetric(horizontal: Insets.large24), - child: AppButton( - isExpanded: true, - text: context.t.verify_otp, - isLoading: state.verifyOtpStatus == ApiStatus.loading, - onPressed: () => context.read().add(const VerifyButtonPressed()), - ), + text: context.t.verify_otp, + isLoading: state.verifyOtpStatus == ApiStatus.loading, + onPressed: () => context.read().add(const VerifyButtonPressed()), ), ], ); From f8dc4c3a5ae60f8c0f04160128c171d470542598 Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Fri, 27 Jun 2025 17:45:26 +0530 Subject: [PATCH 20/21] Refactor: Removed unnecessary Padding from VerifyOTPScreen Removed the `Padding` widget that was wrapping the `BlocConsumer` in the `VerifyOTPScreen` and moved the padding to the `ListView` instead. --- .../verify_otp/screens/verify_otp_screen.dart | 152 +++++++++--------- 1 file changed, 75 insertions(+), 77 deletions(-) diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index c7c7d14..cc773c7 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -43,88 +43,86 @@ class _VerifyOTPScreenState extends State with TickerProviderSt title: context.t.verify_otp, ), body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(Insets.small12), - child: BlocConsumer( - listener: (context, state) { - if (state.verifyOtpStatus == ApiStatus.loaded && state.otp.value == '222222') { - showAppSnackbar(context, 'OTP verified successfully!'); - context.replaceRoute(const ChangePasswordRoute()); - } else if (state.verifyOtpStatus == ApiStatus.error) { - showAppSnackbar(context, 'Invalid OTP', type: SnackbarType.failed); - } - }, - builder: (context, state) { - return ListView( - children: [ - VSpace.large24(), - SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: Assets.images.logo.image(width: 100))), - VSpace.large24(), - SlideAndFadeAnimationWrapper( - delay: 200, - child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), + child: BlocConsumer( + listener: (context, state) { + if (state.verifyOtpStatus == ApiStatus.loaded && state.otp.value == '222222') { + showAppSnackbar(context, 'OTP verified successfully!'); + context.replaceRoute(const ChangePasswordRoute()); + } else if (state.verifyOtpStatus == ApiStatus.error) { + showAppSnackbar(context, 'Invalid OTP', type: SnackbarType.failed); + } + }, + builder: (context, state) { + return ListView( + padding: const EdgeInsets.all(Insets.small12), + children: [ + VSpace.large24(), + SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: Assets.images.logo.image(width: 100))), + VSpace.large24(), + SlideAndFadeAnimationWrapper( + delay: 200, + child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), + ), + VSpace.large24(), + AppTextField(initialValue: widget.emailAddress, label: context.t.email, isReadOnly: true), + VSpace.medium16(), + Center( + child: Padding( + padding: const EdgeInsets.all(Insets.small12), + child: AppText.sSemiBold(text: context.t.enter_otp), ), - VSpace.large24(), - AppTextField(initialValue: widget.emailAddress, label: context.t.email, isReadOnly: true), - VSpace.medium16(), + ), + VSpace.small12(), + Pinput( + length: 6, + separatorBuilder: (index) => HSpace.xxsmall4(), + errorText: state.otp.error != null ? context.t.pin_incorrect : null, + onChanged: (value) { + context.read().add(VerifyOTPChanged(value)); + }, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + VSpace.xsmall8(), + if (state.isTimerRunning) Center( - child: Padding( - padding: const EdgeInsets.all(Insets.small12), - child: AppText.sSemiBold(text: context.t.enter_otp), + child: AppTimer( + seconds: 30, + onFinished: () { + context.read().add(const TimerFinishedEvent()); + }, ), ), - VSpace.small12(), - Pinput( - length: 6, - separatorBuilder: (index) => HSpace.xxsmall4(), - errorText: state.otp.error != null ? context.t.pin_incorrect : null, - onChanged: (value) { - context.read().add(VerifyOTPChanged(value)); - }, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - ), - VSpace.xsmall8(), - if (state.isTimerRunning) - Center( - child: AppTimer( - seconds: 30, - onFinished: () { - context.read().add(const TimerFinishedEvent()); - }, - ), + VSpace.small12(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppText.xsRegular(color: context.colorScheme.black, text: context.t.did_not_receive_otp), + AppButton( + text: context.t.resend_otp, + buttonType: ButtonType.text, + textColor: context.colorScheme.primary400, + onPressed: + state.isTimerRunning + ? null + : () { + FocusScope.of(context).unfocus(); + context.read().add(const ResendEmailEvent()); + }, ), - VSpace.small12(), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppText.xsRegular(color: context.colorScheme.black, text: context.t.did_not_receive_otp), - AppButton( - text: context.t.resend_otp, - buttonType: ButtonType.text, - textColor: context.colorScheme.primary400, - onPressed: - state.isTimerRunning - ? null - : () { - FocusScope.of(context).unfocus(); - context.read().add(const ResendEmailEvent()); - }, - ), - HSpace.xsmall8(), - ], - ), - VSpace.large24(), - AppButton( - isExpanded: true, - padding: const EdgeInsets.symmetric(horizontal: Insets.large24), - text: context.t.verify_otp, - isLoading: state.verifyOtpStatus == ApiStatus.loading, - onPressed: () => context.read().add(const VerifyButtonPressed()), - ), - ], - ); - }, - ), + HSpace.xsmall8(), + ], + ), + VSpace.large24(), + AppButton( + isExpanded: true, + padding: const EdgeInsets.symmetric(horizontal: Insets.large24), + text: context.t.verify_otp, + isLoading: state.verifyOtpStatus == ApiStatus.loading, + onPressed: () => context.read().add(const VerifyButtonPressed()), + ), + ], + ); + }, ), ), ); From c36ea3cb3a5931e41865d4352cbd698715101697 Mon Sep 17 00:00:00 2001 From: tulsigohel Date: Fri, 27 Jun 2025 17:59:59 +0530 Subject: [PATCH 21/21] Refactor: Introduce AppOtpInput widget and update dependencies - Added `pinput` dependency to `app_ui/pubspec.yaml`. - Removed `pinput` dependency from `app_core/pubspec.yaml`. - Created `AppOtpInput` widget in `app_ui` to encapsulate OTP input logic. - Replaced direct usage of `Pinput` with `AppOtpInput` in `VerifyOTPScreen`. - Updated various dependency versions in `widgetbook/pubspec.lock`. - Updated Flutter SDK constraint in `widgetbook/pubspec.lock`. --- .../verify_otp/screens/verify_otp_screen.dart | 152 +++++++++--------- apps/app_core/pubspec.yaml | 2 +- .../src/widgets/molecules/app_otp_input.dart | 23 +++ .../lib/src/widgets/molecules/molecules.dart | 1 + packages/app_ui/pubspec.yaml | 1 + packages/widgetbook/pubspec.lock | 38 +++-- 6 files changed, 126 insertions(+), 91 deletions(-) create mode 100644 packages/app_ui/lib/src/widgets/molecules/app_otp_input.dart diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart index cc773c7..15f07f2 100644 --- a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -8,9 +8,7 @@ import 'package:app_translations/app_translations.dart'; import 'package:app_ui/app_ui.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:pinput/pinput.dart'; @RoutePage() class VerifyOTPScreen extends StatefulWidget implements AutoRouteWrapper { @@ -42,88 +40,84 @@ class _VerifyOTPScreenState extends State with TickerProviderSt automaticallyImplyLeading: true, title: context.t.verify_otp, ), - body: SafeArea( - child: BlocConsumer( - listener: (context, state) { - if (state.verifyOtpStatus == ApiStatus.loaded && state.otp.value == '222222') { - showAppSnackbar(context, 'OTP verified successfully!'); - context.replaceRoute(const ChangePasswordRoute()); - } else if (state.verifyOtpStatus == ApiStatus.error) { - showAppSnackbar(context, 'Invalid OTP', type: SnackbarType.failed); - } - }, - builder: (context, state) { - return ListView( - padding: const EdgeInsets.all(Insets.small12), - children: [ - VSpace.large24(), - SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: Assets.images.logo.image(width: 100))), - VSpace.large24(), - SlideAndFadeAnimationWrapper( - delay: 200, - child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), + body: BlocConsumer( + listener: (context, state) { + if (state.verifyOtpStatus == ApiStatus.loaded && state.otp.value == '222222') { + showAppSnackbar(context, 'OTP verified successfully!'); + context.replaceRoute(const ChangePasswordRoute()); + } else if (state.verifyOtpStatus == ApiStatus.error) { + showAppSnackbar(context, 'Invalid OTP', type: SnackbarType.failed); + } + }, + builder: (context, state) { + return ListView( + padding: const EdgeInsets.all(Insets.small12), + children: [ + VSpace.large24(), + SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: Assets.images.logo.image(width: 100))), + VSpace.large24(), + SlideAndFadeAnimationWrapper( + delay: 200, + child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), + ), + VSpace.large24(), + AppTextField(initialValue: widget.emailAddress, label: context.t.email, isReadOnly: true), + VSpace.medium16(), + Center( + child: Padding( + padding: const EdgeInsets.all(Insets.small12), + child: AppText.sSemiBold(text: context.t.enter_otp), ), - VSpace.large24(), - AppTextField(initialValue: widget.emailAddress, label: context.t.email, isReadOnly: true), - VSpace.medium16(), + ), + VSpace.small12(), + AppOtpInput( + errorText: state.otp.error != null ? context.t.pin_incorrect : null, + onChanged: (value) { + context.read().add(VerifyOTPChanged(value)); + }, + ), + + VSpace.xsmall8(), + if (state.isTimerRunning) Center( - child: Padding( - padding: const EdgeInsets.all(Insets.small12), - child: AppText.sSemiBold(text: context.t.enter_otp), + child: AppTimer( + seconds: 30, + onFinished: () { + context.read().add(const TimerFinishedEvent()); + }, ), ), - VSpace.small12(), - Pinput( - length: 6, - separatorBuilder: (index) => HSpace.xxsmall4(), - errorText: state.otp.error != null ? context.t.pin_incorrect : null, - onChanged: (value) { - context.read().add(VerifyOTPChanged(value)); - }, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - ), - VSpace.xsmall8(), - if (state.isTimerRunning) - Center( - child: AppTimer( - seconds: 30, - onFinished: () { - context.read().add(const TimerFinishedEvent()); - }, - ), + VSpace.small12(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppText.xsRegular(color: context.colorScheme.black, text: context.t.did_not_receive_otp), + AppButton( + text: context.t.resend_otp, + buttonType: ButtonType.text, + textColor: context.colorScheme.primary400, + onPressed: + state.isTimerRunning + ? null + : () { + FocusScope.of(context).unfocus(); + context.read().add(const ResendEmailEvent()); + }, ), - VSpace.small12(), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppText.xsRegular(color: context.colorScheme.black, text: context.t.did_not_receive_otp), - AppButton( - text: context.t.resend_otp, - buttonType: ButtonType.text, - textColor: context.colorScheme.primary400, - onPressed: - state.isTimerRunning - ? null - : () { - FocusScope.of(context).unfocus(); - context.read().add(const ResendEmailEvent()); - }, - ), - HSpace.xsmall8(), - ], - ), - VSpace.large24(), - AppButton( - isExpanded: true, - padding: const EdgeInsets.symmetric(horizontal: Insets.large24), - text: context.t.verify_otp, - isLoading: state.verifyOtpStatus == ApiStatus.loading, - onPressed: () => context.read().add(const VerifyButtonPressed()), - ), - ], - ); - }, - ), + HSpace.xsmall8(), + ], + ), + VSpace.large24(), + AppButton( + isExpanded: true, + padding: const EdgeInsets.symmetric(horizontal: Insets.large24), + text: context.t.verify_otp, + isLoading: state.verifyOtpStatus == ApiStatus.loading, + onPressed: () => context.read().add(const VerifyButtonPressed()), + ), + ], + ); + }, ), ); } diff --git a/apps/app_core/pubspec.yaml b/apps/app_core/pubspec.yaml index 469cf1e..b019f23 100644 --- a/apps/app_core/pubspec.yaml +++ b/apps/app_core/pubspec.yaml @@ -89,7 +89,7 @@ dependencies: # Launch URL url_launcher: ^6.3.1 - pinput: ^5.0.1 + dependency_overrides: web: ^1.0.0 diff --git a/packages/app_ui/lib/src/widgets/molecules/app_otp_input.dart b/packages/app_ui/lib/src/widgets/molecules/app_otp_input.dart new file mode 100644 index 0000000..c0ecb4e --- /dev/null +++ b/packages/app_ui/lib/src/widgets/molecules/app_otp_input.dart @@ -0,0 +1,23 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pinput/pinput.dart'; + +class AppOtpInput extends StatelessWidget { + const AppOtpInput({required this.onChanged, this.length = 6, this.errorText, super.key}); + + final void Function(String) onChanged; + final int length; + final String? errorText; + + @override + Widget build(BuildContext context) { + return Pinput( + length: length, + separatorBuilder: (index) => HSpace.xxsmall4(), + errorText: errorText, + onChanged: onChanged, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ); + } +} diff --git a/packages/app_ui/lib/src/widgets/molecules/molecules.dart b/packages/app_ui/lib/src/widgets/molecules/molecules.dart index fce2c9c..a8e4f56 100644 --- a/packages/app_ui/lib/src/widgets/molecules/molecules.dart +++ b/packages/app_ui/lib/src/widgets/molecules/molecules.dart @@ -4,6 +4,7 @@ export 'app_circular_progress_indicator.dart'; export 'app_dialog.dart'; export 'app_dropdown.dart'; export 'app_network_image.dart'; +export 'app_otp_input.dart'; export 'app_profile_image.dart'; export 'app_refresh_indicator.dart'; export 'app_textfield.dart'; diff --git a/packages/app_ui/pubspec.yaml b/packages/app_ui/pubspec.yaml index 085f719..31c4983 100644 --- a/packages/app_ui/pubspec.yaml +++ b/packages/app_ui/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: timeago: ^3.7.0 url_launcher: ^6.3.1 flutter_svg: ^2.0.17 + pinput: ^5.0.1 dev_dependencies: flutter_test: diff --git a/packages/widgetbook/pubspec.lock b/packages/widgetbook/pubspec.lock index 1ba8abf..6c60a02 100644 --- a/packages/widgetbook/pubspec.lock +++ b/packages/widgetbook/pubspec.lock @@ -52,10 +52,10 @@ packages: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" boolean_selector: dependency: transitive description: @@ -236,10 +236,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" ffi: dependency: transitive description: @@ -419,10 +419,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: @@ -583,6 +583,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + pinput: + dependency: transitive + description: + name: pinput + sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a" + url: "https://pub.dev" + source: hosted + version: "5.0.1" platform: dependency: transitive description: @@ -659,10 +667,10 @@ packages: dependency: transitive description: name: skeletonizer - sha256: "0dcacc51c144af4edaf37672072156f49e47036becbc394d7c51850c5c1e884b" + sha256: a9ddf63900947f4c0648372b6e9987bc2b028db9db843376db6767224d166c31 url: "https://pub.dev" source: hosted - version: "1.4.3" + version: "2.0.1" sky_engine: dependency: transitive description: flutter @@ -812,6 +820,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" url_launcher: dependency: transitive description: @@ -920,10 +936,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.0" watcher: dependency: transitive description: @@ -1006,4 +1022,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.7.0 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.31.0-0.0.pre"