feat: Refactor OTP authentication to use email instead of phone number

This commit is contained in:
Vaibhav Surve 2025-06-10 12:14:05 +05:30
parent 25dfcf3e08
commit c253c14481
3 changed files with 208 additions and 131 deletions

View File

@ -2,23 +2,21 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
class OTPController extends GetxController {
final formKey = GlobalKey<FormState>();
// Observables
final RxString phoneNumber = ''.obs;
final RxString email = ''.obs;
final RxBool isOTPSent = false.obs;
final RxBool isSending = false.obs;
final RxBool isResending = false.obs;
final RxInt timer = 0.obs;
Timer? _countdownTimer;
// Text controllers and focus nodes
final TextEditingController phoneController = TextEditingController();
final List<TextEditingController> otpControllers = List.generate(4, (_) => TextEditingController());
final TextEditingController emailController = TextEditingController();
final List<TextEditingController> otpControllers =
List.generate(4, (_) => TextEditingController());
final List<FocusNode> focusNodes = List.generate(4, (_) => FocusNode());
@override
@ -30,7 +28,7 @@ class OTPController extends GetxController {
@override
void onClose() {
_countdownTimer?.cancel();
phoneController.dispose();
emailController.dispose();
for (final controller in otpControllers) {
controller.dispose();
}
@ -40,19 +38,28 @@ class OTPController extends GetxController {
super.onClose();
}
static Future<bool> _sendOTP(String phone) async {
await Future.delayed(const Duration(seconds: 2));
debugPrint('Sending OTP to $phone');
return true;
Future<bool> _sendOTP(String email) async {
final result = await AuthService.generateOtp(email);
if (result == null) {
debugPrint('OTP sent to $email');
return true;
} else {
showAppSnackbar(
title: "Error",
message: result['error'] ?? "Failed to send OTP",
type: SnackbarType.error,
);
return false;
}
}
Future<void> sendOTP() async {
final phone = phoneController.text.trim();
final userEmail = emailController.text.trim();
if (!_validatePhone(phone)) {
if (!_validateEmail(userEmail)) {
showAppSnackbar(
title: "Error",
message: "Please enter a valid 10-digit mobile number",
message: "Please enter a valid email address",
type: SnackbarType.error,
);
return;
@ -61,18 +68,12 @@ class OTPController extends GetxController {
if (isSending.value) return;
isSending.value = true;
final success = await _sendOTP(phone);
final success = await _sendOTP(userEmail);
if (success) {
phoneNumber.value = phone;
email.value = userEmail;
isOTPSent.value = true;
_startTimer();
_clearOTPFields();
} else {
showAppSnackbar(
title: "Error",
message: "Failed to send OTP. Please try again.",
type: SnackbarType.error,
);
}
isSending.value = false;
@ -84,15 +85,9 @@ class OTPController extends GetxController {
_clearOTPFields();
final success = await _sendOTP(phoneNumber.value);
final success = await _sendOTP(email.value);
if (success) {
_startTimer();
} else {
showAppSnackbar(
title: "Error",
message: "Failed to resend OTP. Please try again.",
type: SnackbarType.error,
);
}
isResending.value = false;
@ -112,19 +107,35 @@ class OTPController extends GetxController {
}
}
void verifyOTP() {
Future<void> verifyOTP() async {
final enteredOTP = otpControllers.map((c) => c.text).join();
if (enteredOTP.length != otpControllers.length || enteredOTP.contains('')) {
final result = await AuthService.verifyOtp(
email: email.value,
otp: enteredOTP,
);
if (result == null) {
showAppSnackbar(
title: "Success",
message: "OTP verified successfully",
type: SnackbarType.success,
);
final bool isMpinEnabled = LocalStorage.getIsMpin();
print('MPIN Enabled? $isMpinEnabled');
if (isMpinEnabled) {
Get.toNamed('/auth/mpin-auth');
} else {
Get.offAllNamed('/home');
}
} else {
showAppSnackbar(
title: "Error",
message: "Please enter the complete ${otpControllers.length}-digit OTP",
message: result['error'] ?? "Failed to verify OTP",
type: SnackbarType.error,
);
return;
}
// TODO: Add your OTP verification logic
debugPrint('Verifying OTP: $enteredOTP');
}
void _clearOTPFields() {
@ -146,10 +157,10 @@ class OTPController extends GetxController {
});
}
void resetForChangeNumber() {
void resetForChangeEmail() {
isOTPSent.value = false;
phoneNumber.value = '';
phoneController.clear();
email.value = '';
emailController.clear();
_clearOTPFields();
timer.value = 0;
@ -161,8 +172,8 @@ class OTPController extends GetxController {
}
}
bool _validatePhone(String phone) {
final regex = RegExp(r'^\d{10}$');
return regex.hasMatch(phone);
bool _validateEmail(String email) {
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$');
return regex.hasMatch(email);
}
}

View File

@ -25,31 +25,7 @@ class AuthService {
final responseData = jsonDecode(response.body);
if (response.statusCode == 200 && responseData['data'] != null) {
final jwtToken = responseData['data']['token'];
final refreshToken = responseData['data']['refreshToken'];
final mpinToken = responseData['data']['mpinToken'];
// Log the tokens using the logger
logger.i("JWT Token: $jwtToken");
if (refreshToken != null) logger.i("Refresh Token: $refreshToken");
if (mpinToken != null) logger.i("MPIN Token: $mpinToken");
await LocalStorage.setJwtToken(jwtToken);
await LocalStorage.setLoggedInUser(true);
if (refreshToken != null) {
await LocalStorage.setRefreshToken(refreshToken);
}
if (mpinToken != null && mpinToken.isNotEmpty) {
await LocalStorage.setMpinToken(mpinToken);
await LocalStorage.setIsMpin(true);
} else {
await LocalStorage.setIsMpin(false);
await LocalStorage.removeMpinToken();
}
Get.put(PermissionController());
isLoggedIn = true;
await _handleLoginSuccess(responseData['data']);
return null;
} else if (response.statusCode == 401) {
return {"password": "Invalid email or password"};
@ -300,4 +276,98 @@ class AuthService {
return {"error": "Network error. Please check your connection."};
}
}
// Generate OTP API
static Future<Map<String, String>?> generateOtp(String email) async {
final requestBody = {"email": email};
logger.i("Sending generate OTP request: $requestBody");
try {
final response = await http.post(
Uri.parse("$_baseUrl/auth/send-otp"),
headers: _headers,
body: jsonEncode(requestBody),
);
logger.i(
"Generate OTP API response (${response.statusCode}): ${response.body}");
final responseData = jsonDecode(response.body);
if (response.statusCode == 200 && responseData['success'] == true) {
logger.i("OTP generated successfully.");
return null;
} else {
return {"error": responseData['message'] ?? "Failed to generate OTP."};
}
} catch (e) {
logger.e("Exception during generate OTP: $e");
return {"error": "Network error. Please check your connection."};
}
}
// Verify OTP API
static Future<Map<String, String>?> verifyOtp({
required String email,
required String otp,
}) async {
final requestBody = {
"email": email,
"otp": otp,
};
logger.i("Sending verify OTP request: $requestBody");
try {
final response = await http.post(
Uri.parse("$_baseUrl/auth/login-otp"),
headers: _headers,
body: jsonEncode(requestBody),
);
logger.i(
"Verify OTP API response (${response.statusCode}): ${response.body}");
final responseData = jsonDecode(response.body);
if (response.statusCode == 200 && responseData['data'] != null) {
await _handleLoginSuccess(responseData['data']);
logger.i("OTP verified and login state initialized successfully.");
return null;
} else {
return {"error": responseData['message'] ?? "Failed to verify OTP."};
}
} catch (e) {
logger.e("Exception during verify OTP: $e");
return {"error": "Network error. Please check your connection."};
}
}
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
final jwtToken = data['token'];
final refreshToken = data['refreshToken'];
final mpinToken = data['mpinToken'];
logger.i("JWT Token: $jwtToken");
if (refreshToken != null) logger.i("Refresh Token: $refreshToken");
if (mpinToken != null) logger.i("MPIN Token: $mpinToken");
await LocalStorage.setJwtToken(jwtToken);
await LocalStorage.setLoggedInUser(true);
if (refreshToken != null) {
await LocalStorage.setRefreshToken(refreshToken);
}
if (mpinToken != null && mpinToken.isNotEmpty) {
await LocalStorage.setMpinToken(mpinToken);
await LocalStorage.setIsMpin(true);
} else {
await LocalStorage.setIsMpin(false);
await LocalStorage.removeMpinToken();
}
Get.put(PermissionController());
isLoggedIn = true;
}
}

View File

@ -7,6 +7,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/controller/auth/otp_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class OTPLoginScreen extends StatefulWidget {
const OTPLoginScreen({super.key});
@ -29,42 +30,43 @@ class _OTPLoginScreenState extends State<OTPLoginScreen> with UIMixin {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Obx(() {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('OTP Verification', fontWeight: 600),
MySpacing.height(24),
if (!controller.isOTPSent.value) ...[
_buildPhoneInput(),
MySpacing.height(24),
_buildSendOTPButton(theme),
] else ...[
_buildOTPSentInfo(),
MySpacing.height(12),
_buildOTPForm(theme),
MySpacing.height(12),
_buildResendOTPSection(theme),
MySpacing.height(12),
_buildVerifyOTPButton(theme),
],
],
),
);
});
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('OTP Verification', fontWeight: 600),
MySpacing.height(24),
Obx(() => !controller.isOTPSent.value
? Column(
children: [
_buildEmailInput(),
MySpacing.height(24),
_buildSendOTPButton(theme),
],
)
: Column(
children: [
_buildOTPSentInfo(),
MySpacing.height(12),
_buildOTPForm(theme),
MySpacing.height(12),
_buildResendOTPSection(theme),
MySpacing.height(12),
_buildVerifyOTPButton(theme),
],
)),
],
),
);
}
Widget _buildPhoneInput() {
Widget _buildEmailInput() {
return TextFormField(
controller: controller.phoneController,
keyboardType: TextInputType.phone,
maxLength: 10,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
controller: controller.emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: 'Enter Mobile Number',
labelText: 'Enter Email Address',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
counterText: '',
),
onFieldSubmitted: (_) => controller.sendOTP(),
);
@ -107,39 +109,23 @@ class _OTPLoginScreenState extends State<OTPLoginScreen> with UIMixin {
),
child: Row(
children: [
Icon(Icons.sms_outlined, color: Colors.redAccent, size: 24),
Icon(Icons.email_outlined, color: Colors.redAccent, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(
'OTP sent to',
fontWeight: 500,
),
MyText.bodySmall(
'+91-${controller.phoneNumber.value}',
fontWeight: 700,
color: Colors.black,
),
MyText.bodySmall('OTP sent to', fontWeight: 500),
MyText.bodySmall(controller.email.value,
fontWeight: 700, color: Colors.black),
],
),
),
TextButton.icon(
onPressed: controller.resetForChangeNumber,
onPressed: controller.resetForChangeEmail,
icon: Icon(Icons.edit, size: 16, color: Colors.redAccent),
label: MyText.bodySmall(
'Change',
fontWeight: 600,
color: Colors.redAccent,
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
foregroundColor: Colors.redAccent,
),
label: MyText.bodySmall('Change',
fontWeight: 600, color: Colors.redAccent),
),
],
),
@ -170,6 +156,10 @@ class _OTPLoginScreenState extends State<OTPLoginScreen> with UIMixin {
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (value) => controller.onOTPChanged(value, index),
validator: (value) {
if (value == null || value.isEmpty) return '';
return null;
},
decoration: InputDecoration(
counterText: '',
filled: true,
@ -204,13 +194,9 @@ class _OTPLoginScreenState extends State<OTPLoginScreen> with UIMixin {
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: MyText.bodySmall(
'Resend OTP',
fontWeight: 600,
color: Colors.blueAccent,
),
child: CircularProgressIndicator(strokeWidth: 2))
: MyText.bodySmall('Resend OTP',
fontWeight: 600, color: Colors.blueAccent),
),
);
}
@ -220,7 +206,17 @@ class _OTPLoginScreenState extends State<OTPLoginScreen> with UIMixin {
return Align(
alignment: Alignment.center,
child: MyButton.rounded(
onPressed: controller.verifyOTP,
onPressed: () {
if (_otpFormKey.currentState!.validate()) {
controller.verifyOTP();
} else {
showAppSnackbar(
title: "Error",
message: "Please enter all 4 digits of the OTP",
type: SnackbarType.error,
);
}
},
elevation: 2,
padding: MySpacing.xy(24, 16),
borderRadiusAll: 10,