feat: Implement support chat functionality, add 2FA verification screen and routing, and update dependencies.

This commit is contained in:
pradeepkumar
2026-03-11 13:26:16 +05:30
parent 4780eb9a63
commit 6928697c5c
17 changed files with 1660 additions and 20 deletions

View File

@@ -52,6 +52,58 @@ class AuthRepository {
}
}
/// Verify 2FA TOTP token during login
Future<AuthResponse> verifyTwoFactor({
required String tempToken,
required String token,
}) async {
try {
final response = await _dio.post(
ApiConstants.twoFactorVerify,
data: {'tempToken': tempToken, 'token': token},
);
final data = response.data['data'] as Map<String, dynamic>;
final authResponse = AuthResponse.fromJson(data);
await SecureStorage.saveTokens(
accessToken: authResponse.accessToken,
refreshToken: authResponse.refreshToken,
);
return authResponse;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? '2FA verification failed',
statusCode: e.response?.statusCode,
);
}
}
/// Verify backup code during login
Future<AuthResponse> verifyBackupCode({
required String tempToken,
required String backupCode,
}) async {
try {
final response = await _dio.post(
ApiConstants.twoFactorVerifyBackup,
data: {'tempToken': tempToken, 'backupCode': backupCode},
);
final data = response.data['data'] as Map<String, dynamic>;
final authResponse = AuthResponse.fromJson(data);
await SecureStorage.saveTokens(
accessToken: authResponse.accessToken,
refreshToken: authResponse.refreshToken,
);
return authResponse;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Backup code verification failed',
statusCode: e.response?.statusCode,
);
}
}
Future<UserModel> getMe() async {
try {
final response = await _dio.get(ApiConstants.authMe);

View File

@@ -16,6 +16,9 @@ class AuthState {
final String? errorMessage;
final List<FieldError> fieldErrors;
final String registeredEmail;
final bool requiresTwoFactor;
final String? tempToken;
final String? twoFactorEmail;
const AuthState({
this.status = AuthStatus.initial,
@@ -23,6 +26,9 @@ class AuthState {
this.errorMessage,
this.fieldErrors = const [],
this.registeredEmail = '',
this.requiresTwoFactor = false,
this.tempToken,
this.twoFactorEmail,
});
AuthState copyWith({
@@ -31,6 +37,9 @@ class AuthState {
String? errorMessage,
List<FieldError>? fieldErrors,
String? registeredEmail,
bool? requiresTwoFactor,
String? tempToken,
String? twoFactorEmail,
}) {
return AuthState(
status: status ?? this.status,
@@ -38,6 +47,9 @@ class AuthState {
errorMessage: errorMessage,
fieldErrors: fieldErrors ?? this.fieldErrors,
registeredEmail: registeredEmail ?? this.registeredEmail,
requiresTwoFactor: requiresTwoFactor ?? this.requiresTwoFactor,
tempToken: tempToken ?? this.tempToken,
twoFactorEmail: twoFactorEmail ?? this.twoFactorEmail,
);
}
@@ -112,8 +124,10 @@ class AuthNotifier extends StateNotifier<AuthState> {
if (result['requiresTwoFactor'] == true) {
state = state.copyWith(
status: AuthStatus.error,
errorMessage: '2FA verification required',
status: AuthStatus.initial,
requiresTwoFactor: true,
tempToken: result['tempToken'] as String?,
twoFactorEmail: email,
);
return;
}
@@ -189,6 +203,74 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
}
Future<void> verifyTwoFactor(String token) async {
final tempToken = state.tempToken;
if (tempToken == null) return;
state = state.copyWith(
status: AuthStatus.loading,
errorMessage: null,
);
try {
final authResponse = await _repository.verifyTwoFactor(
tempToken: tempToken,
token: token,
);
state = AuthState(
status: AuthStatus.authenticated,
user: authResponse.user,
);
_initPushNotifications();
} on ApiException catch (e) {
state = state.copyWith(
status: AuthStatus.initial,
errorMessage: e.message,
);
} catch (e) {
state = state.copyWith(
status: AuthStatus.initial,
errorMessage: 'Verification failed. Please try again.',
);
}
}
Future<void> verifyBackupCode(String backupCode) async {
final tempToken = state.tempToken;
if (tempToken == null) return;
state = state.copyWith(
status: AuthStatus.loading,
errorMessage: null,
);
try {
final authResponse = await _repository.verifyBackupCode(
tempToken: tempToken,
backupCode: backupCode,
);
state = AuthState(
status: AuthStatus.authenticated,
user: authResponse.user,
);
_initPushNotifications();
} on ApiException catch (e) {
state = state.copyWith(
status: AuthStatus.initial,
errorMessage: e.message,
);
} catch (e) {
state = state.copyWith(
status: AuthStatus.initial,
errorMessage: 'Verification failed. Please try again.',
);
}
}
void clearTwoFactor() {
state = const AuthState();
}
void _initPushNotifications() {
final push = PushNotificationService();
push.initialize().then((_) => push.registerAfterLogin());

View File

@@ -61,7 +61,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
final authState = ref.watch(authProvider);
final isLoading = authState.status == AuthStatus.loading;
// Router redirect handles navigation on auth state change
// Navigate on auth state changes
ref.listen<AuthState>(authProvider, (previous, next) {
if (next.status == AuthStatus.authenticated) {
context.go('/home');
}
// Navigate to 2FA verification screen
if (next.requiresTwoFactor && next.tempToken != null) {
context.go('/verify-2fa');
}
});
return Scaffold(
body: Container(

View File

@@ -0,0 +1,390 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
class Verify2faScreen extends ConsumerStatefulWidget {
const Verify2faScreen({super.key});
@override
ConsumerState<Verify2faScreen> createState() => _Verify2faScreenState();
}
class _Verify2faScreenState extends ConsumerState<Verify2faScreen> {
final List<TextEditingController> _controllers =
List.generate(6, (_) => TextEditingController());
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
final TextEditingController _backupController = TextEditingController();
bool _useBackupCode = false;
@override
void dispose() {
for (final c in _controllers) {
c.dispose();
}
for (final f in _focusNodes) {
f.dispose();
}
_backupController.dispose();
super.dispose();
}
String get _otpCode => _controllers.map((c) => c.text).join();
void _handleOtpInput(int index, String value) {
if (value.length > 1) {
// Handle paste
final digits = value.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.length >= 6) {
for (int i = 0; i < 6; i++) {
_controllers[i].text = digits[i];
}
_focusNodes[5].requestFocus();
_submit();
return;
}
_controllers[index].text = value[value.length - 1];
}
if (value.isNotEmpty && index < 5) {
_focusNodes[index + 1].requestFocus();
}
// Auto-submit when all 6 digits are entered
if (_otpCode.length == 6) {
_submit();
}
}
void _handleKeyDown(int index, KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace) {
if (_controllers[index].text.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
_controllers[index - 1].clear();
}
}
}
void _submit() {
if (_useBackupCode) {
final code = _backupController.text.trim();
if (code.isEmpty) return;
ref.read(authProvider.notifier).verifyBackupCode(code);
} else {
final code = _otpCode;
if (code.length != 6) return;
ref.read(authProvider.notifier).verifyTwoFactor(code);
}
}
void _goBackToLogin() {
ref.read(authProvider.notifier).clearTwoFactor();
context.go('/login');
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState.status == AuthStatus.loading;
// Navigate to home when authentication succeeds
ref.listen<AuthState>(authProvider, (previous, next) {
if (next.status == AuthStatus.authenticated) {
context.go('/home');
}
});
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.gradientStart, AppColors.gradientEnd],
),
),
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const SizedBox(height: 40),
// Logo
Image.asset(
'assets/icons/logo.png',
width: 143,
height: 41,
),
const SizedBox(height: 32),
// Lock icon
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.accentOrange.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.lock_outlined,
color: AppColors.accentOrange,
size: 32,
),
),
const SizedBox(height: 24),
// Title
const Text(
'Two-Factor Authentication',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 25,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 12),
// Subtitle
Text(
_useBackupCode
? 'Enter one of your backup codes to sign in.'
: 'Enter the 6-digit code from your authenticator app.',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
// Email display
if (authState.twoFactorEmail != null)
Text(
authState.twoFactorEmail!,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark.withValues(alpha: 0.6),
),
),
const SizedBox(height: 24),
// Error banner
if (authState.errorMessage != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.errorBg,
border: Border.all(color: AppColors.errorBorder),
borderRadius: BorderRadius.circular(7),
),
child: Text(
authState.errorMessage!,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w300,
color: AppColors.error,
),
),
),
const SizedBox(height: 16),
],
// Code input
if (_useBackupCode)
_buildBackupCodeInput()
else
_buildOtpInput(),
const SizedBox(height: 24),
// Verify button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primaryDark,
),
)
: const Text('Verify'),
),
),
const SizedBox(height: 24),
// Toggle backup code / authenticator
GestureDetector(
onTap: () {
setState(() {
_useBackupCode = !_useBackupCode;
// Clear inputs
for (final c in _controllers) {
c.clear();
}
_backupController.clear();
});
},
child: Text(
_useBackupCode
? 'Use authenticator app instead'
: 'Use a backup code instead',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.accentOrange,
),
),
),
const SizedBox(height: 16),
// Back to login
GestureDetector(
onTap: _goBackToLogin,
child: const Text(
'Back to login',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
),
const SizedBox(height: 40),
],
),
),
),
),
);
}
Widget _buildOtpInput() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(6, (index) {
return Container(
width: 48,
height: 56,
margin: EdgeInsets.only(
right: index < 5 ? 8 : 0,
left: index == 3 ? 8 : 0, // Extra gap after 3rd digit
),
child: KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (event) => _handleKeyDown(index, event),
child: TextField(
controller: _controllers[index],
focusNode: _focusNodes[index],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (value) => _handleOtpInput(index, value),
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
decoration: InputDecoration(
counterText: '',
filled: true,
fillColor: AppColors.inputFill,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.15),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.15),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(
color: AppColors.accentOrange,
width: 2,
),
),
),
),
),
);
}),
);
}
Widget _buildBackupCodeInput() {
return TextField(
controller: _backupController,
textAlign: TextAlign.center,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
letterSpacing: 2,
),
decoration: InputDecoration(
hintText: 'Enter backup code',
hintStyle: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.4),
letterSpacing: 0,
),
filled: true,
fillColor: AppColors.inputFill,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.15),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.15),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(
color: AppColors.accentOrange,
width: 2,
),
),
),
);
}
}