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: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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/state/login_view_model.dart';
|
||||
import 'package:auth/src/features/login/presentation/widgets/two_factor_bottom_sheet.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -11,155 +12,351 @@ class LoginScreen extends ConsumerWidget {
|
||||
|
||||
const LoginScreen({super.key, required this.navigationContract});
|
||||
|
||||
Future<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),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check,
|
||||
color: theme.getColorFor(ThemeCode.buttonPrimary),
|
||||
size: 54,
|
||||
),
|
||||
Text(
|
||||
context.translate(I18n.welcome),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
CustomTextField(
|
||||
hint: context.translate(I18n.username),
|
||||
label: context.translate(I18n.username),
|
||||
controller: vm.emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
CustomTextField(
|
||||
showPassword: state.passwordVisible,
|
||||
label: context.translate(I18n.password),
|
||||
hint: "********",
|
||||
controller: vm.passwordController,
|
||||
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => onSignIn(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: CustomTextButton(
|
||||
text: context.translate(I18n.forgotPassword),
|
||||
onPressed: state.isLoading
|
||||
? () {}
|
||||
: () =>
|
||||
navigationContract.pushTo(AppRoutes.recoverPassword),
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
PrimaryButton(
|
||||
onPressed: state.isLoading ? () {} : onSignIn,
|
||||
text: context.translate(I18n.signIn),
|
||||
color: theme.getColorFor(ThemeCode.buttonPrimary),
|
||||
leading: state.isLoading
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
Stack(
|
||||
body: SafeArea(
|
||||
child: AbsorbPointer(
|
||||
absorbing: isLoading,
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 40),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Divider(endIndent: 74, indent: 74),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
color: theme.getColorFor(ThemeCode.backgroundPrimary),
|
||||
child: Text(context.translate(I18n.orContinueWith)),
|
||||
),
|
||||
_Header(theme: theme),
|
||||
SizedBox(height: 48),
|
||||
const _EmailSection(),
|
||||
SizedBox(height: 24),
|
||||
_PasswordSection(onSubmitted: () => _onLogIn(context, ref)),
|
||||
SizedBox(height: 16),
|
||||
_ForgotPassword(navigationContract: navigationContract),
|
||||
SizedBox(height: 30),
|
||||
_SignInSection(
|
||||
theme: theme,
|
||||
onSignIn: () => _onLogIn(context, ref),
|
||||
),
|
||||
SizedBox(height: 30),
|
||||
_OrContinueWith(theme: theme),
|
||||
SizedBox(height: 24),
|
||||
_SocialButtons(theme: theme),
|
||||
SizedBox(height: 30),
|
||||
_Footer(navigationContract: navigationContract),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SecondaryButton(
|
||||
onPressed: state.isLoading
|
||||
? () {}
|
||||
: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LoadingGoogleScreen(),
|
||||
),
|
||||
),
|
||||
radius: 16,
|
||||
padding: 44,
|
||||
text: context.translate(I18n.google),
|
||||
label: 'Google',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SecondaryButton(
|
||||
onPressed: state.isLoading ? () {} : () {},
|
||||
radius: 16,
|
||||
padding: 44,
|
||||
icon: Icons.apple,
|
||||
label: 'Apple',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
Text(
|
||||
context.translate(I18n.dontHaveAccount),
|
||||
style: const TextStyle(fontSize: 18, letterSpacing: 0),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: state.isLoading
|
||||
? null
|
||||
: () => navigationContract.goTo(AppRoutes.signup),
|
||||
child: Text(
|
||||
context.translate(I18n.createOneNow),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({required this.theme});
|
||||
final ThemePort theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check,
|
||||
color: theme.getColorFor(ThemeCode.buttonPrimary),
|
||||
size: 54,
|
||||
),
|
||||
Text(
|
||||
context.translate(I18n.welcome),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailSection extends ConsumerWidget {
|
||||
const _EmailSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final vm = ref.read(loginViewModelProvider.notifier);
|
||||
final String emailErrorKey = ref.watch(
|
||||
loginViewModelProvider.select((s) => s.emailError),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
CustomTextField(
|
||||
hint: context.translate(I18n.username),
|
||||
label: context.translate(I18n.username),
|
||||
controller: vm.emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
_FieldErrorText.fromKey(errorKey: emailErrorKey),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PasswordSection extends ConsumerWidget {
|
||||
const _PasswordSection({required this.onSubmitted});
|
||||
final Future<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: passwordVisible,
|
||||
label: context.translate(I18n.password),
|
||||
hint: '********',
|
||||
controller: vm.passwordController,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => onSubmitted(),
|
||||
),
|
||||
_FieldErrorText.fromKey(errorKey: passwordErrorKey),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ForgotPassword extends ConsumerWidget {
|
||||
const _ForgotPassword({required this.navigationContract});
|
||||
final NavigationContract navigationContract;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bool isLoading = ref.watch(
|
||||
loginViewModelProvider.select((s) => s.isLoading),
|
||||
);
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: CustomTextButton(
|
||||
text: context.translate(I18n.forgotPassword),
|
||||
onPressed: isLoading
|
||||
? () {}
|
||||
: () => navigationContract.pushTo(AppRoutes.recoverPassword),
|
||||
size: 16,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignInSection extends ConsumerWidget {
|
||||
const _SignInSection({required this.onSignIn, required this.theme});
|
||||
|
||||
final VoidCallback onSignIn;
|
||||
final ThemePort theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bool isLoading = ref.watch(
|
||||
loginViewModelProvider.select((s) => s.isLoading),
|
||||
);
|
||||
final String errorMessage = ref.watch(
|
||||
loginViewModelProvider.select((s) => s.errorMessage),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
PrimaryButton(
|
||||
onPressed: isLoading ? () {} : onSignIn,
|
||||
text: context.translate(I18n.signIn),
|
||||
color: theme.getColorFor(ThemeCode.buttonPrimary),
|
||||
leading: isLoading
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (errorMessage.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
errorMessage,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OrContinueWith extends StatelessWidget {
|
||||
const _OrContinueWith({required this.theme});
|
||||
final ThemePort theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
const Divider(endIndent: 74, indent: 74),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
color: theme.getColorFor(ThemeCode.backgroundPrimary),
|
||||
child: Text(context.translate(I18n.orContinueWith)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SocialButtons extends ConsumerWidget {
|
||||
const _SocialButtons({required this.theme});
|
||||
final ThemePort theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bool isLoading = ref.watch(
|
||||
loginViewModelProvider.select((s) => s.isLoading),
|
||||
);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SecondaryButton(
|
||||
onPressed: isLoading
|
||||
? () {}
|
||||
: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const LoadingGoogleScreen(),
|
||||
),
|
||||
),
|
||||
radius: 16,
|
||||
padding: 44,
|
||||
text: context.translate(I18n.google),
|
||||
label: 'Google',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SecondaryButton(
|
||||
onPressed: isLoading ? () {} : () {},
|
||||
radius: 16,
|
||||
padding: 44,
|
||||
icon: Icons.apple,
|
||||
label: 'Apple',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Footer extends ConsumerWidget {
|
||||
const _Footer({required this.navigationContract});
|
||||
final NavigationContract navigationContract;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bool isLoading = ref.watch(
|
||||
loginViewModelProvider.select((s) => s.isLoading),
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
context.translate(I18n.dontHaveAccount),
|
||||
style: const TextStyle(fontSize: 18, letterSpacing: 0),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => navigationContract.goTo(AppRoutes.signup),
|
||||
child: Text(
|
||||
context.translate(I18n.createOneNow),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FieldErrorText extends StatelessWidget {
|
||||
const _FieldErrorText._({required this.text});
|
||||
final String text;
|
||||
|
||||
factory _FieldErrorText.fromKey({required String errorKey}) {
|
||||
return _FieldErrorText._(text: errorKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (text.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
context.translate(text),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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/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 {
|
||||
await _loginUseCase.login(email: email, password: password);
|
||||
if (!ref.mounted) return false;
|
||||
final String token = await _loginUseCase.login(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
if (!ref.mounted) return null;
|
||||
|
||||
state = state.copyWith(isLoading: false);
|
||||
return token;
|
||||
} catch (e) {
|
||||
if (!ref.mounted) return null;
|
||||
|
||||
state = state.copyWith(isLoading: false, errorMessage: e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void prepareTwoFactor() {
|
||||
otpController.text = '';
|
||||
state = state.copyWith(otpCode: '', otpError: '', isOtpLoading: false);
|
||||
}
|
||||
|
||||
void _onOtpChanged() {
|
||||
final raw = otpController.text;
|
||||
if (raw == state.otpCode) return;
|
||||
|
||||
state = state.copyWith(otpCode: raw, otpError: '');
|
||||
|
||||
if (state.showErrors) {
|
||||
state = state.copyWith(otpError: _otpErrorFor(raw));
|
||||
}
|
||||
}
|
||||
|
||||
void setOtpCode(String code) {
|
||||
state = state.copyWith(otpCode: code, otpError: '');
|
||||
}
|
||||
|
||||
String _otpErrorFor(String value) {
|
||||
final code = value.trim();
|
||||
if (code.isEmpty) return I18n.errorTwoFactorCodeRequired;
|
||||
if (code.length != 6) return I18n.errorTwoFactorCodeInvalidLength;
|
||||
return '';
|
||||
}
|
||||
|
||||
bool _validateOtp() {
|
||||
final otpError = _otpErrorFor(state.otpCode);
|
||||
|
||||
state = state.copyWith(
|
||||
showErrors: true,
|
||||
otpError: otpError,
|
||||
errorMessage: '',
|
||||
);
|
||||
|
||||
return otpError.isEmpty;
|
||||
}
|
||||
|
||||
Future<bool> twoFactor({required String token}) async {
|
||||
if (!_validateOtp()) return false;
|
||||
|
||||
final code = state.otpCode.trim();
|
||||
|
||||
state = state.copyWith(isOtpLoading: true, otpError: '', errorMessage: '');
|
||||
|
||||
try {
|
||||
await _twoFactorUseCase.twoFactor(token: token, code: code);
|
||||
|
||||
if (!ref.mounted) return false;
|
||||
|
||||
state = state.copyWith(isOtpLoading: false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (!ref.mounted) return false;
|
||||
state = state.copyWith(isLoading: false, errorMessage: e.toString());
|
||||
|
||||
state = state.copyWith(
|
||||
isOtpLoading: false,
|
||||
otpError: I18n.errorTwoFactorCodeInvalid,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -73,7 +193,10 @@ class LoginViewModel extends Notifier<LoginViewState> {
|
||||
void disposeControllers() {
|
||||
emailController.removeListener(_onEmailChanged);
|
||||
passwordController.removeListener(_onPasswordChanged);
|
||||
otpController.removeListener(_onOtpChanged);
|
||||
|
||||
emailController.dispose();
|
||||
passwordController.dispose();
|
||||
otpController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user