From 0b2f1ff86922712f0a8d70bba669e11555332ab4 Mon Sep 17 00:00:00 2001 From: JulianAlcala Date: Fri, 19 Dec 2025 13:47:31 +0100 Subject: [PATCH] Added two factor login, otp code widget and intl --- .../auth_remote_datasource_impl.dart | 82 ++- .../repositories/auth_repository_impl.dart | 2 +- .../domain/repositories/auth_repository.dart | 2 +- .../features/login/domain/login_use_case.dart | 2 +- .../login/domain/login_use_case_impl.dart | 2 +- .../login/domain/two_factor_use_case.dart | 3 + .../domain/two_factor_use_case_impl.dart | 13 + .../login/presentation/login_screen.dart | 475 +++++++++++++----- .../providers/two_factor_provider.dart | 9 + .../presentation/state/login_view_model.dart | 155 +++++- .../presentation/state/login_view_state.dart | 7 + .../state/login_view_state.freezed.dart | 65 ++- .../presentation/widgets/otp_code_fields.dart | 211 ++++++++ .../widgets/two_factor_bottom_sheet.dart | 118 +++++ packages/sf_localizations/assets/l10n/de.json | 15 +- packages/sf_localizations/assets/l10n/en.json | 15 +- packages/sf_localizations/assets/l10n/es.json | 15 +- packages/sf_localizations/assets/l10n/fr.json | 15 +- packages/sf_localizations/assets/l10n/it.json | 15 +- packages/sf_localizations/assets/l10n/pt.json | 15 +- .../lib/src/generated/i18n.dart | 14 + 21 files changed, 1041 insertions(+), 209 deletions(-) create mode 100644 modules/auth/lib/src/features/login/domain/two_factor_use_case.dart create mode 100644 modules/auth/lib/src/features/login/domain/two_factor_use_case_impl.dart create mode 100644 modules/auth/lib/src/features/login/presentation/providers/two_factor_provider.dart create mode 100644 modules/auth/lib/src/features/login/presentation/widgets/otp_code_fields.dart create mode 100644 modules/auth/lib/src/features/login/presentation/widgets/two_factor_bottom_sheet.dart diff --git a/modules/auth/lib/src/core/data/datasource/auth_remote_datasource_impl.dart b/modules/auth/lib/src/core/data/datasource/auth_remote_datasource_impl.dart index 0510575a..4c9a04a4 100644 --- a/modules/auth/lib/src/core/data/datasource/auth_remote_datasource_impl.dart +++ b/modules/auth/lib/src/core/data/datasource/auth_remote_datasource_impl.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; import 'package:sf_infrastructure/sf_infrastructure.dart'; @@ -38,22 +40,6 @@ class AuthRemoteDatasourceImpl implements AuthRemoteDatasource { } } - Exception _mapDioError(DioException error, {required String defaultMessage}) { - final responseData = error.response?.data; - String message = defaultMessage; - - if (responseData is Map) { - final serverMessage = responseData['message']; - if (serverMessage is String && serverMessage.isNotEmpty) { - message = serverMessage; - } - } else if (error.message != null && error.message!.isNotEmpty) { - message = error.message!; - } - - return Exception(message); - } - @override Future login({ required String email, @@ -67,19 +53,71 @@ class AuthRemoteDatasourceImpl implements AuthRemoteDatasource { final token = response.data!['token']; return token; } on DioException catch (error) { - throw _mapDioError(error, defaultMessage: 'Error in login'); + throw _mapDioError( + error, + defaultMessage: error.message ?? 'Error in login', + ); } } @override - Future twoFALogin({required String token, required String code}) async { + Future twoFALogin({ + required String token, + required String code, + }) async { try { - await _repository.post>( - '/auth/login', - body: {'token': token, 'password': code}, + final response = await _repository.post( + '/auth/totp/login', + body: { + 'token': token, + 'code': code, + 'rememberMe': true, + }, ); + + final data = response.data; + if (data == null || data.isEmpty) { + throw Exception('Empty response from /auth/totp/login'); + } + + return data; } on DioException catch (error) { - throw _mapDioError(error, defaultMessage: 'Error in login'); + throw _mapDioError(error, defaultMessage: 'Error in twoFALogin'); } } } + +Exception _mapDioError(DioException error, {required String defaultMessage}) { + final apiMsg = _extractApiMessage(error.response?.data); + final msg = apiMsg ?? error.message ?? defaultMessage; + return Exception(msg); +} + +String? _extractApiMessage(Object? data) { + if (data == null) return null; + + if (data is Map) { + final errorObj = data['error']; + if (errorObj is Map && errorObj['message'] is String) { + return (errorObj['message'] as String).trim(); + } + if (data['message'] is String) { + return (data['message'] as String).trim(); + } + return null; + } + + if (data is String) { + final raw = data.trim(); + if (raw.isEmpty) return null; + + try { + final decoded = jsonDecode(raw); + return _extractApiMessage(decoded); + } catch (_) { + return raw; + } + } + + return null; +} diff --git a/modules/auth/lib/src/core/data/repositories/auth_repository_impl.dart b/modules/auth/lib/src/core/data/repositories/auth_repository_impl.dart index 1bfc3593..a0bb81c6 100644 --- a/modules/auth/lib/src/core/data/repositories/auth_repository_impl.dart +++ b/modules/auth/lib/src/core/data/repositories/auth_repository_impl.dart @@ -22,7 +22,7 @@ class AuthRepositoryImpl implements AuthRepository { } @override - Future twoFALogin({required String token, required String code}) { + Future twoFactor({required String token, required String code}) { return _remote.twoFALogin(token: token, code: code); } } diff --git a/modules/auth/lib/src/core/domain/repositories/auth_repository.dart b/modules/auth/lib/src/core/domain/repositories/auth_repository.dart index 43c8fdb4..c011369d 100644 --- a/modules/auth/lib/src/core/domain/repositories/auth_repository.dart +++ b/modules/auth/lib/src/core/domain/repositories/auth_repository.dart @@ -5,5 +5,5 @@ abstract class AuthRepository { Future login({required String email, required String password}); - Future twoFALogin({required String token, required String code}); + Future twoFactor({required String token, required String code}); } diff --git a/modules/auth/lib/src/features/login/domain/login_use_case.dart b/modules/auth/lib/src/features/login/domain/login_use_case.dart index f6ab5b70..6b822de9 100644 --- a/modules/auth/lib/src/features/login/domain/login_use_case.dart +++ b/modules/auth/lib/src/features/login/domain/login_use_case.dart @@ -1,3 +1,3 @@ abstract class LoginUseCase { - Future login({required String email, required String password}); + Future login({required String email, required String password}); } diff --git a/modules/auth/lib/src/features/login/domain/login_use_case_impl.dart b/modules/auth/lib/src/features/login/domain/login_use_case_impl.dart index 6688eee8..926e15ad 100644 --- a/modules/auth/lib/src/features/login/domain/login_use_case_impl.dart +++ b/modules/auth/lib/src/features/login/domain/login_use_case_impl.dart @@ -7,7 +7,7 @@ class LoginUseCaseImpl implements LoginUseCase { final AuthRepository _repository; @override - Future login({required String email, required String password}) { + Future login({required String email, required String password}) { return _repository.login(email: email, password: password); } } diff --git a/modules/auth/lib/src/features/login/domain/two_factor_use_case.dart b/modules/auth/lib/src/features/login/domain/two_factor_use_case.dart new file mode 100644 index 00000000..e7c29742 --- /dev/null +++ b/modules/auth/lib/src/features/login/domain/two_factor_use_case.dart @@ -0,0 +1,3 @@ +abstract class TwoFactorUseCase { + Future twoFactor({required String token, required String code}); +} diff --git a/modules/auth/lib/src/features/login/domain/two_factor_use_case_impl.dart b/modules/auth/lib/src/features/login/domain/two_factor_use_case_impl.dart new file mode 100644 index 00000000..ce7ee7e1 --- /dev/null +++ b/modules/auth/lib/src/features/login/domain/two_factor_use_case_impl.dart @@ -0,0 +1,13 @@ +import 'package:auth/src/core/domain/repositories/auth_repository.dart'; +import 'package:auth/src/features/login/domain/two_factor_use_case.dart'; + +class TwoFactorUseCaseImpl implements TwoFactorUseCase { + TwoFactorUseCaseImpl(this._repository); + + final AuthRepository _repository; + + @override + Future twoFactor({required String token, required String code}) { + return _repository.twoFactor(token: token, code: code); + } +} diff --git a/modules/auth/lib/src/features/login/presentation/login_screen.dart b/modules/auth/lib/src/features/login/presentation/login_screen.dart index 331c59e9..ae18c21b 100644 --- a/modules/auth/lib/src/features/login/presentation/login_screen.dart +++ b/modules/auth/lib/src/features/login/presentation/login_screen.dart @@ -1,5 +1,6 @@ import 'package:auth/src/features/login/presentation/loading_google_screen.dart'; import 'package:auth/src/features/login/presentation/state/login_view_model.dart'; +import 'package:auth/src/features/login/presentation/widgets/two_factor_bottom_sheet.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -11,155 +12,351 @@ class LoginScreen extends ConsumerWidget { const LoginScreen({super.key, required this.navigationContract}); + Future _onLogIn(BuildContext context, WidgetRef ref) async { + FocusManager.instance.primaryFocus?.unfocus(); + + final vm = ref.read(loginViewModelProvider.notifier); + + final String? token = await vm.login(); + if (!context.mounted) return; + + if (token == null || token.isEmpty) return; + + vm.prepareTwoFactor(); + + final bool? verified = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + isDismissible: false, + enableDrag: false, + backgroundColor: Colors.transparent, + builder: (_) => TwoFactorBottomSheet(token: token), + ); + + if (!context.mounted) return; + + if (verified == true) { + navigationContract.goTo(AppRoutes.dashboardHome); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final theme = ref.watch(themePortProvider); - final vm = ref.read(loginViewModelProvider.notifier); - final state = ref.watch(loginViewModelProvider); - - Future onSignIn() async { - FocusScope.of(context).unfocus(); - final login = await vm.login(); - if (login) navigationContract.goTo(AppRoutes.dashboardHome); - } + final bool isLoading = ref.watch( + loginViewModelProvider.select((s) => s.isLoading), + ); return Scaffold( backgroundColor: theme.getColorFor(ThemeCode.backgroundPrimary), - body: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.check, - color: theme.getColorFor(ThemeCode.buttonPrimary), - size: 54, - ), - Text( - context.translate(I18n.welcome), - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 48), - - CustomTextField( - hint: context.translate(I18n.username), - label: context.translate(I18n.username), - controller: vm.emailController, - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - ), - - const SizedBox(height: 24), - - CustomTextField( - showPassword: state.passwordVisible, - label: context.translate(I18n.password), - hint: "********", - controller: vm.passwordController, - - textInputAction: TextInputAction.done, - onSubmitted: (_) => onSignIn(), - ), - - const SizedBox(height: 16), - - Align( - alignment: Alignment.topLeft, - child: CustomTextButton( - text: context.translate(I18n.forgotPassword), - onPressed: state.isLoading - ? () {} - : () => - navigationContract.pushTo(AppRoutes.recoverPassword), - size: 16, - ), - ), - - const SizedBox(height: 30), - - PrimaryButton( - onPressed: state.isLoading ? () {} : onSignIn, - text: context.translate(I18n.signIn), - color: theme.getColorFor(ThemeCode.buttonPrimary), - leading: state.isLoading - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : null, - ), - - const SizedBox(height: 30), - - Stack( + body: SafeArea( + child: AbsorbPointer( + absorbing: isLoading, + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Divider(endIndent: 74, indent: 74), - Align( - alignment: Alignment.center, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14), - color: theme.getColorFor(ThemeCode.backgroundPrimary), - child: Text(context.translate(I18n.orContinueWith)), - ), + _Header(theme: theme), + SizedBox(height: 48), + const _EmailSection(), + SizedBox(height: 24), + _PasswordSection(onSubmitted: () => _onLogIn(context, ref)), + SizedBox(height: 16), + _ForgotPassword(navigationContract: navigationContract), + SizedBox(height: 30), + _SignInSection( + theme: theme, + onSignIn: () => _onLogIn(context, ref), ), + SizedBox(height: 30), + _OrContinueWith(theme: theme), + SizedBox(height: 24), + _SocialButtons(theme: theme), + SizedBox(height: 30), + _Footer(navigationContract: navigationContract), ], ), - - const SizedBox(height: 24), - - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SecondaryButton( - onPressed: state.isLoading - ? () {} - : () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const LoadingGoogleScreen(), - ), - ), - radius: 16, - padding: 44, - text: context.translate(I18n.google), - label: 'Google', - ), - const SizedBox(width: 16), - SecondaryButton( - onPressed: state.isLoading ? () {} : () {}, - radius: 16, - padding: 44, - icon: Icons.apple, - label: 'Apple', - ), - ], - ), - - const SizedBox(height: 30), - - Text( - context.translate(I18n.dontHaveAccount), - style: const TextStyle(fontSize: 18, letterSpacing: 0), - ), - TextButton( - onPressed: state.isLoading - ? null - : () => navigationContract.goTo(AppRoutes.signup), - child: Text( - context.translate(I18n.createOneNow), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - letterSpacing: 0, - ), - ), - ), - ], + ), + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header({required this.theme}); + final ThemePort theme; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Icon( + Icons.check, + color: theme.getColorFor(ThemeCode.buttonPrimary), + size: 54, + ), + Text( + context.translate(I18n.welcome), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold), + ), + ], + ); + } +} + +class _EmailSection extends ConsumerWidget { + const _EmailSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final vm = ref.read(loginViewModelProvider.notifier); + final String emailErrorKey = ref.watch( + loginViewModelProvider.select((s) => s.emailError), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CustomTextField( + hint: context.translate(I18n.username), + label: context.translate(I18n.username), + controller: vm.emailController, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + ), + _FieldErrorText.fromKey(errorKey: emailErrorKey), + ], + ); + } +} + +class _PasswordSection extends ConsumerWidget { + const _PasswordSection({required this.onSubmitted}); + final Future Function() onSubmitted; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final vm = ref.read(loginViewModelProvider.notifier); + + final bool passwordVisible = ref.watch( + loginViewModelProvider.select((s) => s.passwordVisible), + ); + final String passwordErrorKey = ref.watch( + loginViewModelProvider.select((s) => s.passwordError), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CustomTextField( + showPassword: passwordVisible, + label: context.translate(I18n.password), + hint: '********', + controller: vm.passwordController, + textInputAction: TextInputAction.done, + onSubmitted: (_) => onSubmitted(), + ), + _FieldErrorText.fromKey(errorKey: passwordErrorKey), + ], + ); + } +} + +class _ForgotPassword extends ConsumerWidget { + const _ForgotPassword({required this.navigationContract}); + final NavigationContract navigationContract; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bool isLoading = ref.watch( + loginViewModelProvider.select((s) => s.isLoading), + ); + + return Align( + alignment: Alignment.topLeft, + child: CustomTextButton( + text: context.translate(I18n.forgotPassword), + onPressed: isLoading + ? () {} + : () => navigationContract.pushTo(AppRoutes.recoverPassword), + size: 16, + ), + ); + } +} + +class _SignInSection extends ConsumerWidget { + const _SignInSection({required this.onSignIn, required this.theme}); + + final VoidCallback onSignIn; + final ThemePort theme; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bool isLoading = ref.watch( + loginViewModelProvider.select((s) => s.isLoading), + ); + final String errorMessage = ref.watch( + loginViewModelProvider.select((s) => s.errorMessage), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PrimaryButton( + onPressed: isLoading ? () {} : onSignIn, + text: context.translate(I18n.signIn), + color: theme.getColorFor(ThemeCode.buttonPrimary), + leading: isLoading + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : null, + ), + if (errorMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + errorMessage, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 13, + ), + ), + ), + ], + ); + } +} + +class _OrContinueWith extends StatelessWidget { + const _OrContinueWith({required this.theme}); + final ThemePort theme; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + const Divider(endIndent: 74, indent: 74), + Align( + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14), + color: theme.getColorFor(ThemeCode.backgroundPrimary), + child: Text(context.translate(I18n.orContinueWith)), + ), + ), + ], + ); + } +} + +class _SocialButtons extends ConsumerWidget { + const _SocialButtons({required this.theme}); + final ThemePort theme; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bool isLoading = ref.watch( + loginViewModelProvider.select((s) => s.isLoading), + ); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SecondaryButton( + onPressed: isLoading + ? () {} + : () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LoadingGoogleScreen(), + ), + ), + radius: 16, + padding: 44, + text: context.translate(I18n.google), + label: 'Google', + ), + const SizedBox(width: 16), + SecondaryButton( + onPressed: isLoading ? () {} : () {}, + radius: 16, + padding: 44, + icon: Icons.apple, + label: 'Apple', + ), + ], + ); + } +} + +class _Footer extends ConsumerWidget { + const _Footer({required this.navigationContract}); + final NavigationContract navigationContract; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bool isLoading = ref.watch( + loginViewModelProvider.select((s) => s.isLoading), + ); + + return Column( + children: [ + Text( + context.translate(I18n.dontHaveAccount), + style: const TextStyle(fontSize: 18, letterSpacing: 0), + ), + TextButton( + onPressed: isLoading + ? null + : () => navigationContract.goTo(AppRoutes.signup), + child: Text( + context.translate(I18n.createOneNow), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + letterSpacing: 0, + ), + ), + ), + ], + ); + } +} + +class _FieldErrorText extends StatelessWidget { + const _FieldErrorText._({required this.text}); + final String text; + + factory _FieldErrorText.fromKey({required String errorKey}) { + return _FieldErrorText._(text: errorKey); + } + + @override + Widget build(BuildContext context) { + if (text.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + context.translate(text), + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), ), ), ); diff --git a/modules/auth/lib/src/features/login/presentation/providers/two_factor_provider.dart b/modules/auth/lib/src/features/login/presentation/providers/two_factor_provider.dart new file mode 100644 index 00000000..5eac987d --- /dev/null +++ b/modules/auth/lib/src/features/login/presentation/providers/two_factor_provider.dart @@ -0,0 +1,9 @@ +import 'package:auth/src/core/providers/auth_repository_provider.dart'; +import 'package:auth/src/features/login/domain/two_factor_use_case.dart'; +import 'package:auth/src/features/login/domain/two_factor_use_case_impl.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final twoFactorUseCaseProvider = Provider.autoDispose((ref) { + final authRepository = ref.read(authRepositoryProvider); + return TwoFactorUseCaseImpl(authRepository); +}); diff --git a/modules/auth/lib/src/features/login/presentation/state/login_view_model.dart b/modules/auth/lib/src/features/login/presentation/state/login_view_model.dart index 28a3f5fa..4ce9568d 100644 --- a/modules/auth/lib/src/features/login/presentation/state/login_view_model.dart +++ b/modules/auth/lib/src/features/login/presentation/state/login_view_model.dart @@ -1,8 +1,11 @@ import 'package:auth/src/features/login/domain/login_use_case.dart'; +import 'package:auth/src/features/login/domain/two_factor_use_case.dart'; import 'package:auth/src/features/login/presentation/providers/login_provider.dart'; +import 'package:auth/src/features/login/presentation/providers/two_factor_provider.dart'; import 'package:auth/src/features/login/presentation/state/login_view_state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sf_localizations/sf_localizations.dart'; final loginViewModelProvider = NotifierProvider.autoDispose( @@ -11,18 +14,30 @@ final loginViewModelProvider = class LoginViewModel extends Notifier { late final LoginUseCase _loginUseCase; + late final TwoFactorUseCase _twoFactorUseCase; + late final TextEditingController emailController; late final TextEditingController passwordController; + late final TextEditingController otpController; + + static final RegExp _emailRegex = RegExp( + r'^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$', + caseSensitive: false, + ); + @override LoginViewState build() { _loginUseCase = ref.read(loginUseCaseProvider); + _twoFactorUseCase = ref.read(twoFactorUseCaseProvider); emailController = TextEditingController(); passwordController = TextEditingController(); + otpController = TextEditingController(); emailController.addListener(_onEmailChanged); passwordController.addListener(_onPasswordChanged); + otpController.addListener(_onOtpChanged); ref.onDispose(disposeControllers); @@ -30,17 +45,24 @@ class LoginViewModel extends Notifier { } void _onEmailChanged() { - if (emailController.text != state.email) { - state = state.copyWith(email: emailController.text, errorMessage: ''); + final text = emailController.text; + if (text == state.email) return; + + state = state.copyWith(email: text, errorMessage: ''); + + if (state.showErrors) { + state = state.copyWith(emailError: _emailErrorFor(text)); } } void _onPasswordChanged() { - if (passwordController.text != state.password) { - state = state.copyWith( - password: passwordController.text, - errorMessage: '', - ); + final text = passwordController.text; + if (text == state.password) return; + + state = state.copyWith(password: text, errorMessage: ''); + + if (state.showErrors) { + state = state.copyWith(passwordError: _passwordErrorFor(text)); } } @@ -48,24 +70,122 @@ class LoginViewModel extends Notifier { state = state.copyWith(passwordVisible: !state.passwordVisible); } - Future login() async { + bool _isValidEmail(String email) => _emailRegex.hasMatch(email); + + String _emailErrorFor(String value) { + final email = value.trim(); + if (email.isEmpty) return I18n.errorEmailRequired; + if (!_isValidEmail(email)) return I18n.errorEmailInvalid; + return ''; + } + + String _passwordErrorFor(String value) { + final password = value.trim(); + if (password.isEmpty) return I18n.errorPasswordRequired; + if (password.length < 6) return I18n.errorPasswordMinLength; + return ''; + } + + bool _validateForm() { + final emailError = _emailErrorFor(state.email); + final passwordError = _passwordErrorFor(state.password); + + state = state.copyWith( + showErrors: true, + emailError: emailError, + passwordError: passwordError, + errorMessage: '', + ); + + return emailError.isEmpty && passwordError.isEmpty; + } + + Future login() async { + if (!_validateForm()) return null; + final email = state.email.trim(); final password = state.password.trim(); - if (email.isEmpty) { - state = state.copyWith(errorMessage: 'errorMessageIsEmpty'); - return false; - } - state = state.copyWith(isLoading: true, errorMessage: ''); + try { - await _loginUseCase.login(email: email, password: password); - if (!ref.mounted) return false; + final String token = await _loginUseCase.login( + email: email, + password: password, + ); + + if (!ref.mounted) return null; + state = state.copyWith(isLoading: false); + return token; + } catch (e) { + if (!ref.mounted) return null; + + state = state.copyWith(isLoading: false, errorMessage: e.toString()); + return null; + } + } + + void prepareTwoFactor() { + otpController.text = ''; + state = state.copyWith(otpCode: '', otpError: '', isOtpLoading: false); + } + + void _onOtpChanged() { + final raw = otpController.text; + if (raw == state.otpCode) return; + + state = state.copyWith(otpCode: raw, otpError: ''); + + if (state.showErrors) { + state = state.copyWith(otpError: _otpErrorFor(raw)); + } + } + + void setOtpCode(String code) { + state = state.copyWith(otpCode: code, otpError: ''); + } + + String _otpErrorFor(String value) { + final code = value.trim(); + if (code.isEmpty) return I18n.errorTwoFactorCodeRequired; + if (code.length != 6) return I18n.errorTwoFactorCodeInvalidLength; + return ''; + } + + bool _validateOtp() { + final otpError = _otpErrorFor(state.otpCode); + + state = state.copyWith( + showErrors: true, + otpError: otpError, + errorMessage: '', + ); + + return otpError.isEmpty; + } + + Future twoFactor({required String token}) async { + if (!_validateOtp()) return false; + + final code = state.otpCode.trim(); + + state = state.copyWith(isOtpLoading: true, otpError: '', errorMessage: ''); + + try { + await _twoFactorUseCase.twoFactor(token: token, code: code); + + if (!ref.mounted) return false; + + state = state.copyWith(isOtpLoading: false); return true; } catch (e) { if (!ref.mounted) return false; - state = state.copyWith(isLoading: false, errorMessage: e.toString()); + + state = state.copyWith( + isOtpLoading: false, + otpError: I18n.errorTwoFactorCodeInvalid, + ); return false; } } @@ -73,7 +193,10 @@ class LoginViewModel extends Notifier { void disposeControllers() { emailController.removeListener(_onEmailChanged); passwordController.removeListener(_onPasswordChanged); + otpController.removeListener(_onOtpChanged); + emailController.dispose(); passwordController.dispose(); + otpController.dispose(); } } diff --git a/modules/auth/lib/src/features/login/presentation/state/login_view_state.dart b/modules/auth/lib/src/features/login/presentation/state/login_view_state.dart index 5dfb3fbd..f3783870 100644 --- a/modules/auth/lib/src/features/login/presentation/state/login_view_state.dart +++ b/modules/auth/lib/src/features/login/presentation/state/login_view_state.dart @@ -8,7 +8,14 @@ abstract class LoginViewState with _$LoginViewState { @Default('') String email, @Default('') String password, @Default(false) bool passwordVisible, + @Default('') String emailError, + @Default('') String passwordError, @Default('') String errorMessage, + @Default(false) bool showErrors, @Default(false) bool isLoading, + @Default('') String token, + @Default('') String otpCode, + @Default('') String otpError, + @Default(false) bool isOtpLoading, }) = _LoginViewState; } diff --git a/modules/auth/lib/src/features/login/presentation/state/login_view_state.freezed.dart b/modules/auth/lib/src/features/login/presentation/state/login_view_state.freezed.dart index 5d528dd7..fdadb062 100644 --- a/modules/auth/lib/src/features/login/presentation/state/login_view_state.freezed.dart +++ b/modules/auth/lib/src/features/login/presentation/state/login_view_state.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$LoginViewState { - String get email; String get password; bool get passwordVisible; String get errorMessage; bool get isLoading; + String get email; String get password; bool get passwordVisible; String get emailError; String get passwordError; String get errorMessage; bool get showErrors; bool get isLoading; String get token; String get otpCode; String get otpError; bool get isOtpLoading; /// Create a copy of LoginViewState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -25,16 +25,16 @@ $LoginViewStateCopyWith get copyWith => _$LoginViewStateCopyWith @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is LoginViewState&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&(identical(other.passwordVisible, passwordVisible) || other.passwordVisible == passwordVisible)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is LoginViewState&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&(identical(other.passwordVisible, passwordVisible) || other.passwordVisible == passwordVisible)&&(identical(other.emailError, emailError) || other.emailError == emailError)&&(identical(other.passwordError, passwordError) || other.passwordError == passwordError)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.showErrors, showErrors) || other.showErrors == showErrors)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.token, token) || other.token == token)&&(identical(other.otpCode, otpCode) || other.otpCode == otpCode)&&(identical(other.otpError, otpError) || other.otpError == otpError)&&(identical(other.isOtpLoading, isOtpLoading) || other.isOtpLoading == isOtpLoading)); } @override -int get hashCode => Object.hash(runtimeType,email,password,passwordVisible,errorMessage,isLoading); +int get hashCode => Object.hash(runtimeType,email,password,passwordVisible,emailError,passwordError,errorMessage,showErrors,isLoading,token,otpCode,otpError,isOtpLoading); @override String toString() { - return 'LoginViewState(email: $email, password: $password, passwordVisible: $passwordVisible, errorMessage: $errorMessage, isLoading: $isLoading)'; + return 'LoginViewState(email: $email, password: $password, passwordVisible: $passwordVisible, emailError: $emailError, passwordError: $passwordError, errorMessage: $errorMessage, showErrors: $showErrors, isLoading: $isLoading, token: $token, otpCode: $otpCode, otpError: $otpError, isOtpLoading: $isOtpLoading)'; } @@ -45,7 +45,7 @@ abstract mixin class $LoginViewStateCopyWith<$Res> { factory $LoginViewStateCopyWith(LoginViewState value, $Res Function(LoginViewState) _then) = _$LoginViewStateCopyWithImpl; @useResult $Res call({ - String email, String password, bool passwordVisible, String errorMessage, bool isLoading + String email, String password, bool passwordVisible, String emailError, String passwordError, String errorMessage, bool showErrors, bool isLoading, String token, String otpCode, String otpError, bool isOtpLoading }); @@ -62,13 +62,20 @@ class _$LoginViewStateCopyWithImpl<$Res> /// Create a copy of LoginViewState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? email = null,Object? password = null,Object? passwordVisible = null,Object? errorMessage = null,Object? isLoading = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? email = null,Object? password = null,Object? passwordVisible = null,Object? emailError = null,Object? passwordError = null,Object? errorMessage = null,Object? showErrors = null,Object? isLoading = null,Object? token = null,Object? otpCode = null,Object? otpError = null,Object? isOtpLoading = null,}) { return _then(_self.copyWith( email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable as String,passwordVisible: null == passwordVisible ? _self.passwordVisible : passwordVisible // ignore: cast_nullable_to_non_nullable -as bool,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable -as String,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,emailError: null == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable +as String,passwordError: null == passwordError ? _self.passwordError : passwordError // ignore: cast_nullable_to_non_nullable +as String,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String,showErrors: null == showErrors ? _self.showErrors : showErrors // ignore: cast_nullable_to_non_nullable +as bool,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String,otpCode: null == otpCode ? _self.otpCode : otpCode // ignore: cast_nullable_to_non_nullable +as String,otpError: null == otpError ? _self.otpError : otpError // ignore: cast_nullable_to_non_nullable +as String,isOtpLoading: null == isOtpLoading ? _self.isOtpLoading : isOtpLoading // ignore: cast_nullable_to_non_nullable as bool, )); } @@ -154,10 +161,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String email, String password, bool passwordVisible, String errorMessage, bool isLoading)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String email, String password, bool passwordVisible, String emailError, String passwordError, String errorMessage, bool showErrors, bool isLoading, String token, String otpCode, String otpError, bool isOtpLoading)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _LoginViewState() when $default != null: -return $default(_that.email,_that.password,_that.passwordVisible,_that.errorMessage,_that.isLoading);case _: +return $default(_that.email,_that.password,_that.passwordVisible,_that.emailError,_that.passwordError,_that.errorMessage,_that.showErrors,_that.isLoading,_that.token,_that.otpCode,_that.otpError,_that.isOtpLoading);case _: return orElse(); } @@ -175,10 +182,10 @@ return $default(_that.email,_that.password,_that.passwordVisible,_that.errorMess /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String email, String password, bool passwordVisible, String errorMessage, bool isLoading) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String email, String password, bool passwordVisible, String emailError, String passwordError, String errorMessage, bool showErrors, bool isLoading, String token, String otpCode, String otpError, bool isOtpLoading) $default,) {final _that = this; switch (_that) { case _LoginViewState(): -return $default(_that.email,_that.password,_that.passwordVisible,_that.errorMessage,_that.isLoading);case _: +return $default(_that.email,_that.password,_that.passwordVisible,_that.emailError,_that.passwordError,_that.errorMessage,_that.showErrors,_that.isLoading,_that.token,_that.otpCode,_that.otpError,_that.isOtpLoading);case _: throw StateError('Unexpected subclass'); } @@ -195,10 +202,10 @@ return $default(_that.email,_that.password,_that.passwordVisible,_that.errorMess /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String email, String password, bool passwordVisible, String errorMessage, bool isLoading)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String email, String password, bool passwordVisible, String emailError, String passwordError, String errorMessage, bool showErrors, bool isLoading, String token, String otpCode, String otpError, bool isOtpLoading)? $default,) {final _that = this; switch (_that) { case _LoginViewState() when $default != null: -return $default(_that.email,_that.password,_that.passwordVisible,_that.errorMessage,_that.isLoading);case _: +return $default(_that.email,_that.password,_that.passwordVisible,_that.emailError,_that.passwordError,_that.errorMessage,_that.showErrors,_that.isLoading,_that.token,_that.otpCode,_that.otpError,_that.isOtpLoading);case _: return null; } @@ -210,14 +217,21 @@ return $default(_that.email,_that.password,_that.passwordVisible,_that.errorMess class _LoginViewState implements LoginViewState { - const _LoginViewState({this.email = '', this.password = '', this.passwordVisible = false, this.errorMessage = '', this.isLoading = false}); + const _LoginViewState({this.email = '', this.password = '', this.passwordVisible = false, this.emailError = '', this.passwordError = '', this.errorMessage = '', this.showErrors = false, this.isLoading = false, this.token = '', this.otpCode = '', this.otpError = '', this.isOtpLoading = false}); @override@JsonKey() final String email; @override@JsonKey() final String password; @override@JsonKey() final bool passwordVisible; +@override@JsonKey() final String emailError; +@override@JsonKey() final String passwordError; @override@JsonKey() final String errorMessage; +@override@JsonKey() final bool showErrors; @override@JsonKey() final bool isLoading; +@override@JsonKey() final String token; +@override@JsonKey() final String otpCode; +@override@JsonKey() final String otpError; +@override@JsonKey() final bool isOtpLoading; /// Create a copy of LoginViewState /// with the given fields replaced by the non-null parameter values. @@ -229,16 +243,16 @@ _$LoginViewStateCopyWith<_LoginViewState> get copyWith => __$LoginViewStateCopyW @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoginViewState&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&(identical(other.passwordVisible, passwordVisible) || other.passwordVisible == passwordVisible)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoginViewState&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&(identical(other.passwordVisible, passwordVisible) || other.passwordVisible == passwordVisible)&&(identical(other.emailError, emailError) || other.emailError == emailError)&&(identical(other.passwordError, passwordError) || other.passwordError == passwordError)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.showErrors, showErrors) || other.showErrors == showErrors)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.token, token) || other.token == token)&&(identical(other.otpCode, otpCode) || other.otpCode == otpCode)&&(identical(other.otpError, otpError) || other.otpError == otpError)&&(identical(other.isOtpLoading, isOtpLoading) || other.isOtpLoading == isOtpLoading)); } @override -int get hashCode => Object.hash(runtimeType,email,password,passwordVisible,errorMessage,isLoading); +int get hashCode => Object.hash(runtimeType,email,password,passwordVisible,emailError,passwordError,errorMessage,showErrors,isLoading,token,otpCode,otpError,isOtpLoading); @override String toString() { - return 'LoginViewState(email: $email, password: $password, passwordVisible: $passwordVisible, errorMessage: $errorMessage, isLoading: $isLoading)'; + return 'LoginViewState(email: $email, password: $password, passwordVisible: $passwordVisible, emailError: $emailError, passwordError: $passwordError, errorMessage: $errorMessage, showErrors: $showErrors, isLoading: $isLoading, token: $token, otpCode: $otpCode, otpError: $otpError, isOtpLoading: $isOtpLoading)'; } @@ -249,7 +263,7 @@ abstract mixin class _$LoginViewStateCopyWith<$Res> implements $LoginViewStateCo factory _$LoginViewStateCopyWith(_LoginViewState value, $Res Function(_LoginViewState) _then) = __$LoginViewStateCopyWithImpl; @override @useResult $Res call({ - String email, String password, bool passwordVisible, String errorMessage, bool isLoading + String email, String password, bool passwordVisible, String emailError, String passwordError, String errorMessage, bool showErrors, bool isLoading, String token, String otpCode, String otpError, bool isOtpLoading }); @@ -266,13 +280,20 @@ class __$LoginViewStateCopyWithImpl<$Res> /// Create a copy of LoginViewState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? email = null,Object? password = null,Object? passwordVisible = null,Object? errorMessage = null,Object? isLoading = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? email = null,Object? password = null,Object? passwordVisible = null,Object? emailError = null,Object? passwordError = null,Object? errorMessage = null,Object? showErrors = null,Object? isLoading = null,Object? token = null,Object? otpCode = null,Object? otpError = null,Object? isOtpLoading = null,}) { return _then(_LoginViewState( email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable as String,passwordVisible: null == passwordVisible ? _self.passwordVisible : passwordVisible // ignore: cast_nullable_to_non_nullable -as bool,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable -as String,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,emailError: null == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable +as String,passwordError: null == passwordError ? _self.passwordError : passwordError // ignore: cast_nullable_to_non_nullable +as String,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable +as String,showErrors: null == showErrors ? _self.showErrors : showErrors // ignore: cast_nullable_to_non_nullable +as bool,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool,token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable +as String,otpCode: null == otpCode ? _self.otpCode : otpCode // ignore: cast_nullable_to_non_nullable +as String,otpError: null == otpError ? _self.otpError : otpError // ignore: cast_nullable_to_non_nullable +as String,isOtpLoading: null == isOtpLoading ? _self.isOtpLoading : isOtpLoading // ignore: cast_nullable_to_non_nullable as bool, )); } diff --git a/modules/auth/lib/src/features/login/presentation/widgets/otp_code_fields.dart b/modules/auth/lib/src/features/login/presentation/widgets/otp_code_fields.dart new file mode 100644 index 00000000..4b0fc151 --- /dev/null +++ b/modules/auth/lib/src/features/login/presentation/widgets/otp_code_fields.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class OtpCodeFields extends StatefulWidget { + const OtpCodeFields({ + super.key, + this.length = 6, + this.autofocus = true, + this.enabled = true, + this.errorText, + this.onChanged, + this.onCompleted, + this.boxSize = 48, + this.gap = 10, + }); + + final int length; + final bool autofocus; + final bool enabled; + final String? errorText; + final ValueChanged? onChanged; + final ValueChanged? onCompleted; + final double boxSize; + final double gap; + + @override + State createState() => _OtpCodeFieldsState(); +} + +class _OtpCodeFieldsState extends State { + late final List _controllers; + late final List _focusNodes; + + String get _code => _controllers.map((c) => c.text.trim()).join(); + + @override + void initState() { + super.initState(); + _controllers = List.generate(widget.length, (_) => TextEditingController()); + _focusNodes = List.generate(widget.length, (_) => FocusNode()); + + if (widget.autofocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _focusNodes.first.requestFocus(); + }); + } + } + + @override + void dispose() { + for (final c in _controllers) { + c.dispose(); + } + for (final f in _focusNodes) { + f.dispose(); + } + super.dispose(); + } + + void _emit() { + final code = _code; + widget.onChanged?.call(code); + if (code.length == widget.length && + !_controllers.any((c) => c.text.isEmpty)) { + widget.onCompleted?.call(code); + } + } + + void _setFromPaste(String value) { + final digits = value.replaceAll(RegExp(r'\D'), ''); + if (digits.isEmpty) return; + + final clipped = digits.length > widget.length + ? digits.substring(0, widget.length) + : digits; + + for (var i = 0; i < widget.length; i++) { + _controllers[i].text = i < clipped.length ? clipped[i] : ''; + } + + final nextIndex = clipped.length >= widget.length + ? widget.length - 1 + : clipped.length; + + _focusNodes[nextIndex].requestFocus(); + _emit(); + setState(() {}); + } + + void _onChanged(int index, String value) { + if (!mounted) return; + + if (value.length > 1) { + _setFromPaste(value); + return; + } + + if (value.isNotEmpty && index < widget.length - 1) { + _focusNodes[index + 1].requestFocus(); + } + + _emit(); + setState(() {}); + } + + KeyEventResult _onKey(int index, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + if (event.logicalKey == LogicalKeyboardKey.backspace) { + final current = _controllers[index].text; + + if (current.isEmpty && index > 0) { + _controllers[index - 1].text = ''; + _focusNodes[index - 1].requestFocus(); + _emit(); + setState(() {}); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final borderColor = widget.errorText == null || widget.errorText!.isEmpty + ? Theme.of(context).dividerColor + : Theme.of(context).colorScheme.error; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + alignment: WrapAlignment.center, + spacing: widget.gap, + children: List.generate(widget.length, (i) { + return SizedBox( + width: widget.boxSize, + height: widget.boxSize, + child: Focus( + onKeyEvent: (_, event) => _onKey(i, event), + child: TextField( + enabled: widget.enabled, + controller: _controllers[i], + focusNode: _focusNodes[i], + keyboardType: TextInputType.number, + textInputAction: i == widget.length - 1 + ? TextInputAction.done + : TextInputAction.next, + textAlign: TextAlign.center, + maxLength: 1, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(1), + ], + decoration: InputDecoration( + counterText: '', + contentPadding: EdgeInsets.zero, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: borderColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 1.6, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + width: 1.2, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + width: 1.6, + ), + ), + ), + onChanged: (v) => _onChanged(i, v), + ), + ), + ); + }), + ), + if (widget.errorText != null && widget.errorText!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + widget.errorText!, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ), + ], + ); + } +} diff --git a/modules/auth/lib/src/features/login/presentation/widgets/two_factor_bottom_sheet.dart b/modules/auth/lib/src/features/login/presentation/widgets/two_factor_bottom_sheet.dart new file mode 100644 index 00000000..27d5d21d --- /dev/null +++ b/modules/auth/lib/src/features/login/presentation/widgets/two_factor_bottom_sheet.dart @@ -0,0 +1,118 @@ +import 'package:auth/src/features/login/presentation/state/login_view_model.dart'; +import 'package:auth/src/features/login/presentation/widgets/otp_code_fields.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sf_localizations/sf_localizations.dart'; + +class TwoFactorBottomSheet extends ConsumerWidget { + const TwoFactorBottomSheet({super.key, required this.token}); + + final String token; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(themePortProvider); + final vm = ref.read(loginViewModelProvider.notifier); + + final String otpErrorKey = ref.watch( + loginViewModelProvider.select((s) => s.otpError), + ); + final bool isOtpLoading = ref.watch( + loginViewModelProvider.select((s) => s.isOtpLoading), + ); + final String otpCode = ref.watch( + loginViewModelProvider.select((s) => s.otpCode), + ); + + final String otpErrorText = otpErrorKey.isEmpty + ? '' + : context.translate(otpErrorKey); + Future onVerify() async { + FocusManager.instance.primaryFocus?.unfocus(); + + final ok = await vm.twoFactor(token: token); + if (!context.mounted) return; + + if (ok) Navigator.of(context).pop(true); + } + + return Container( + decoration: BoxDecoration( + color: theme.getColorFor(ThemeCode.backgroundPrimary), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: EdgeInsets.fromLTRB( + 24, + 12, + 24, + 24 + MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 4, + width: 48, + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(999), + ), + ), + const SizedBox(height: 16), + + Text( + context.translate(I18n.twoFactorTitle), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + context.translate(I18n.twoFactorSubtitle), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 20), + + OtpCodeFields( + length: 6, + enabled: !isOtpLoading, + errorText: otpErrorText.isEmpty ? null : otpErrorText, + onChanged: (code) { + vm.setOtpCode(code); + }, + onCompleted: (_) => onVerify(), + ), + const SizedBox(height: 20), + + PrimaryButton( + onPressed: (isOtpLoading || otpCode.trim().length != 6) + ? () {} + : onVerify, + text: context.translate(I18n.twoFactorVerify), + color: theme.getColorFor(ThemeCode.buttonPrimary), + leading: isOtpLoading + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : null, + ), + + const SizedBox(height: 12), + + TextButton( + onPressed: isOtpLoading + ? null + : () => Navigator.of(context).pop(false), + child: Text(context.translate(I18n.close)), + ), + ], + ), + ); + } +} diff --git a/packages/sf_localizations/assets/l10n/de.json b/packages/sf_localizations/assets/l10n/de.json index 95f198e1..e406016c 100644 --- a/packages/sf_localizations/assets/l10n/de.json +++ b/packages/sf_localizations/assets/l10n/de.json @@ -30,5 +30,18 @@ "google": "Google", "apple": "Apple", "dontHaveAccount": "Du hast noch kein Konto?", - "createOneNow": "Jetzt eines erstellen" + "createOneNow": "Jetzt eines erstellen", + "errorEmailRequired": "E-Mail ist erforderlich.", + "errorEmailInvalid": "Bitte gib eine gültige E-Mail-Adresse ein.", + "errorPasswordRequired": "Passwort ist erforderlich.", + "errorPasswordMinLength": "Das Passwort muss mindestens 6 Zeichen lang sein.", + "twoFactorTitle": "Zwei-Faktor-Authentifizierung", + "twoFactorSubtitle": "Gib den 6-stelligen Code ein, um fortzufahren.", + "twoFactorCodeLabel": "Bestätigungscode", + "twoFactorCodeHint": "6-stelliger Code", + "twoFactorVerify": "Bestätigen", + "close": "Schließen", + "errorTwoFactorCodeRequired": "Der Bestätigungscode ist erforderlich.", + "errorTwoFactorCodeInvalidLength": "Der Code muss 6-stellig sein.", + "errorTwoFactorCodeInvalid": "Ungültiger Code. Bitte versuche es erneut." } \ No newline at end of file diff --git a/packages/sf_localizations/assets/l10n/en.json b/packages/sf_localizations/assets/l10n/en.json index c106c693..5fce03fd 100755 --- a/packages/sf_localizations/assets/l10n/en.json +++ b/packages/sf_localizations/assets/l10n/en.json @@ -30,5 +30,18 @@ "google": "Google", "apple": "Apple", "dontHaveAccount": "Don't have an account?", - "createOneNow": "Create one now" + "createOneNow": "Create one now", + "errorEmailRequired": "Email is required.", + "errorEmailInvalid": "Please enter a valid email address.", + "errorPasswordRequired": "Password is required.", + "errorPasswordMinLength": "Password must be at least 6 characters.", + "twoFactorTitle": "Two-factor authentication", + "twoFactorSubtitle": "Enter the 6-digit code to continue.", + "twoFactorCodeLabel": "Verification code", + "twoFactorCodeHint": "6-digit code", + "twoFactorVerify": "Verify", + "close": "Close", + "errorTwoFactorCodeRequired": "The verification code is required.", + "errorTwoFactorCodeInvalidLength": "The code must be 6 digits.", + "errorTwoFactorCodeInvalid": "Invalid code. Please try again." } \ No newline at end of file diff --git a/packages/sf_localizations/assets/l10n/es.json b/packages/sf_localizations/assets/l10n/es.json index 2f05efaf..21067dfd 100644 --- a/packages/sf_localizations/assets/l10n/es.json +++ b/packages/sf_localizations/assets/l10n/es.json @@ -30,5 +30,18 @@ "google": "Google", "apple": "Apple", "dontHaveAccount": "¿No tienes cuenta?", - "createOneNow": "Crear una ahora" + "createOneNow": "Crear una ahora", + "errorEmailRequired": "El email es obligatorio.", + "errorEmailInvalid": "Introduce un email válido.", + "errorPasswordRequired": "La contraseña es obligatoria.", + "errorPasswordMinLength": "La contraseña debe tener al menos 6 caracteres.", + "twoFactorTitle": "Autenticación en dos pasos", + "twoFactorSubtitle": "Introduce el código de 6 dígitos para continuar.", + "twoFactorCodeLabel": "Código de verificación", + "twoFactorCodeHint": "Código de 6 dígitos", + "twoFactorVerify": "Verificar", + "close": "Cerrar", + "errorTwoFactorCodeRequired": "El código de verificación es obligatorio.", + "errorTwoFactorCodeInvalidLength": "El código debe tener 6 dígitos.", + "errorTwoFactorCodeInvalid": "Código incorrecto. Inténtalo de nuevo." } \ No newline at end of file diff --git a/packages/sf_localizations/assets/l10n/fr.json b/packages/sf_localizations/assets/l10n/fr.json index 806b14d9..3b2c8f68 100644 --- a/packages/sf_localizations/assets/l10n/fr.json +++ b/packages/sf_localizations/assets/l10n/fr.json @@ -30,5 +30,18 @@ "google": "Google", "apple": "Apple", "dontHaveAccount": "Tu n'as pas de compte ?", - "createOneNow": "Crée-en un maintenant" + "createOneNow": "Crée-en un maintenant", + "errorEmailRequired": "L'e-mail est obligatoire.", + "errorEmailInvalid": "Veuillez saisir une adresse e-mail valide.", + "errorPasswordRequired": "Le mot de passe est obligatoire.", + "errorPasswordMinLength": "Le mot de passe doit contenir au moins 6 caractères.", + "twoFactorTitle": "Authentification à deux facteurs", + "twoFactorSubtitle": "Saisissez le code à 6 chiffres pour continuer.", + "twoFactorCodeLabel": "Code de vérification", + "twoFactorCodeHint": "Code à 6 chiffres", + "twoFactorVerify": "Vérifier", + "close": "Fermer", + "errorTwoFactorCodeRequired": "Le code de vérification est obligatoire.", + "errorTwoFactorCodeInvalidLength": "Le code doit contenir 6 chiffres.", + "errorTwoFactorCodeInvalid": "Code incorrect. Veuillez réessayer." } \ No newline at end of file diff --git a/packages/sf_localizations/assets/l10n/it.json b/packages/sf_localizations/assets/l10n/it.json index a5a3055e..516bc426 100644 --- a/packages/sf_localizations/assets/l10n/it.json +++ b/packages/sf_localizations/assets/l10n/it.json @@ -30,5 +30,18 @@ "google": "Google", "apple": "Apple", "dontHaveAccount": "Non hai un account?", - "createOneNow": "Creane uno adesso" + "createOneNow": "Creane uno adesso", + "errorEmailRequired": "L'email è obbligatoria.", + "errorEmailInvalid": "Inserisci un'email valida.", + "errorPasswordRequired": "La password è obbligatoria.", + "errorPasswordMinLength": "La password deve contenere almeno 6 caratteri.", + "twoFactorTitle": "Autenticazione a due fattori", + "twoFactorSubtitle": "Inserisci il codice a 6 cifre per continuare.", + "twoFactorCodeLabel": "Codice di verifica", + "twoFactorCodeHint": "Codice a 6 cifre", + "twoFactorVerify": "Verifica", + "close": "Chiudi", + "errorTwoFactorCodeRequired": "Il codice di verifica è obbligatorio.", + "errorTwoFactorCodeInvalidLength": "Il codice deve essere di 6 cifre.", + "errorTwoFactorCodeInvalid": "Codice non valido. Riprova." } \ No newline at end of file diff --git a/packages/sf_localizations/assets/l10n/pt.json b/packages/sf_localizations/assets/l10n/pt.json index 0bad019b..a3ff7bea 100644 --- a/packages/sf_localizations/assets/l10n/pt.json +++ b/packages/sf_localizations/assets/l10n/pt.json @@ -30,5 +30,18 @@ "google": "Google", "apple": "Apple", "dontHaveAccount": "Não tem conta?", - "createOneNow": "Criar uma agora" + "createOneNow": "Criar uma agora", + "errorEmailRequired": "O e-mail é obrigatório.", + "errorEmailInvalid": "Introduz um e-mail válido.", + "errorPasswordRequired": "A palavra-passe é obrigatória.", + "errorPasswordMinLength": "A palavra-passe deve ter pelo menos 6 caracteres.", + "twoFactorTitle": "Autenticação de dois fatores", + "twoFactorSubtitle": "Introduz o código de 6 dígitos para continuar.", + "twoFactorCodeLabel": "Código de verificação", + "twoFactorCodeHint": "Código de 6 dígitos", + "twoFactorVerify": "Verificar", + "close": "Fechar", + "errorTwoFactorCodeRequired": "O código de verificação é obrigatório.", + "errorTwoFactorCodeInvalidLength": "O código deve ter 6 dígitos.", + "errorTwoFactorCodeInvalid": "Código inválido. Tenta novamente." } \ No newline at end of file diff --git a/packages/sf_localizations/lib/src/generated/i18n.dart b/packages/sf_localizations/lib/src/generated/i18n.dart index 0170a2df..16430635 100755 --- a/packages/sf_localizations/lib/src/generated/i18n.dart +++ b/packages/sf_localizations/lib/src/generated/i18n.dart @@ -35,4 +35,18 @@ class I18n { static const String apple = "apple"; static const String dontHaveAccount = "dontHaveAccount"; static const String createOneNow = "createOneNow"; + static const String errorEmailRequired = 'errorEmailRequired'; + static const String errorEmailInvalid = 'errorEmailInvalid'; + static const String errorPasswordRequired = 'errorPasswordRequired'; + static const String errorPasswordMinLength = 'errorPasswordMinLength'; + static const String twoFactorTitle = 'twoFactorTitle'; + static const String twoFactorSubtitle = 'twoFactorSubtitle'; + static const String twoFactorCodeLabel = 'twoFactorCodeLabel'; + static const String twoFactorCodeHint = 'twoFactorCodeHint'; + static const String twoFactorVerify = 'twoFactorVerify'; + static const String close = 'close'; + static const String errorTwoFactorCodeRequired = 'errorTwoFactorCodeRequired'; + static const String errorTwoFactorCodeInvalidLength = + 'errorTwoFactorCodeInvalidLength'; + static const String errorTwoFactorCodeInvalid = 'errorTwoFactorCodeInvalid'; }