Added two factor login, otp code widget and intl

This commit is contained in:
2025-12-19 13:47:31 +01:00
parent 076951af24
commit 0b2f1ff869
21 changed files with 1041 additions and 209 deletions

View File

@@ -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<String, dynamic>) {
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<String> 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<void> twoFALogin({required String token, required String code}) async {
Future<String> twoFALogin({
required String token,
required String code,
}) async {
try {
await _repository.post<Map<String, dynamic>>(
'/auth/login',
body: <String, dynamic>{'token': token, 'password': code},
final response = await _repository.post<String>(
'/auth/totp/login',
body: <String, dynamic>{
'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;
}

View File

@@ -22,7 +22,7 @@ class AuthRepositoryImpl implements AuthRepository {
}
@override
Future<void> twoFALogin({required String token, required String code}) {
Future<void> twoFactor({required String token, required String code}) {
return _remote.twoFALogin(token: token, code: code);
}
}

View File

@@ -5,5 +5,5 @@ abstract class AuthRepository {
Future<String> login({required String email, required String password});
Future<void> twoFALogin({required String token, required String code});
Future<void> twoFactor({required String token, required String code});
}

View File

@@ -1,3 +1,3 @@
abstract class LoginUseCase {
Future<void> login({required String email, required String password});
Future<String> login({required String email, required String password});
}

View File

@@ -7,7 +7,7 @@ class LoginUseCaseImpl implements LoginUseCase {
final AuthRepository _repository;
@override
Future<void> login({required String email, required String password}) {
Future<String> login({required String email, required String password}) {
return _repository.login(email: email, password: password);
}
}

View File

@@ -0,0 +1,3 @@
abstract class TwoFactorUseCase {
Future<void> twoFactor({required String token, required String code});
}

View File

@@ -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<void> twoFactor({required String token, required String code}) {
return _repository.twoFactor(token: token, code: code);
}
}

View File

@@ -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,24 +12,86 @@ class LoginScreen extends ConsumerWidget {
const LoginScreen({super.key, required this.navigationContract});
Future<void> _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<bool>(
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<void> 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),
body: SafeArea(
child: AbsorbPointer(
absorbing: isLoading,
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_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),
],
),
),
),
),
);
}
}
class _Header extends StatelessWidget {
const _Header({required this.theme});
final ThemePort theme;
@override
Widget build(BuildContext context) {
return Column(
children: [
Icon(
Icons.check,
@@ -40,8 +103,24 @@ class LoginScreen extends ConsumerWidget {
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold),
),
const SizedBox(height: 48),
],
);
}
}
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),
@@ -49,40 +128,90 @@ class LoginScreen extends ConsumerWidget {
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
),
_FieldErrorText.fromKey(errorKey: emailErrorKey),
],
);
}
}
const SizedBox(height: 24),
class _PasswordSection extends ConsumerWidget {
const _PasswordSection({required this.onSubmitted});
final Future<void> 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: state.passwordVisible,
showPassword: passwordVisible,
label: context.translate(I18n.password),
hint: "********",
hint: '********',
controller: vm.passwordController,
textInputAction: TextInputAction.done,
onSubmitted: (_) => onSignIn(),
onSubmitted: (_) => onSubmitted(),
),
_FieldErrorText.fromKey(errorKey: passwordErrorKey),
],
);
}
}
const SizedBox(height: 16),
class _ForgotPassword extends ConsumerWidget {
const _ForgotPassword({required this.navigationContract});
final NavigationContract navigationContract;
Align(
@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: state.isLoading
onPressed: isLoading
? () {}
: () =>
navigationContract.pushTo(AppRoutes.recoverPassword),
: () => navigationContract.pushTo(AppRoutes.recoverPassword),
size: 16,
),
),
);
}
}
const SizedBox(height: 30),
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: state.isLoading ? () {} : onSignIn,
onPressed: isLoading ? () {} : onSignIn,
text: context.translate(I18n.signIn),
color: theme.getColorFor(ThemeCode.buttonPrimary),
leading: state.isLoading
leading: isLoading
? const SizedBox(
height: 18,
width: 18,
@@ -93,10 +222,30 @@ class LoginScreen extends ConsumerWidget {
)
: 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,
),
),
),
],
);
}
}
const SizedBox(height: 30),
class _OrContinueWith extends StatelessWidget {
const _OrContinueWith({required this.theme});
final ThemePort theme;
Stack(
@override
Widget build(BuildContext context) {
return Stack(
children: [
const Divider(endIndent: 74, indent: 74),
Align(
@@ -108,15 +257,25 @@ class LoginScreen extends ConsumerWidget {
),
),
],
),
);
}
}
const SizedBox(height: 24),
class _SocialButtons extends ConsumerWidget {
const _SocialButtons({required this.theme});
final ThemePort theme;
Row(
@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: state.isLoading
onPressed: isLoading
? () {}
: () => Navigator.push(
context,
@@ -131,23 +290,35 @@ class LoginScreen extends ConsumerWidget {
),
const SizedBox(width: 16),
SecondaryButton(
onPressed: state.isLoading ? () {} : () {},
onPressed: isLoading ? () {} : () {},
radius: 16,
padding: 44,
icon: Icons.apple,
label: 'Apple',
),
],
),
);
}
}
const SizedBox(height: 30),
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: state.isLoading
onPressed: isLoading
? null
: () => navigationContract.goTo(AppRoutes.signup),
child: Text(
@@ -160,6 +331,32 @@ class LoginScreen extends ConsumerWidget {
),
),
],
);
}
}
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,
),
),
),
);

View File

@@ -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<TwoFactorUseCase>((ref) {
final authRepository = ref.read(authRepositoryProvider);
return TwoFactorUseCaseImpl(authRepository);
});

View File

@@ -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<LoginViewModel, LoginViewState>(
@@ -11,18 +14,30 @@ final loginViewModelProvider =
class LoginViewModel extends Notifier<LoginViewState> {
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<LoginViewState> {
}
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<LoginViewState> {
state = state.copyWith(passwordVisible: !state.passwordVisible);
}
Future<bool> 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<String?> 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 {
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;
}
}
state = state.copyWith(isLoading: true, errorMessage: '');
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<bool> twoFactor({required String token}) async {
if (!_validateOtp()) return false;
final code = state.otpCode.trim();
state = state.copyWith(isOtpLoading: true, otpError: '', errorMessage: '');
try {
await _loginUseCase.login(email: email, password: password);
await _twoFactorUseCase.twoFactor(token: token, code: code);
if (!ref.mounted) return false;
state = state.copyWith(isLoading: 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<LoginViewState> {
void disposeControllers() {
emailController.removeListener(_onEmailChanged);
passwordController.removeListener(_onPasswordChanged);
otpController.removeListener(_onOtpChanged);
emailController.dispose();
passwordController.dispose();
otpController.dispose();
}
}

View File

@@ -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;
}

View File

@@ -14,7 +14,7 @@ T _$identity<T>(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<LoginViewState> 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 extends Object?>(TResult Function( String email, String password, bool passwordVisible, String errorMessage, bool isLoading)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(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 extends Object?>(TResult Function( String email, String password, bool passwordVisible, String errorMessage, bool isLoading) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(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 extends Object?>(TResult? Function( String email, String password, bool passwordVisible, String errorMessage, bool isLoading)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(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,
));
}

View File

@@ -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<String>? onChanged;
final ValueChanged<String>? onCompleted;
final double boxSize;
final double gap;
@override
State<OtpCodeFields> createState() => _OtpCodeFieldsState();
}
class _OtpCodeFieldsState extends State<OtpCodeFields> {
late final List<TextEditingController> _controllers;
late final List<FocusNode> _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,
),
),
),
],
);
}
}

View File

@@ -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<void> 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)),
),
],
),
);
}
}

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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';
}