Files
mobile-app/lib/features/auth/presentation/screens/verify_2fa_screen.dart

401 lines
13 KiB
Dart

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;
bool _isSubmitting = 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 (_isSubmitting) return;
if (_useBackupCode) {
final code = _backupController.text.trim();
if (code.isEmpty) return;
_isSubmitting = true;
ref.read(authProvider.notifier).verifyBackupCode(code);
} else {
final code = _otpCode;
if (code.length != 6) return;
_isSubmitting = true;
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 when authentication succeeds, reset submit lock on error.
// Agents land on /profile (Edit Profile tab — their home/dashboard view).
ref.listen<AuthState>(authProvider, (previous, next) {
if (next.status == AuthStatus.authenticated) {
final isAgent = next.user?.role == 'AGENT';
context.go(isAgent ? '/profile' : '/home');
}
if (next.status != AuthStatus.loading) {
_isSubmitting = false;
}
});
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,
),
),
),
);
}
}