Added two factor login, otp code widget and intl
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:sf_infrastructure/sf_infrastructure.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
|
@override
|
||||||
Future<String> login({
|
Future<String> login({
|
||||||
required String email,
|
required String email,
|
||||||
@@ -67,19 +53,71 @@ class AuthRemoteDatasourceImpl implements AuthRemoteDatasource {
|
|||||||
final token = response.data!['token'];
|
final token = response.data!['token'];
|
||||||
return token;
|
return token;
|
||||||
} on DioException catch (error) {
|
} on DioException catch (error) {
|
||||||
throw _mapDioError(error, defaultMessage: 'Error in login');
|
throw _mapDioError(
|
||||||
|
error,
|
||||||
|
defaultMessage: error.message ?? 'Error in login',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> twoFALogin({required String token, required String code}) async {
|
Future<String> twoFALogin({
|
||||||
|
required String token,
|
||||||
|
required String code,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
await _repository.post<Map<String, dynamic>>(
|
final response = await _repository.post<String>(
|
||||||
'/auth/login',
|
'/auth/totp/login',
|
||||||
body: <String, dynamic>{'token': token, 'password': code},
|
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) {
|
} 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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);
|
return _remote.twoFALogin(token: token, code: code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ abstract class AuthRepository {
|
|||||||
|
|
||||||
Future<String> login({required String email, required String password});
|
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});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
abstract class LoginUseCase {
|
abstract class LoginUseCase {
|
||||||
Future<void> login({required String email, required String password});
|
Future<String> login({required String email, required String password});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class LoginUseCaseImpl implements LoginUseCase {
|
|||||||
final AuthRepository _repository;
|
final AuthRepository _repository;
|
||||||
|
|
||||||
@override
|
@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);
|
return _repository.login(email: email, password: password);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
abstract class TwoFactorUseCase {
|
||||||
|
Future<void> twoFactor({required String token, required String code});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:auth/src/features/login/presentation/loading_google_screen.dart';
|
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/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:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -11,24 +12,86 @@ class LoginScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
const LoginScreen({super.key, required this.navigationContract});
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = ref.watch(themePortProvider);
|
final theme = ref.watch(themePortProvider);
|
||||||
final vm = ref.read(loginViewModelProvider.notifier);
|
final bool isLoading = ref.watch(
|
||||||
final state = ref.watch(loginViewModelProvider);
|
loginViewModelProvider.select((s) => s.isLoading),
|
||||||
|
);
|
||||||
Future<void> onSignIn() async {
|
|
||||||
FocusScope.of(context).unfocus();
|
|
||||||
final login = await vm.login();
|
|
||||||
if (login) navigationContract.goTo(AppRoutes.dashboardHome);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: theme.getColorFor(ThemeCode.backgroundPrimary),
|
backgroundColor: theme.getColorFor(ThemeCode.backgroundPrimary),
|
||||||
body: SingleChildScrollView(
|
body: SafeArea(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
|
child: AbsorbPointer(
|
||||||
|
absorbing: isLoading,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 40),
|
||||||
child: Column(
|
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: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.check,
|
Icons.check,
|
||||||
@@ -40,8 +103,24 @@ class LoginScreen extends ConsumerWidget {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold),
|
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(
|
CustomTextField(
|
||||||
hint: context.translate(I18n.username),
|
hint: context.translate(I18n.username),
|
||||||
label: context.translate(I18n.username),
|
label: context.translate(I18n.username),
|
||||||
@@ -49,40 +128,90 @@ class LoginScreen extends ConsumerWidget {
|
|||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
textInputAction: TextInputAction.next,
|
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(
|
CustomTextField(
|
||||||
showPassword: state.passwordVisible,
|
showPassword: passwordVisible,
|
||||||
label: context.translate(I18n.password),
|
label: context.translate(I18n.password),
|
||||||
hint: "********",
|
hint: '********',
|
||||||
controller: vm.passwordController,
|
controller: vm.passwordController,
|
||||||
|
|
||||||
textInputAction: TextInputAction.done,
|
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,
|
alignment: Alignment.topLeft,
|
||||||
child: CustomTextButton(
|
child: CustomTextButton(
|
||||||
text: context.translate(I18n.forgotPassword),
|
text: context.translate(I18n.forgotPassword),
|
||||||
onPressed: state.isLoading
|
onPressed: isLoading
|
||||||
? () {}
|
? () {}
|
||||||
: () =>
|
: () => navigationContract.pushTo(AppRoutes.recoverPassword),
|
||||||
navigationContract.pushTo(AppRoutes.recoverPassword),
|
|
||||||
size: 16,
|
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(
|
PrimaryButton(
|
||||||
onPressed: state.isLoading ? () {} : onSignIn,
|
onPressed: isLoading ? () {} : onSignIn,
|
||||||
text: context.translate(I18n.signIn),
|
text: context.translate(I18n.signIn),
|
||||||
color: theme.getColorFor(ThemeCode.buttonPrimary),
|
color: theme.getColorFor(ThemeCode.buttonPrimary),
|
||||||
leading: state.isLoading
|
leading: isLoading
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 18,
|
height: 18,
|
||||||
width: 18,
|
width: 18,
|
||||||
@@ -93,10 +222,30 @@ class LoginScreen extends ConsumerWidget {
|
|||||||
)
|
)
|
||||||
: null,
|
: 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: [
|
children: [
|
||||||
const Divider(endIndent: 74, indent: 74),
|
const Divider(endIndent: 74, indent: 74),
|
||||||
Align(
|
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,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
SecondaryButton(
|
SecondaryButton(
|
||||||
onPressed: state.isLoading
|
onPressed: isLoading
|
||||||
? () {}
|
? () {}
|
||||||
: () => Navigator.push(
|
: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -131,23 +290,35 @@ class LoginScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
SecondaryButton(
|
SecondaryButton(
|
||||||
onPressed: state.isLoading ? () {} : () {},
|
onPressed: isLoading ? () {} : () {},
|
||||||
radius: 16,
|
radius: 16,
|
||||||
padding: 44,
|
padding: 44,
|
||||||
icon: Icons.apple,
|
icon: Icons.apple,
|
||||||
label: '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(
|
Text(
|
||||||
context.translate(I18n.dontHaveAccount),
|
context.translate(I18n.dontHaveAccount),
|
||||||
style: const TextStyle(fontSize: 18, letterSpacing: 0),
|
style: const TextStyle(fontSize: 18, letterSpacing: 0),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: state.isLoading
|
onPressed: isLoading
|
||||||
? null
|
? null
|
||||||
: () => navigationContract.goTo(AppRoutes.signup),
|
: () => navigationContract.goTo(AppRoutes.signup),
|
||||||
child: Text(
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import 'package:auth/src/features/login/domain/login_use_case.dart';
|
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/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:auth/src/features/login/presentation/state/login_view_state.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:sf_localizations/sf_localizations.dart';
|
||||||
|
|
||||||
final loginViewModelProvider =
|
final loginViewModelProvider =
|
||||||
NotifierProvider.autoDispose<LoginViewModel, LoginViewState>(
|
NotifierProvider.autoDispose<LoginViewModel, LoginViewState>(
|
||||||
@@ -11,18 +14,30 @@ final loginViewModelProvider =
|
|||||||
|
|
||||||
class LoginViewModel extends Notifier<LoginViewState> {
|
class LoginViewModel extends Notifier<LoginViewState> {
|
||||||
late final LoginUseCase _loginUseCase;
|
late final LoginUseCase _loginUseCase;
|
||||||
|
late final TwoFactorUseCase _twoFactorUseCase;
|
||||||
|
|
||||||
late final TextEditingController emailController;
|
late final TextEditingController emailController;
|
||||||
late final TextEditingController passwordController;
|
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
|
@override
|
||||||
LoginViewState build() {
|
LoginViewState build() {
|
||||||
_loginUseCase = ref.read(loginUseCaseProvider);
|
_loginUseCase = ref.read(loginUseCaseProvider);
|
||||||
|
_twoFactorUseCase = ref.read(twoFactorUseCaseProvider);
|
||||||
|
|
||||||
emailController = TextEditingController();
|
emailController = TextEditingController();
|
||||||
passwordController = TextEditingController();
|
passwordController = TextEditingController();
|
||||||
|
otpController = TextEditingController();
|
||||||
|
|
||||||
emailController.addListener(_onEmailChanged);
|
emailController.addListener(_onEmailChanged);
|
||||||
passwordController.addListener(_onPasswordChanged);
|
passwordController.addListener(_onPasswordChanged);
|
||||||
|
otpController.addListener(_onOtpChanged);
|
||||||
|
|
||||||
ref.onDispose(disposeControllers);
|
ref.onDispose(disposeControllers);
|
||||||
|
|
||||||
@@ -30,17 +45,24 @@ class LoginViewModel extends Notifier<LoginViewState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onEmailChanged() {
|
void _onEmailChanged() {
|
||||||
if (emailController.text != state.email) {
|
final text = emailController.text;
|
||||||
state = state.copyWith(email: emailController.text, errorMessage: '');
|
if (text == state.email) return;
|
||||||
|
|
||||||
|
state = state.copyWith(email: text, errorMessage: '');
|
||||||
|
|
||||||
|
if (state.showErrors) {
|
||||||
|
state = state.copyWith(emailError: _emailErrorFor(text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPasswordChanged() {
|
void _onPasswordChanged() {
|
||||||
if (passwordController.text != state.password) {
|
final text = passwordController.text;
|
||||||
state = state.copyWith(
|
if (text == state.password) return;
|
||||||
password: passwordController.text,
|
|
||||||
errorMessage: '',
|
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);
|
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 email = state.email.trim();
|
||||||
final password = state.password.trim();
|
final password = state.password.trim();
|
||||||
|
|
||||||
if (email.isEmpty) {
|
state = state.copyWith(isLoading: true, errorMessage: '');
|
||||||
state = state.copyWith(errorMessage: 'errorMessageIsEmpty');
|
|
||||||
return false;
|
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 {
|
try {
|
||||||
await _loginUseCase.login(email: email, password: password);
|
await _twoFactorUseCase.twoFactor(token: token, code: code);
|
||||||
|
|
||||||
if (!ref.mounted) return false;
|
if (!ref.mounted) return false;
|
||||||
state = state.copyWith(isLoading: false);
|
|
||||||
|
state = state.copyWith(isOtpLoading: false);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!ref.mounted) return false;
|
if (!ref.mounted) return false;
|
||||||
state = state.copyWith(isLoading: false, errorMessage: e.toString());
|
|
||||||
|
state = state.copyWith(
|
||||||
|
isOtpLoading: false,
|
||||||
|
otpError: I18n.errorTwoFactorCodeInvalid,
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +193,10 @@ class LoginViewModel extends Notifier<LoginViewState> {
|
|||||||
void disposeControllers() {
|
void disposeControllers() {
|
||||||
emailController.removeListener(_onEmailChanged);
|
emailController.removeListener(_onEmailChanged);
|
||||||
passwordController.removeListener(_onPasswordChanged);
|
passwordController.removeListener(_onPasswordChanged);
|
||||||
|
otpController.removeListener(_onOtpChanged);
|
||||||
|
|
||||||
emailController.dispose();
|
emailController.dispose();
|
||||||
passwordController.dispose();
|
passwordController.dispose();
|
||||||
|
otpController.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ abstract class LoginViewState with _$LoginViewState {
|
|||||||
@Default('') String email,
|
@Default('') String email,
|
||||||
@Default('') String password,
|
@Default('') String password,
|
||||||
@Default(false) bool passwordVisible,
|
@Default(false) bool passwordVisible,
|
||||||
|
@Default('') String emailError,
|
||||||
|
@Default('') String passwordError,
|
||||||
@Default('') String errorMessage,
|
@Default('') String errorMessage,
|
||||||
|
@Default(false) bool showErrors,
|
||||||
@Default(false) bool isLoading,
|
@Default(false) bool isLoading,
|
||||||
|
@Default('') String token,
|
||||||
|
@Default('') String otpCode,
|
||||||
|
@Default('') String otpError,
|
||||||
|
@Default(false) bool isOtpLoading,
|
||||||
}) = _LoginViewState;
|
}) = _LoginViewState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
|
|||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$LoginViewState {
|
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
|
/// Create a copy of LoginViewState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -25,16 +25,16 @@ $LoginViewStateCopyWith<LoginViewState> get copyWith => _$LoginViewStateCopyWith
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory $LoginViewStateCopyWith(LoginViewState value, $Res Function(LoginViewState) _then) = _$LoginViewStateCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$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
|
/// Create a copy of LoginViewState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_self.copyWith(
|
||||||
email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
|
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,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 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 bool,emailError: null == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable
|
||||||
as String,isLoading: null == isLoading ? _self.isLoading : isLoading // 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,
|
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) {
|
switch (_that) {
|
||||||
case _LoginViewState() when $default != null:
|
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();
|
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) {
|
switch (_that) {
|
||||||
case _LoginViewState():
|
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');
|
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) {
|
switch (_that) {
|
||||||
case _LoginViewState() when $default != null:
|
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;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -210,14 +217,21 @@ return $default(_that.email,_that.password,_that.passwordVisible,_that.errorMess
|
|||||||
|
|
||||||
|
|
||||||
class _LoginViewState implements LoginViewState {
|
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 email;
|
||||||
@override@JsonKey() final String password;
|
@override@JsonKey() final String password;
|
||||||
@override@JsonKey() final bool passwordVisible;
|
@override@JsonKey() final bool passwordVisible;
|
||||||
|
@override@JsonKey() final String emailError;
|
||||||
|
@override@JsonKey() final String passwordError;
|
||||||
@override@JsonKey() final String errorMessage;
|
@override@JsonKey() final String errorMessage;
|
||||||
|
@override@JsonKey() final bool showErrors;
|
||||||
@override@JsonKey() final bool isLoading;
|
@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
|
/// Create a copy of LoginViewState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@@ -229,16 +243,16 @@ _$LoginViewStateCopyWith<_LoginViewState> get copyWith => __$LoginViewStateCopyW
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory _$LoginViewStateCopyWith(_LoginViewState value, $Res Function(_LoginViewState) _then) = __$LoginViewStateCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$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
|
/// Create a copy of LoginViewState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_LoginViewState(
|
||||||
email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
|
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,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 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 bool,emailError: null == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable
|
||||||
as String,isLoading: null == isLoading ? _self.isLoading : isLoading // 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,
|
as bool,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,5 +30,18 @@
|
|||||||
"google": "Google",
|
"google": "Google",
|
||||||
"apple": "Apple",
|
"apple": "Apple",
|
||||||
"dontHaveAccount": "Du hast noch kein Konto?",
|
"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."
|
||||||
}
|
}
|
||||||
@@ -30,5 +30,18 @@
|
|||||||
"google": "Google",
|
"google": "Google",
|
||||||
"apple": "Apple",
|
"apple": "Apple",
|
||||||
"dontHaveAccount": "Don't have an account?",
|
"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."
|
||||||
}
|
}
|
||||||
@@ -30,5 +30,18 @@
|
|||||||
"google": "Google",
|
"google": "Google",
|
||||||
"apple": "Apple",
|
"apple": "Apple",
|
||||||
"dontHaveAccount": "¿No tienes cuenta?",
|
"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."
|
||||||
}
|
}
|
||||||
@@ -30,5 +30,18 @@
|
|||||||
"google": "Google",
|
"google": "Google",
|
||||||
"apple": "Apple",
|
"apple": "Apple",
|
||||||
"dontHaveAccount": "Tu n'as pas de compte ?",
|
"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."
|
||||||
}
|
}
|
||||||
@@ -30,5 +30,18 @@
|
|||||||
"google": "Google",
|
"google": "Google",
|
||||||
"apple": "Apple",
|
"apple": "Apple",
|
||||||
"dontHaveAccount": "Non hai un account?",
|
"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."
|
||||||
}
|
}
|
||||||
@@ -30,5 +30,18 @@
|
|||||||
"google": "Google",
|
"google": "Google",
|
||||||
"apple": "Apple",
|
"apple": "Apple",
|
||||||
"dontHaveAccount": "Não tem conta?",
|
"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."
|
||||||
}
|
}
|
||||||
@@ -35,4 +35,18 @@ class I18n {
|
|||||||
static const String apple = "apple";
|
static const String apple = "apple";
|
||||||
static const String dontHaveAccount = "dontHaveAccount";
|
static const String dontHaveAccount = "dontHaveAccount";
|
||||||
static const String createOneNow = "createOneNow";
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user