From a89346fc8af0efd486cf4c3550b01ec6ff1832b6 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 9 Jun 2025 18:24:06 +0530 Subject: [PATCH 1/8] Update application label in AndroidManifest.xml to match project name --- android/app/src/main/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ec90481..a93d51b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,7 +7,7 @@ Date: Tue, 10 Jun 2025 10:04:18 +0530 Subject: [PATCH 2/8] feat: Add MPIN authentication and OTP login screens - Implemented MPINAuthScreen for generating and entering MPIN. - Created MPINScreen for user interaction with MPIN input. - Developed OTPLoginScreen for OTP verification process. - Added request demo bottom sheet with organization form. - Enhanced DashboardScreen to check MPIN status and prompt user to generate MPIN if not set. --- .../auth/forgot_password_controller.dart | 2 +- lib/controller/auth/login_controller.dart | 49 ++- lib/controller/auth/mpin_controller.dart | 276 ++++++++++++ lib/controller/auth/otp_controller.dart | 168 ++++++++ .../auth/register_account_controller.dart | 2 +- lib/helpers/services/auth_service.dart | 114 ++++- .../services/storage/local_storage.dart | 42 +- lib/helpers/theme/admin_theme.dart | 28 +- lib/main.dart | 41 +- lib/routes.dart | 14 +- lib/view/auth/email_login_form.dart | 198 +++++++++ lib/view/auth/forgot_password_screen.dart | 269 ++++++++---- lib/view/auth/login_option_screen.dart | 225 ++++++++++ lib/view/auth/mpin_auth_screen.dart | 311 ++++++++++++++ lib/view/auth/mpin_screen.dart | 150 +++++++ lib/view/auth/otp_login_form.dart | 236 +++++++++++ lib/view/auth/request_demo_bottom_sheet.dart | 400 +++++++++++------- lib/view/dashboard/dashboard_screen.dart | 82 +++- 18 files changed, 2301 insertions(+), 306 deletions(-) create mode 100644 lib/controller/auth/mpin_controller.dart create mode 100644 lib/controller/auth/otp_controller.dart create mode 100644 lib/view/auth/email_login_form.dart create mode 100644 lib/view/auth/login_option_screen.dart create mode 100644 lib/view/auth/mpin_auth_screen.dart create mode 100644 lib/view/auth/mpin_screen.dart create mode 100644 lib/view/auth/otp_login_form.dart diff --git a/lib/controller/auth/forgot_password_controller.dart b/lib/controller/auth/forgot_password_controller.dart index db69cdb..bb4e978 100644 --- a/lib/controller/auth/forgot_password_controller.dart +++ b/lib/controller/auth/forgot_password_controller.dart @@ -61,6 +61,6 @@ class ForgotPasswordController extends MyController { } void gotoLogIn() { - Get.toNamed('/auth/login'); + Get.toNamed('/auth/login-option'); } } diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index b8488d5..6ee233b 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -4,9 +4,8 @@ import 'package:marco/controller/my_controller.dart'; import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:marco/helpers/widgets/my_validators.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - class LoginController extends MyController { final MyFormValidator basicValidator = MyFormValidator(); @@ -40,7 +39,7 @@ class LoginController extends MyController { } void onChangeCheckBox(bool? value) { - isChecked.value = value ?? isChecked.value; + isChecked.value = value ?? false; } void onChangeShowPassword() { @@ -66,33 +65,45 @@ class LoginController extends MyController { basicValidator.clearErrors(); } else { await _handleRememberMe(); + final bool isMpinEnabled = LocalStorage.getIsMpin(); + print('MPIN Enabled? $isMpinEnabled'); - final currentRoute = ModalRoute.of(Get.context!)?.settings.name ?? ""; - final nextUrl = Uri.parse(currentRoute).queryParameters['next'] ?? "/home"; - Get.toNamed(nextUrl); + if (isMpinEnabled) { + Get.toNamed('/auth/mpin-auth'); + } else { + Get.toNamed('/home'); + } } isLoading.value = false; } - Future _handleRememberMe() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - if (isChecked.value) { - await prefs.setString('username', basicValidator.getController('username')!.text); - await prefs.setString('password', basicValidator.getController('password')!.text); - await prefs.setBool('remember_me', true); + void handlePostLoginNavigation() { + final bool isMpinEnabled = LocalStorage.getIsMpin(); + + if (isMpinEnabled) { + Get.toNamed('/auth/mpin-auth'); } else { - await prefs.remove('username'); - await prefs.remove('password'); - await prefs.setBool('remember_me', false); + Get.offAllNamed('/home'); + } + } + + Future _handleRememberMe() async { + if (isChecked.value) { + await LocalStorage.setToken('username', basicValidator.getController('username')!.text); + await LocalStorage.setToken('password', basicValidator.getController('password')!.text); + await LocalStorage.setBool('remember_me', true); + } else { + await LocalStorage.removeToken('username'); + await LocalStorage.removeToken('password'); + await LocalStorage.setBool('remember_me', false); } } Future _loadSavedCredentials() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - final savedUsername = prefs.getString('username'); - final savedPassword = prefs.getString('password'); - final remember = prefs.getBool('remember_me') ?? false; + final savedUsername = LocalStorage.getToken('username'); + final savedPassword = LocalStorage.getToken('password'); + final remember = LocalStorage.getBool('remember_me') ?? false; isChecked.value = remember; diff --git a/lib/controller/auth/mpin_controller.dart b/lib/controller/auth/mpin_controller.dart new file mode 100644 index 0000000..d84c80d --- /dev/null +++ b/lib/controller/auth/mpin_controller.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:logger/logger.dart'; +import 'package:marco/helpers/services/auth_service.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; +import 'package:marco/helpers/widgets/my_form_validator.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/view/dashboard/dashboard_screen.dart'; + +class MPINController extends GetxController { + final Logger logger = Logger(); + + final MyFormValidator basicValidator = MyFormValidator(); + final isNewUser = false.obs; + final RxBool isLoading = false.obs; + final formKey = GlobalKey(); + + final digitControllers = List.generate(6, (_) => TextEditingController()); + final focusNodes = List.generate(6, (_) => FocusNode()); + + final retypeControllers = List.generate(6, (_) => TextEditingController()); + final retypeFocusNodes = List.generate(6, (_) => FocusNode()); + + @override + void onInit() { + super.onInit(); + final bool hasMpin = LocalStorage.getIsMpin(); + isNewUser.value = !hasMpin; + logger.i("[MPINController] onInit called. isNewUser: ${isNewUser.value}"); + } + + void onDigitChanged(String value, int index, {bool isRetype = false}) { + logger.i( + "[MPINController] onDigitChanged -> index: $index, value: $value, isRetype: $isRetype"); + final nodes = isRetype ? retypeFocusNodes : focusNodes; + if (value.isNotEmpty && index < 5) { + nodes[index + 1].requestFocus(); + } else if (value.isEmpty && index > 0) { + nodes[index - 1].requestFocus(); + } + } + + Future onSubmitMPIN() async { + logger.i("[MPINController] onSubmitMPIN triggered"); + + if (!formKey.currentState!.validate()) { + logger.w("[MPINController] Form validation failed"); + return; + } + + final enteredMPIN = digitControllers.map((c) => c.text).join(); + logger.i("[MPINController] Entered MPIN: $enteredMPIN"); + + if (enteredMPIN.length < 6) { + _showError("Please enter all 6 digits."); + return; + } + + if (isNewUser.value) { + final retypeMPIN = retypeControllers.map((c) => c.text).join(); + logger.i("[MPINController] Retyped MPIN: $retypeMPIN"); + + if (retypeMPIN.length < 6) { + _showError("Please enter all 6 digits in Retype MPIN."); + return; + } + + if (enteredMPIN != retypeMPIN) { + _showError("MPIN and Retype MPIN do not match."); + clearFields(); + clearRetypeFields(); + return; + } + + logger.i("[MPINController] MPINs matched. Proceeding to generate MPIN."); + final bool success = await generateMPIN(mpin: enteredMPIN); + + if (success) { + logger.i("[MPINController] MPIN generation successful."); + _navigateToDashboard(message: "MPIN Set Successfully"); + } else { + logger.w("[MPINController] MPIN generation failed."); + clearFields(); + clearRetypeFields(); + } + } else { + logger.i("[MPINController] Existing user. Proceeding to verify MPIN."); + await verifyMPIN(); + } + } + + Future onForgotMPIN() async { + logger.i("[MPINController] onForgotMPIN called"); + isNewUser.value = true; + clearFields(); + clearRetypeFields(); + } + + void switchToEnterMPIN() { + logger.i("[MPINController] switchToEnterMPIN called"); + isNewUser.value = false; + clearFields(); + clearRetypeFields(); + } + + void _showError(String message) { + logger.e("[MPINController] ERROR: $message"); + showAppSnackbar( + title: "Error", + message: message, + type: SnackbarType.error, + ); + } + + void _navigateToDashboard({String? message}) { + if (message != null) { + logger + .i("[MPINController] Navigating to Dashboard with message: $message"); + showAppSnackbar( + title: "Success", + message: message, + type: SnackbarType.success, + ); + } + Get.offAll(() => const DashboardScreen()); + } + + void clearFields() { + logger.i("[MPINController] clearFields called"); + for (final c in digitControllers) { + c.clear(); + } + focusNodes.first.requestFocus(); + } + + void clearRetypeFields() { + logger.i("[MPINController] clearRetypeFields called"); + for (final c in retypeControllers) { + c.clear(); + } + retypeFocusNodes.first.requestFocus(); + } + + @override + void onClose() { + logger.i("[MPINController] onClose called"); + for (final controller in digitControllers) { + controller.dispose(); + } + for (final node in focusNodes) { + node.dispose(); + } + for (final controller in retypeControllers) { + controller.dispose(); + } + for (final node in retypeFocusNodes) { + node.dispose(); + } + super.onClose(); + } + + Future generateMPIN({ + required String mpin, + }) async { + try { + isLoading.value = true; + logger.i("[MPINController] generateMPIN started for MPIN: $mpin"); + + final employeeInfo = LocalStorage.getEmployeeInfo(); + final String? employeeId = employeeInfo?.id; + + if (employeeId == null || employeeId.isEmpty) { + isLoading.value = false; + _showError("Missing employee ID."); + return false; + } + + logger.i( + "[MPINController] Calling AuthService.generateMpin for employeeId: $employeeId"); + + final response = await AuthService.generateMpin( + employeeId: employeeId, + mpin: mpin, + ); + + isLoading.value = false; + + if (response == null) { + logger.i( + "[MPINController] MPIN generated successfully (null response treated as success)"); + Get.toNamed('/auth/mpin-auth'); + + return true; + } else { + logger.w( + "[MPINController] MPIN generation returned error response: $response"); + showAppSnackbar( + title: "MPIN Generation Failed", + message: "Please check your inputs.", + type: SnackbarType.error, + ); + basicValidator.addErrors(response); + basicValidator.validateForm(); + basicValidator.clearErrors(); + return false; + } + } catch (e) { + isLoading.value = false; + _showError("Failed to generate MPIN: $e"); + logger.e("[MPINController] Exception in generateMPIN: $e"); + return false; + } + } + + Future verifyMPIN() async { + logger.i("[MPINController] verifyMPIN triggered"); + + final enteredMPIN = digitControllers.map((c) => c.text).join(); + logger.i("[MPINController] Entered MPIN: $enteredMPIN"); + + if (enteredMPIN.length < 6) { + _showError("Please enter all 6 digits."); + return; + } + + final mpinToken = await LocalStorage.getMpinToken(); + + if (mpinToken == null || mpinToken.isEmpty) { + _showError("Missing MPIN token. Please log in again."); + return; + } + + try { + isLoading.value = true; + + final response = await AuthService.verifyMpin( + mpin: enteredMPIN, + mpinToken: mpinToken, + ); + + isLoading.value = false; + + if (response == null) { + logger.i("[MPINController] MPIN verified successfully."); + + // Set mpin_verified to true in local storage here: + await LocalStorage.setBool('mpin_verified', true); + + showAppSnackbar( + title: "Success", + message: "MPIN Verified Successfully", + type: SnackbarType.success, + ); + _navigateToDashboard(); + } else { + final errorMessage = response["error"] ?? "Invalid MPIN"; + logger.w("[MPINController] MPIN verification failed: $errorMessage"); + showAppSnackbar( + title: "Error", + message: errorMessage, + type: SnackbarType.error, + ); + clearFields(); + } + } catch (e) { + isLoading.value = false; + final error = "Failed to verify MPIN: $e"; + logger.e("[MPINController] Exception in verifyMPIN: $error"); + showAppSnackbar( + title: "Error", + message: "Something went wrong. Please try again.", + type: SnackbarType.error, + ); + } + } +} diff --git a/lib/controller/auth/otp_controller.dart b/lib/controller/auth/otp_controller.dart new file mode 100644 index 0000000..764c4d9 --- /dev/null +++ b/lib/controller/auth/otp_controller.dart @@ -0,0 +1,168 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + +class OTPController extends GetxController { + final formKey = GlobalKey(); + + // Observables + final RxString phoneNumber = ''.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 otpControllers = List.generate(4, (_) => TextEditingController()); + final List focusNodes = List.generate(4, (_) => FocusNode()); + + @override + void onInit() { + super.onInit(); + timer.value = 0; + } + + @override + void onClose() { + _countdownTimer?.cancel(); + phoneController.dispose(); + for (final controller in otpControllers) { + controller.dispose(); + } + for (final node in focusNodes) { + node.dispose(); + } + super.onClose(); + } + + static Future _sendOTP(String phone) async { + await Future.delayed(const Duration(seconds: 2)); + debugPrint('Sending OTP to $phone'); + return true; + } + + Future sendOTP() async { + final phone = phoneController.text.trim(); + + if (!_validatePhone(phone)) { + showAppSnackbar( + title: "Error", + message: "Please enter a valid 10-digit mobile number", + type: SnackbarType.error, + ); + return; + } + + if (isSending.value) return; + isSending.value = true; + + final success = await _sendOTP(phone); + if (success) { + phoneNumber.value = phone; + isOTPSent.value = true; + _startTimer(); + _clearOTPFields(); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to send OTP. Please try again.", + type: SnackbarType.error, + ); + } + + isSending.value = false; + } + + Future onResendOTP() async { + if (isResending.value) return; + isResending.value = true; + + _clearOTPFields(); + + final success = await _sendOTP(phoneNumber.value); + if (success) { + _startTimer(); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to resend OTP. Please try again.", + type: SnackbarType.error, + ); + } + + isResending.value = false; + } + + void onOTPChanged(String value, int index) { + if (value.isNotEmpty) { + if (index < otpControllers.length - 1) { + focusNodes[index + 1].requestFocus(); + } else { + focusNodes[index].unfocus(); + } + } else { + if (index > 0) { + focusNodes[index - 1].requestFocus(); + } + } + } + + void verifyOTP() { + final enteredOTP = otpControllers.map((c) => c.text).join(); + if (enteredOTP.length != otpControllers.length || enteredOTP.contains('')) { + showAppSnackbar( + title: "Error", + message: "Please enter the complete ${otpControllers.length}-digit OTP", + type: SnackbarType.error, + ); + return; + } + + // TODO: Add your OTP verification logic + debugPrint('Verifying OTP: $enteredOTP'); + } + + void _clearOTPFields() { + for (final controller in otpControllers) { + controller.clear(); + } + focusNodes[0].requestFocus(); + } + + void _startTimer() { + timer.value = 60; + _countdownTimer?.cancel(); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (this.timer.value > 0) { + this.timer.value--; + } else { + timer.cancel(); + } + }); + } + + void resetForChangeNumber() { + isOTPSent.value = false; + phoneNumber.value = ''; + phoneController.clear(); + _clearOTPFields(); + + timer.value = 0; + isSending.value = false; + isResending.value = false; + + for (final node in focusNodes) { + node.unfocus(); + } + } + + bool _validatePhone(String phone) { + final regex = RegExp(r'^\d{10}$'); + return regex.hasMatch(phone); + } +} diff --git a/lib/controller/auth/register_account_controller.dart b/lib/controller/auth/register_account_controller.dart index 3d416f4..61632b6 100644 --- a/lib/controller/auth/register_account_controller.dart +++ b/lib/controller/auth/register_account_controller.dart @@ -61,6 +61,6 @@ class RegisterAccountController extends MyController { } void gotoLogin() { - Get.toNamed('/auth/login'); + Get.toNamed('/auth/login-option'); } } diff --git a/lib/helpers/services/auth_service.dart b/lib/helpers/services/auth_service.dart index a7c356d..1366e33 100644 --- a/lib/helpers/services/auth_service.dart +++ b/lib/helpers/services/auth_service.dart @@ -4,11 +4,12 @@ import 'package:get/get.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/controller/permission_controller.dart'; import 'package:logger/logger.dart'; +import 'package:marco/helpers/services/api_endpoints.dart'; + final Logger logger = Logger(); class AuthService { - static const String _baseUrl = "https://stageapi.marcoaiot.com/api"; - // static const String _baseUrl = "https://api.marcoaiot.com/api"; + static const String _baseUrl = ApiEndpoints.baseUrl; static const Map _headers = { 'Content-Type': 'application/json', }; @@ -17,21 +18,21 @@ class AuthService { Map data) async { try { final response = await http.post( - Uri.parse("$_baseUrl/auth/login"), + Uri.parse("$_baseUrl/auth/login-mobile"), headers: _headers, body: jsonEncode(data), ); final responseData = jsonDecode(response.body); if (response.statusCode == 200 && responseData['data'] != null) { - isLoggedIn = true; - 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); @@ -39,9 +40,16 @@ class AuthService { 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; return null; } else if (response.statusCode == 401) { return {"password": "Invalid email or password"}; @@ -198,4 +206,98 @@ class AuthService { return null; } } + + /// Generates a new MPIN for the user. + static Future?> generateMpin({ + required String employeeId, + required String mpin, + }) async { + final jwtToken = await LocalStorage.getJwtToken(); + + final requestBody = { + "employeeId": employeeId, + "mpin": mpin, + }; + + logger.i("Sending MPIN generation request: $requestBody"); + + try { + final response = await http.post( + Uri.parse("$_baseUrl/auth/generate-mpin"), + headers: { + 'Content-Type': 'application/json', + if (jwtToken != null && jwtToken.isNotEmpty) + 'Authorization': 'Bearer $jwtToken', + }, + body: jsonEncode(requestBody), + ); + + logger.i( + "Generate MPIN API response (${response.statusCode}): ${response.body}"); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200 && responseData['success'] == true) { + logger.i("MPIN generated successfully."); + return null; + } else { + return {"error": responseData['message'] ?? "Failed to generate MPIN."}; + } + } catch (e) { + logger.e("Exception during generate MPIN: $e"); + return {"error": "Network error. Please check your connection."}; + } + } + + static Future?> verifyMpin({ + required String mpin, + required String mpinToken, + }) async { + // Get employee info from local storage + final employeeInfo = LocalStorage.getEmployeeInfo(); + + if (employeeInfo == null) { + logger.w("Employee info not found in local storage."); + return {"error": "Employee info not found. Please login again."}; + } + + final employeeId = employeeInfo.id; + + final jwtToken = await LocalStorage.getJwtToken(); + + final requestBody = { + "employeeId": employeeId, + "mpin": mpin, + "mpinToken": mpinToken, + }; + + logger.i("Sending MPIN verification request: $requestBody"); + + try { + final response = await http.post( + Uri.parse("$_baseUrl/auth/login-mpin"), + headers: { + 'Content-Type': 'application/json', + if (jwtToken != null && jwtToken.isNotEmpty) + 'Authorization': 'Bearer $jwtToken', + }, + body: jsonEncode(requestBody), + ); + + logger.i( + "Verify MPIN API response (${response.statusCode}): ${response.body}"); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200 && responseData['success'] == true) { + logger.i("MPIN verified successfully."); + return null; + } else { + return {"error": responseData['message'] ?? "Failed to verify MPIN."}; + } + } catch (e) { + logger.e("Exception during verify MPIN: $e"); + return {"error": "Network error. Please check your connection."}; + } + } } diff --git a/lib/helpers/services/storage/local_storage.dart b/lib/helpers/services/storage/local_storage.dart index 9504952..c4fc325 100644 --- a/lib/helpers/services/storage/local_storage.dart +++ b/lib/helpers/services/storage/local_storage.dart @@ -15,7 +15,8 @@ class LocalStorage { static const String _refreshTokenKey = "refresh_token"; static const String _userPermissionsKey = "user_permissions"; static const String _employeeInfoKey = "employee_info"; - + static const String _mpinTokenKey = "mpinToken"; + static const String _isMpinKey = "isMpin"; static SharedPreferences? _preferencesInstance; static SharedPreferences get preferences { @@ -132,14 +133,51 @@ class LocalStorage { static Future setRefreshToken(String refreshToken) { return setToken(_refreshTokenKey, refreshToken); } + static Future logout() async { await removeLoggedInUser(); await removeToken(_jwtTokenKey); await removeToken(_refreshTokenKey); await removeUserPermissions(); await removeEmployeeInfo(); - Get.offAllNamed('/auth/login'); + await removeMpinToken(); + await removeIsMpin(); + await preferences.remove("mpin_verified"); await preferences.remove(_languageKey); await preferences.remove(_themeCustomizerKey); + Get.offAllNamed('/auth/login-option'); + } + + static Future setMpinToken(String token) { + return preferences.setString(_mpinTokenKey, token); + } + + static String? getMpinToken() { + return preferences.getString(_mpinTokenKey); + } + + static Future removeMpinToken() { + return preferences.remove(_mpinTokenKey); + } + + // MPIN Enabled flag + static Future setIsMpin(bool value) { + return preferences.setBool(_isMpinKey, value); + } + + static bool getIsMpin() { + return preferences.getBool(_isMpinKey) ?? false; + } + + static Future removeIsMpin() { + return preferences.remove(_isMpinKey); + } + + static Future setBool(String key, bool value) async { + return preferences.setBool(key, value); + } + + static bool? getBool(String key) { + return preferences.getBool(key); } } diff --git a/lib/helpers/theme/admin_theme.dart b/lib/helpers/theme/admin_theme.dart index f6d94a0..29ab18d 100644 --- a/lib/helpers/theme/admin_theme.dart +++ b/lib/helpers/theme/admin_theme.dart @@ -18,7 +18,8 @@ enum ContentThemeColor { dark, pink, green, - red; + red, + brandRed; Color get color { return (AdminTheme.theme.contentTheme.getMappedIntoThemeColor[this]?['color']) ?? Colors.black; @@ -119,6 +120,8 @@ class ContentTheme { final Color purple, onPurple; final Color pink, onPink; final Color red, onRed; + final Color brandRed, onBrandRed; + final Color cardBackground, cardShadow, cardBorder, cardText, cardTextMuted; final Color title; final Color disabled, onDisabled; @@ -136,6 +139,7 @@ class ContentTheme { ContentThemeColor.dark: {'color': c.dark, 'onColor': c.onDark}, ContentThemeColor.pink: {'color': c.pink, 'onColor': c.onPink}, ContentThemeColor.red: {'color': c.red, 'onColor': c.onRed}, + ContentThemeColor.brandRed: {'color': c.brandRed, 'onColor': c.onBrandRed}, }; } @@ -144,8 +148,6 @@ class ContentTheme { this.onBackground = const Color(0xffF1F1F2), this.primary = const Color(0xff663399), this.onPrimary = const Color(0xffffffff), - this.disabled = const Color(0xffffffff), - this.onDisabled = const Color(0xffffffff), this.secondary = const Color(0xff6c757d), this.onSecondary = const Color(0xffffffff), this.success = const Color(0xff00be82), @@ -160,18 +162,22 @@ class ContentTheme { this.onLight = const Color(0xff313a46), this.dark = const Color(0xff313a46), this.onDark = const Color(0xffffffff), + this.purple = const Color(0xff800080), + this.onPurple = const Color(0xffFF0000), + this.pink = const Color(0xffFF1087), + this.onPink = const Color(0xffffffff), + this.red = const Color(0xffFF0000), + this.onRed = const Color(0xffffffff), + this.brandRed = const Color.fromARGB(255, 255, 0, 0), + this.onBrandRed = const Color(0xffffffff), this.cardBackground = const Color(0xffffffff), this.cardShadow = const Color(0xffffffff), this.cardBorder = const Color(0xffffffff), this.cardText = const Color(0xff6c757d), this.cardTextMuted = const Color(0xff98a6ad), this.title = const Color(0xff6c757d), - this.pink = const Color(0xffFF1087), - this.onPink = const Color(0xffffffff), - this.purple = const Color(0xff800080), - this.onPurple = const Color(0xffFF0000), - this.red = const Color(0xffFF0000), - this.onRed = const Color(0xffffffff), + this.disabled = const Color(0xffffffff), + this.onDisabled = const Color(0xffffffff), }); //-------------------------------------- Left Bar Theme ----------------------------------------// @@ -186,6 +192,8 @@ class ContentTheme { cardText: const Color(0xff6c757d), title: const Color(0xff6c757d), cardTextMuted: const Color(0xff98a6ad), + brandRed: const Color.fromARGB(255, 255, 0, 0), + onBrandRed: const Color(0xffffffff), ); static final ContentTheme darkContentTheme = ContentTheme( @@ -200,6 +208,8 @@ class ContentTheme { cardText: const Color(0xffaab8c5), title: const Color(0xffaab8c5), cardTextMuted: const Color(0xff8391a2), + brandRed: const Color.fromARGB(255, 255, 0, 0), + onBrandRed: const Color(0xffffffff), ); } diff --git a/lib/main.dart b/lib/main.dart index 9a54e61..1b5de88 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,41 +12,68 @@ import 'package:marco/routes.dart'; import 'package:provider/provider.dart'; import 'package:url_strategy/url_strategy.dart'; import 'package:marco/helpers/services/auth_service.dart'; +import 'package:flutter/services.dart'; +import 'package:logger/logger.dart'; +final Logger logger = Logger(); Future main() async { WidgetsFlutterBinding.ensureInitialized(); setPathUrlStrategy(); + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + statusBarColor: const Color.fromARGB(255, 255, 0, 0), + statusBarIconBrightness: Brightness.light, + )); + try { await LocalStorage.init(); await ThemeCustomizer.init(); AppStyle.init(); - } catch (e) { - print('Error during app initialization: $e'); + logger.i("App initialization completed successfully."); + } catch (e, stacktrace) { + logger.e('Error during app initialization:', + error: e, stackTrace: stacktrace); return; } runApp(ChangeNotifierProvider( create: (context) => AppNotifier(), - child: MyApp(), + child: const MyApp(), )); } class MyApp extends StatelessWidget { const MyApp({super.key}); + Future _getInitialRoute() async { - return AuthService.isLoggedIn ? "/dashboard" : "/auth/login"; + if (!AuthService.isLoggedIn) { + logger.i("User not logged in. Routing to /auth/login-option"); + return "/auth/login-option"; + } + logger.i("User is logged in."); + + final bool hasMpin = LocalStorage.getIsMpin(); + logger.i("MPIN enabled: $hasMpin"); + + if (hasMpin) { + await LocalStorage.setBool("mpin_verified", false); + logger.i("Routing to /auth/mpin-auth and setting mpin_verified to false"); + return "/auth/mpin-auth"; + } else { + logger.i("MPIN not enabled. Routing to /home"); + return "/home"; + } } - @override + @override Widget build(BuildContext context) { return Consumer( - builder: (_, notifier, ___) { + builder: (_, notifier, __) { return FutureBuilder( future: _getInitialRoute(), builder: (context, snapshot) { if (!snapshot.hasData) { - return MaterialApp( + return const MaterialApp( home: Center(child: CircularProgressIndicator()), ); } diff --git a/lib/routes.dart b/lib/routes.dart index 55a14eb..0c93767 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -17,11 +17,16 @@ import 'package:marco/view/dashboard/Attendence/attendance_screen.dart'; import 'package:marco/view/taskPlaning/daily_task_planing.dart'; import 'package:marco/view/taskPlaning/daily_progress.dart'; import 'package:marco/view/employees/employees_screen.dart'; +import 'package:marco/view/auth/login_option_screen.dart'; +import 'package:marco/view/auth/mpin_screen.dart'; +import 'package:marco/view/auth/mpin_auth_screen.dart'; class AuthMiddleware extends GetMiddleware { @override RouteSettings? redirect(String? route) { - return AuthService.isLoggedIn ? null : RouteSettings(name: '/auth/login'); + return AuthService.isLoggedIn + ? null + : RouteSettings(name: '/auth/login-option'); } } @@ -58,7 +63,7 @@ getPageRoute() { name: '/dashboard/daily-task-planing', page: () => DailyTaskPlaningScreen(), middlewares: [AuthMiddleware()]), - GetPage( + GetPage( name: '/dashboard/daily-task-progress', page: () => DailyProgressReportScreen(), middlewares: [AuthMiddleware()]), @@ -72,12 +77,15 @@ getPageRoute() { middlewares: [AuthMiddleware()]), // Authentication GetPage(name: '/auth/login', page: () => LoginScreen()), + GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()), + GetPage(name: '/auth/mpin', page: () => MPINScreen()), + GetPage(name: '/auth/mpin-auth', page: () => MPINAuthScreen()), GetPage( name: '/auth/register_account', page: () => const RegisterAccountScreen()), GetPage( name: '/auth/forgot_password', - page: () => const ForgotPasswordScreen()), + page: () => ForgotPasswordScreen()), GetPage( name: '/auth/reset_password', page: () => const ResetPasswordScreen()), // Error diff --git a/lib/view/auth/email_login_form.dart b/lib/view/auth/email_login_form.dart new file mode 100644 index 0000000..03db388 --- /dev/null +++ b/lib/view/auth/email_login_form.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_lucide/flutter_lucide.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/auth/login_controller.dart'; +import 'package:marco/helpers/theme/app_theme.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_button.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/view/auth/request_demo_bottom_sheet.dart'; +import 'package:marco/helpers/services/api_endpoints.dart'; + +class EmailLoginForm extends StatefulWidget { + EmailLoginForm({super.key}); + + @override + State createState() => _EmailLoginFormState(); +} + +class _EmailLoginFormState extends State with UIMixin { + late final LoginController controller; + bool get isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); + @override + void initState() { + controller = Get.put(LoginController(), tag: 'login_controller'); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: 'login_controller', + builder: (_) { + return Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + return Form( + key: controller.basicValidator.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // MyText.titleMedium('Login With Username & Password', + // fontWeight: 600), + MySpacing.height(8), + MyText.bodyMedium("User Name", fontWeight: 600), + MySpacing.height(8), + _buildInputField( + controller.basicValidator.getController('username')!, + controller.basicValidator.getValidation('username'), + hintText: "Enter your email", + icon: LucideIcons.mail, + keyboardType: TextInputType.emailAddress, + ), + MySpacing.height(16), + MyText.bodyMedium("Password", fontWeight: 600), + MySpacing.height(8), + Obx(() { + return _buildInputField( + controller.basicValidator.getController('password')!, + controller.basicValidator.getValidation('password'), + hintText: "Enter your password", + icon: LucideIcons.lock, + obscureText: !controller.showPassword.value, + suffix: IconButton( + icon: Icon( + controller.showPassword.value + ? LucideIcons.eye + : LucideIcons.eye_off, + size: 18, + ), + onPressed: controller.onChangeShowPassword, + ), + ); + }), + MySpacing.height(16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Obx(() { + return InkWell( + onTap: () => controller + .onChangeCheckBox(!controller.isChecked.value), + child: Row( + children: [ + Checkbox( + value: controller.isChecked.value, + onChanged: controller.onChangeCheckBox, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + fillColor: + MaterialStateProperty.resolveWith( + (states) => + states.contains(WidgetState.selected) + ? contentTheme.brandRed + : Colors.white, + ), + checkColor: contentTheme.onPrimary, + visualDensity: getCompactDensity, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + MySpacing.width(8), + MyText.bodySmall("Remember Me"), + ], + ), + ); + }), + MyButton.text( + onPressed: controller.goToForgotPassword, + elevation: 0, + padding: MySpacing.xy(8, 0), + splashColor: contentTheme.secondary.withAlpha(36), + child: MyText.bodySmall( + 'Forgot password?', + fontWeight: 600, + color: contentTheme.secondary, + ), + ), + ], + ), + MySpacing.height(28), + Center( + child: MyButton.rounded( + onPressed: controller.onLogin, + elevation: 2, + padding: MySpacing.xy(80, 16), + borderRadiusAll: 10, + backgroundColor: contentTheme.brandRed, + child: MyText.labelLarge( + 'Login', + fontWeight: 700, + color: contentTheme.onPrimary, + ), + ), + ), + MySpacing.height(16), + Center( + child: MyButton.text( + onPressed: () { + OrganizationFormBottomSheet.show(context); + }, + elevation: 0, + padding: MySpacing.xy(12, 8), + splashColor: contentTheme.secondary.withAlpha(30), + child: MyText.bodySmall( + "Request a Demo", + color: contentTheme.brandRed, + fontWeight: 600, + ), + ), + ), + ], + ), + ); + }); + }, + ); + } + + Widget _buildInputField( + TextEditingController controller, + FormFieldValidator? validator, { + required String hintText, + required IconData icon, + TextInputType keyboardType = TextInputType.text, + bool obscureText = false, + Widget? suffix, + }) { + return Material( + elevation: 2, + shadowColor: contentTheme.secondary.withAlpha(30), + borderRadius: BorderRadius.circular(12), + child: TextFormField( + controller: controller, + validator: validator, + obscureText: obscureText, + keyboardType: keyboardType, + style: MyTextStyle.labelMedium(), + decoration: InputDecoration( + hintText: hintText, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: theme.cardColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(2), + borderSide: BorderSide.none, + ), + prefixIcon: Icon(icon, size: 18), + suffixIcon: suffix, + contentPadding: MySpacing.xy(12, 16), + ), + ), + ); + } +} diff --git a/lib/view/auth/forgot_password_screen.dart b/lib/view/auth/forgot_password_screen.dart index 4c9f08a..b3f925b 100644 --- a/lib/view/auth/forgot_password_screen.dart +++ b/lib/view/auth/forgot_password_screen.dart @@ -2,15 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:get/get.dart'; import 'package:marco/controller/auth/forgot_password_controller.dart'; -import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_button.dart'; -import 'package:marco/helpers/widgets/my_spacing.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; -import 'package:marco/view/layouts/auth_layout.dart'; import 'package:marco/images.dart'; -import 'package:marco/helpers/theme/app_theme.dart'; - +import 'package:marco/helpers/services/api_endpoints.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; class ForgotPasswordScreen extends StatefulWidget { const ForgotPasswordScreen({super.key}); @@ -19,100 +18,186 @@ class ForgotPasswordScreen extends StatefulWidget { State createState() => _ForgotPasswordScreenState(); } -class _ForgotPasswordScreenState extends State with UIMixin { - ForgotPasswordController controller = Get.put(ForgotPasswordController()); +class _ForgotPasswordScreenState extends State + with UIMixin { + final ForgotPasswordController controller = + Get.put(ForgotPasswordController()); + + bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); @override Widget build(BuildContext context) { - return AuthLayout( - child: GetBuilder( - init: controller, - builder: (controller) { - return Form( - key: controller.basicValidator.formKey, - child: SingleChildScrollView( - padding: MySpacing.xy(2, 40), - child: Container( - width: double.infinity, - padding: MySpacing.all(24), - decoration: BoxDecoration( - color: theme.colorScheme.primary.withOpacity(0.02), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: contentTheme.primary.withOpacity(0.5), + return Scaffold( + backgroundColor: contentTheme.brandRed, + body: SafeArea( + child: LayoutBuilder(builder: (context, constraints) { + return Column( + children: [ + const SizedBox(height: 24), + _buildHeader(), + const SizedBox(height: 16), + _buildWelcomeTextsAndChips(), + const SizedBox(height: 16), + Expanded( + child: Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.vertical(top: Radius.circular(32)), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 32), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 120, + ), + child: Form( + key: controller.basicValidator.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + MyText.titleLarge( + 'Forgot Password', + fontWeight: 700, + color: Colors.black87, + ), + const SizedBox(height: 8), + MyText.bodyMedium( + "Enter your email and we'll send you instructions to reset your password.", + color: Colors.black54, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + TextFormField( + validator: controller.basicValidator + .getValidation('email'), + controller: controller.basicValidator + .getController('email'), + keyboardType: TextInputType.emailAddress, + style: MyTextStyle.labelMedium(), + decoration: InputDecoration( + labelText: "Email Address", + labelStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + prefixIcon: + const Icon(LucideIcons.mail, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 16), + floatingLabelBehavior: + FloatingLabelBehavior.auto, + ), + ), + const SizedBox(height: 40), + MyButton.rounded( + onPressed: controller.onForgotPassword, + elevation: 2, + padding: MySpacing.xy(80, 16), + borderRadiusAll: 10, + backgroundColor: contentTheme.brandRed, + child: MyText.labelLarge( + 'Send Reset Link', + fontWeight: 700, + color: Colors.white, + ), + ), + const SizedBox(height: 24), + TextButton( + onPressed: () async { + await LocalStorage.logout(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.arrow_back, + size: 16, color: Colors.red), + const SizedBox(width: 4), + MyText.bodySmall( + 'Back to log in', + fontWeight: 600, + fontSize: 14, + color: contentTheme.brandRed, + ), + ], + ), + ), + ], + ), + ), + ), ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Image.asset( - Images.logoDark, - height: 120, - fit: BoxFit.contain, - ), - ), - MySpacing.height(10), - MyText.titleLarge("Forgot Password", fontWeight: 600), - MySpacing.height(12), - MyText.bodyMedium( - "Enter your email and we'll send you instructions to reset your password.", - fontWeight: 600, - xMuted: true, - ), - MySpacing.height(12), - TextFormField( - validator: controller.basicValidator.getValidation('email'), - controller: controller.basicValidator.getController('email'), - keyboardType: TextInputType.emailAddress, - style: MyTextStyle.labelMedium(), - decoration: InputDecoration( - labelText: "Email Address", - labelStyle: MyTextStyle.bodySmall(xMuted: true), - border: OutlineInputBorder(borderSide: BorderSide.none), - filled: true, - fillColor: theme.cardColor, - prefixIcon: Icon(LucideIcons.mail, size: 16), - contentPadding: MySpacing.all(15), - isDense: true, - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(20), - Center( - child: MyButton.rounded( - onPressed: controller.onForgotPassword, - elevation: 0, - padding: MySpacing.xy(20, 16), - backgroundColor: Colors.blueAccent, - child: MyText.labelMedium( - 'Send Reset Link', - color: contentTheme.onPrimary, - ), - ), - ), - Center( - child: MyButton.text( - onPressed: controller.gotoLogIn, - elevation: 0, - padding: MySpacing.x(16), - splashColor: - contentTheme.secondary.withValues(alpha: 0.1), - child: MyText.labelMedium( - 'Back to log in', - color: contentTheme.secondary, - ), - ), - ), - ], - ), ), - ), + ], ); - }, + }), ), ); } + + Widget _buildWelcomeTextsAndChips() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + MyText.titleMedium( + "Welcome to Marco", + fontWeight: 600, + color: Colors.white, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + MyText.bodySmall( + "Streamline Project Management and Boost Productivity with Automation.", + color: Colors.white70, + textAlign: TextAlign.center, + ), + if (_isBetaEnvironment) ...[ + const SizedBox(height: 8), + _buildBetaLabel(), + ], + ], + ), + ); + } + + Widget _buildBetaLabel() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(4), + ), + child: MyText.bodySmall( + 'BETA', + fontWeight: 600, + color: Colors.white, + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 6, + offset: Offset(0, 3), + ), + ], + ), + child: Image.asset(Images.logoDark, height: 70), + ); + } } diff --git a/lib/view/auth/login_option_screen.dart b/lib/view/auth/login_option_screen.dart new file mode 100644 index 0000000..c155cb9 --- /dev/null +++ b/lib/view/auth/login_option_screen.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_lucide/flutter_lucide.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/images.dart'; +import 'package:marco/view/auth/email_login_form.dart'; +import 'package:marco/view/auth/otp_login_form.dart'; +import 'package:marco/helpers/services/api_endpoints.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; // Make sure this import is added + +enum LoginOption { email, otp } + +class LoginOptionScreen extends StatefulWidget { + const LoginOptionScreen({super.key}); + + @override + State createState() => _LoginOptionScreenState(); +} + +class _LoginOptionScreenState extends State with UIMixin { + LoginOption _selectedOption = LoginOption.email; + bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: contentTheme.brandRed, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Column( + children: [ + const SizedBox(height: 24), + _buildHeader(), + const SizedBox(height: 16), + _buildWelcomeTextsAndChips(), + const SizedBox(height: 16), + Expanded( + child: Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.vertical(top: Radius.circular(32)), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 24), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 200, + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLoginForm(), + const SizedBox(height: 24), + const SizedBox(height: 8), + Center(child: _buildVersionInfo()), + ], + ), + ), + ), + ), + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 6, + offset: Offset(0, 3), + ) + ], + ), + child: Image.asset(Images.logoDark, height: 70), + ); + } + + Widget _buildBetaLabel() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(4), + ), + child: MyText( + 'BETA', + color: Colors.white, + fontWeight: 600, + fontSize: 12, + ), + ); + } + + Widget _buildLoginOptionChips() { + return Wrap( + spacing: 12, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + _buildOptionChip( + title: "User Name", + icon: LucideIcons.mail, + value: LoginOption.email, + ), + _buildOptionChip( + title: "OTP", + icon: LucideIcons.message_square, + value: LoginOption.otp, + ), + ], + ); + } + + Widget _buildWelcomeTextsAndChips() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + MyText( + "Welcome to Marco", + fontSize: 20, + fontWeight: 700, + color: Colors.white, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + MyText( + "Streamline Project Management and Boost Productivity with Automation.", + fontSize: 14, + color: Colors.white70, + textAlign: TextAlign.center, + ), + if (_isBetaEnvironment) ...[ + const SizedBox(height: 8), + _buildBetaLabel(), + ], + const SizedBox(height: 20), + _buildLoginOptionChips(), + ], + ), + ); + } + + Widget _buildOptionChip({ + required String title, + required IconData icon, + required LoginOption value, + }) { + final bool isSelected = _selectedOption == value; + + final Color selectedTextColor = contentTheme.brandRed; + final Color unselectedTextColor = Colors.white; + final Color selectedBgColor = Colors.grey[100]!; + final Color unselectedBgColor = contentTheme.brandRed; + + return ChoiceChip( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 18, + color: isSelected ? selectedTextColor : unselectedTextColor, + ), + const SizedBox(width: 6), + MyText( + title, + fontSize: 14, + fontWeight: 600, + color: isSelected ? selectedTextColor : unselectedTextColor, + ), + ], + ), + selected: isSelected, + onSelected: (_) => setState(() => _selectedOption = value), + selectedColor: selectedBgColor, + backgroundColor: unselectedBgColor, + side: BorderSide( + color: Colors.white.withOpacity(0.6), + width: 1.2, + ), + elevation: 3, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + ); + } + + Widget _buildLoginForm() { + switch (_selectedOption) { + case LoginOption.email: + return EmailLoginForm(); + case LoginOption.otp: + return const OTPLoginScreen(); + } + } + + Widget _buildVersionInfo() { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: MyText( + 'App version 1.0.0', + color: Colors.grey.shade500, + fontSize: 12, + ), + ); + } +} diff --git a/lib/view/auth/mpin_auth_screen.dart b/lib/view/auth/mpin_auth_screen.dart new file mode 100644 index 0000000..f1dfbfc --- /dev/null +++ b/lib/view/auth/mpin_auth_screen.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/auth/mpin_controller.dart'; +import 'package:marco/helpers/widgets/my_button.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/images.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; +import 'package:marco/helpers/services/api_endpoints.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; + +class MPINAuthScreen extends StatefulWidget { + const MPINAuthScreen({super.key}); + + @override + State createState() => _MPINAuthScreenState(); +} + +class _MPINAuthScreenState extends State with UIMixin { + bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); + @override + void dispose() { + Get.delete(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final MPINController controller = Get.put(MPINController()); + + return Scaffold( + backgroundColor: contentTheme.brandRed, + body: SafeArea( + child: LayoutBuilder(builder: (context, constraints) { + return Column( + children: [ + _buildHeader(), + const SizedBox(height: 16), + _buildWelcomeTextsAndChips(), + const SizedBox(height: 16), + Expanded( + child: Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.vertical(top: Radius.circular(32)), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 32), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 120), + child: Obx(() { + final isNewUser = controller.isNewUser.value; + + return IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + MyText.headlineSmall( + isNewUser ? 'Generate MPIN' : 'Enter MPIN', + fontWeight: 700, + color: Colors.black87, + ), + const SizedBox(height: 8), + MyText.bodyMedium( + isNewUser + ? 'Set your 6-digit MPIN for quick login.' + : 'Enter your 6-digit MPIN to continue.', + color: Colors.black54, + fontSize: 16, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + _buildMPINForm(controller, isNewUser), + const SizedBox(height: 40), + _buildSubmitButton(controller, isNewUser), + const SizedBox(height: 24), + _buildFooterOptions(controller, isNewUser), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 24), + child: Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () async { + await LocalStorage.logout(); + }, + icon: const Icon(Icons.arrow_back, + color: Colors.white), + label: MyText.bodyMedium( + 'Back to Home Page', + color: Colors.white, + fontWeight: 600, + fontSize: 14, + ), + style: TextButton.styleFrom( + foregroundColor: Colors.white, + ), + ), + ), + ), + ], + ), + ); + }), + ), + ), + ), + ), + ], + ); + }), + ), + ); + } + + Widget _buildWelcomeTextsAndChips() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + MyText.headlineSmall( + "Welcome to Marco", + fontWeight: 700, + color: Colors.white, + textAlign: TextAlign.center, + fontSize: 20, + ), + const SizedBox(height: 4), + MyText.bodyMedium( + "Streamline Project Management and Boost Productivity with Automation.", + color: Colors.white70, + fontSize: 14, + textAlign: TextAlign.center, + ), + if (_isBetaEnvironment) ...[ + const SizedBox(height: 8), + _buildBetaLabel(), + ], + ], + ), + ); + } + + Widget _buildBetaLabel() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(4), + ), + child: MyText.bodySmall( + 'BETA', + color: Colors.white, + fontWeight: 600, + fontSize: 12, + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 6, + offset: Offset(0, 3), + ), + ], + ), + child: Image.asset(Images.logoDark, height: 70), + ); + } + + Widget _buildMPINForm(MPINController controller, bool isNewUser) { + return Form( + key: controller.formKey, + child: Column( + children: [ + _buildDigitRow(controller, isRetype: false), + if (isNewUser) ...[ + const SizedBox(height: 24), + MyText.bodyMedium( + 'Retype MPIN', + fontWeight: 600, + color: Colors.black.withOpacity(0.6), + fontSize: 14, + ), + const SizedBox(height: 8), + _buildDigitRow(controller, isRetype: true), + ], + ], + ), + ); + } + + Widget _buildDigitRow(MPINController controller, {required bool isRetype}) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(6, (index) { + return _buildDigitBox(controller, index, isRetype); + }), + ); + } + + Widget _buildDigitBox(MPINController controller, int index, bool isRetype) { + final textController = isRetype + ? controller.retypeControllers[index] + : controller.digitControllers[index]; + final focusNode = isRetype + ? controller.retypeFocusNodes[index] + : controller.focusNodes[index]; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 6), + width: 40, + height: 55, + child: TextFormField( + controller: textController, + focusNode: focusNode, + obscureText: true, + maxLength: 1, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + letterSpacing: 8, + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (value) => + controller.onDigitChanged(value, index, isRetype: isRetype), + decoration: InputDecoration( + counterText: '', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ); + } + + Widget _buildSubmitButton(MPINController controller, bool isNewUser) { + return Obx(() { + return MyButton.rounded( + onPressed: controller.isLoading.value ? null : controller.onSubmitMPIN, + elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + borderRadiusAll: 10, + backgroundColor: contentTheme.brandRed, + child: controller.isLoading.value + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2), + ) + : MyText.bodyMedium( + isNewUser ? 'Generate MPIN' : 'Submit MPIN', + color: Colors.white, + fontWeight: 700, + fontSize: 16, + ), + ); + }); + } + + Widget _buildFooterOptions(MPINController controller, bool isNewUser) { + return Column( + children: [ + if (isNewUser) + TextButton.icon( + onPressed: controller.switchToEnterMPIN, + icon: const Icon(Icons.arrow_back, size: 18, color: Colors.black87), + label: MyText.bodyMedium( + 'Back to Enter MPIN', + color: Colors.black87, + fontWeight: 600, + fontSize: 14, + ), + ), + if (isNewUser) + TextButton.icon( + onPressed: () async { + Get.delete(); + Get.toNamed('/dashboard'); + }, + icon: + const Icon(Icons.arrow_back, size: 18, color: Colors.redAccent), + label: MyText.bodyMedium( + 'Back to Home Page', + color: contentTheme.brandRed, + fontWeight: 600, + fontSize: 14, + ), + ), + ], + ); + } +} diff --git a/lib/view/auth/mpin_screen.dart b/lib/view/auth/mpin_screen.dart new file mode 100644 index 0000000..b453b29 --- /dev/null +++ b/lib/view/auth/mpin_screen.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/auth/mpin_controller.dart'; +import 'package:marco/helpers/theme/app_theme.dart'; +import 'package:marco/helpers/widgets/my_button.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; + +class MPINScreen extends StatelessWidget { + const MPINScreen({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(MPINController()); + + return Obx(() { + final isNewUser = controller.isNewUser.value; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleMedium( + isNewUser ? 'Generate MPIN' : 'Enter MPIN', + fontWeight: 600, + ), + MySpacing.height(4), + MyText.bodySmall( + isNewUser + ? 'Set your 6-digit MPIN for quick login.' + : 'Enter your 6-digit MPIN to continue.', + ), + MySpacing.height(24), + _buildMPINForm(controller, isNewUser), + MySpacing.height(32), + _buildSubmitButton(controller, isNewUser), + MySpacing.height(16), + _buildFooterOptions(controller, isNewUser), + ], + ); + }); + } + + Widget _buildMPINForm(MPINController controller, bool isNewUser) { + return Form( + key: controller.formKey, + child: Column( + children: [ + _buildDigitRow(controller, isRetype: false), + if (isNewUser) ...[ + MySpacing.height(20), + MyText.bodySmall( + 'Retype MPIN', + fontWeight: 600, + color: theme.colorScheme.onBackground.withOpacity(0.7), + ), + MySpacing.height(8), + _buildDigitRow(controller, isRetype: true), + ], + ], + ), + ); + } + + Widget _buildDigitRow(MPINController controller, {required bool isRetype}) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(6, (index) { + return _buildDigitBox(controller, index, isRetype); + }), + ); + } + + Widget _buildDigitBox(MPINController controller, int index, bool isRetype) { + final textController = isRetype + ? controller.retypeControllers[index] + : controller.digitControllers[index]; + final focusNode = isRetype + ? controller.retypeFocusNodes[index] + : controller.focusNodes[index]; + + return Container( + margin: MySpacing.x(4), + width: 40, + child: TextFormField( + controller: textController, + focusNode: focusNode, + obscureText: true, + maxLength: 1, + textAlign: TextAlign.center, + style: MyTextStyle.titleLarge(fontWeight: 700), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (value) => + controller.onDigitChanged(value, index, isRetype: isRetype), + decoration: InputDecoration( + counterText: '', + filled: true, + fillColor: theme.cardColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ); + } + + Widget _buildSubmitButton(MPINController controller, bool isNewUser) { + return Center( + child: Obx(() { + return MyButton.rounded( + onPressed: + controller.isLoading.value ? null : controller.onSubmitMPIN, + elevation: 2, + padding: MySpacing.xy(24, 16), + borderRadiusAll: 16, + backgroundColor: Colors.blueAccent, + child: controller.isLoading.value + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, strokeWidth: 2), + ) + : MyText.labelMedium( + isNewUser ? 'Generate MPIN' : 'Submit MPIN', + fontWeight: 600, + color: theme.colorScheme.onPrimary, + ), + ); + }), + ); + } + + Widget _buildFooterOptions(MPINController controller, bool isNewUser) { + return Center( + child: TextButton( + onPressed: + isNewUser ? controller.switchToEnterMPIN : controller.onForgotMPIN, + child: MyText.bodySmall( + isNewUser ? 'Back to Enter MPIN' : 'Generate MPIN?', + color: Colors.blueAccent, + fontWeight: 600, + ), + ), + ); + } +} diff --git a/lib/view/auth/otp_login_form.dart b/lib/view/auth/otp_login_form.dart new file mode 100644 index 0000000..596b6f3 --- /dev/null +++ b/lib/view/auth/otp_login_form.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/widgets/my_button.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +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'; + +class OTPLoginScreen extends StatefulWidget { + const OTPLoginScreen({super.key}); + + @override + State createState() => _OTPLoginScreenState(); +} + +class _OTPLoginScreenState extends State with UIMixin { + late final OTPController controller; + final GlobalKey _otpFormKey = GlobalKey(); + + @override + void initState() { + controller = Get.put(OTPController()); + super.initState(); + } + + @override + 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), + ], + ], + ), + ); + }); + } + + Widget _buildPhoneInput() { + return TextFormField( + controller: controller.phoneController, + keyboardType: TextInputType.phone, + maxLength: 10, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: 'Enter Mobile Number', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + counterText: '', + ), + onFieldSubmitted: (_) => controller.sendOTP(), + ); + } + + Widget _buildSendOTPButton(ThemeData theme) { + final isDisabled = controller.isSending.value || controller.timer.value > 0; + return Align( + alignment: Alignment.center, + child: MyButton.rounded( + onPressed: isDisabled ? null : controller.sendOTP, + elevation: 2, + padding: MySpacing.xy(24, 16), + borderRadiusAll: 10, + backgroundColor: isDisabled ? Colors.grey : contentTheme.brandRed, + child: controller.isSending.value + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onPrimary, + ), + ) + : MyText.labelMedium( + 'Send OTP', + fontWeight: 600, + color: theme.colorScheme.onPrimary, + ), + ), + ); + } + + Widget _buildOTPSentInfo() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.sms_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, + ), + ], + ), + ), + TextButton.icon( + onPressed: controller.resetForChangeNumber, + 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, + ), + ), + ], + ), + ); + } + + Widget _buildOTPForm(ThemeData theme) { + return Form( + key: _otpFormKey, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + 4, (index) => _buildOTPBox(index: index, theme: theme)), + ), + ); + } + + Widget _buildOTPBox({required int index, required ThemeData theme}) { + return Container( + margin: MySpacing.x(6), + width: 50, + child: TextFormField( + controller: controller.otpControllers[index], + focusNode: controller.focusNodes[index], + maxLength: 1, + textAlign: TextAlign.center, + style: MyTextStyle.titleLarge(fontWeight: 700), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (value) => controller.onOTPChanged(value, index), + decoration: InputDecoration( + counterText: '', + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade400, width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: contentTheme.brandRed, width: 2), + ), + ), + ), + ); + } + + Widget _buildResendOTPSection(ThemeData theme) { + if (controller.timer.value > 0) { + return MyText.bodySmall( + 'Resend OTP in ${controller.timer.value}s', + color: theme.colorScheme.onBackground.withOpacity(0.6), + textAlign: TextAlign.center, + ); + } else { + return Align( + alignment: Alignment.center, + child: TextButton( + onPressed: + controller.isResending.value ? null : controller.onResendOTP, + child: controller.isResending.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : MyText.bodySmall( + 'Resend OTP', + fontWeight: 600, + color: Colors.blueAccent, + ), + ), + ); + } + } + + Widget _buildVerifyOTPButton(ThemeData theme) { + return Align( + alignment: Alignment.center, + child: MyButton.rounded( + onPressed: controller.verifyOTP, + elevation: 2, + padding: MySpacing.xy(24, 16), + borderRadiusAll: 10, + backgroundColor: contentTheme.brandRed, + child: MyText.labelMedium( + 'Verify OTP', + fontWeight: 600, + color: theme.colorScheme.onPrimary, + ), + ), + ); + } +} diff --git a/lib/view/auth/request_demo_bottom_sheet.dart b/lib/view/auth/request_demo_bottom_sheet.dart index 691d002..b3b21c4 100644 --- a/lib/view/auth/request_demo_bottom_sheet.dart +++ b/lib/view/auth/request_demo_bottom_sheet.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:marco/helpers/widgets/my_form_validator.dart'; import 'package:marco/helpers/services/auth_service.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; class OrganizationFormBottomSheet { static void show(BuildContext context) { @@ -14,9 +16,8 @@ class OrganizationFormBottomSheet { initialChildSize: 0.85, minChildSize: 0.5, maxChildSize: 0.95, - builder: (context, scrollController) { - return _OrganizationForm(scrollController: scrollController); - }, + builder: (context, scrollController) => + _OrganizationForm(scrollController: scrollController), ), ); } @@ -30,21 +31,16 @@ class _OrganizationForm extends StatefulWidget { State<_OrganizationForm> createState() => _OrganizationFormState(); } -class _OrganizationFormState extends State<_OrganizationForm> { +class _OrganizationFormState extends State<_OrganizationForm> with UIMixin { final MyFormValidator validator = MyFormValidator(); bool _loading = false; + bool _agreed = false; List> _industries = []; String? _selectedIndustryId; + String? _selectedSize; - final List _sizes = [ - '1-10', - '11-50', - '51-200', - '201-1000', - '1000+', - ]; - + final List _sizes = ['1-10', '11-50', '51-200', '201-1000', '1000+']; final Map _sizeApiMap = { '1-10': 'less than 10', '11-50': '11 to 50', @@ -53,25 +49,24 @@ class _OrganizationFormState extends State<_OrganizationForm> { '1000+': 'more than 1000', }; - String? _selectedSize; - bool _agreed = false; - @override void initState() { super.initState(); - _loadIndustries(); + _registerFields(); + } - validator.addField('organizationName', + void _registerFields() { + validator.addField('organizationName', required: true, controller: TextEditingController()); - validator.addField('email', + validator.addField('email', required: true, controller: TextEditingController()); - validator.addField('contactPerson', + validator.addField('contactPerson', required: true, controller: TextEditingController()); - validator.addField('contactNumber', + validator.addField('contactNumber', required: true, controller: TextEditingController()); - validator.addField('about', controller: TextEditingController()); - validator.addField('address', + validator.addField('about', controller: TextEditingController()); + validator.addField('address', required: true, controller: TextEditingController()); } @@ -91,38 +86,53 @@ class _OrganizationFormState extends State<_OrganizationForm> { color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.fromLTRB(20, 20, 20, 40), child: SingleChildScrollView( controller: widget.scrollController, child: Form( key: validator.formKey, child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 40, - height: 5, - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(10), + Center( + child: Container( + width: 40, + height: 5, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(10), + ), ), ), - Text( - 'Adventure starts here 🚀', - style: Theme.of(context).textTheme.titleLarge, + Center( + child: Column( + children: [ + MyText.titleLarge( + 'Adventure starts here 🚀', + fontWeight: 600, + color: Colors.black87, + ), + const SizedBox(height: 4), + MyText.bodySmall( + "Make your app management easy and fun!", + color: Colors.grey, + ), + ], + ), ), - const SizedBox(height: 4), - const Text("Make your app management easy and fun!"), const SizedBox(height: 20), + _sectionHeader('Organization Info'), _buildTextField('organizationName', 'Organization Name'), _buildTextField('email', 'Email', keyboardType: TextInputType.emailAddress), + _buildTextField('about', 'About Organization'), + _sectionHeader('Contact Details'), _buildTextField('contactPerson', 'Contact Person'), _buildTextField('contactNumber', 'Contact Number', keyboardType: TextInputType.phone), - _buildTextField('about', 'About Organization'), _buildTextField('address', 'Current Address'), - const SizedBox(height: 10), + _sectionHeader('Additional Details'), _buildPopupMenuField( 'Organization Size', _sizes, @@ -140,58 +150,78 @@ class _OrganizationFormState extends State<_OrganizationForm> { (val) { setState(() { final selectedIndustry = _industries.firstWhere( - (element) => element['name'] == val, - orElse: () => {}); + (element) => element['name'] == val, + orElse: () => {}, + ); _selectedIndustryId = selectedIndustry['id']; }); }, 'Please select industry', ), - const SizedBox(height: 10), - GestureDetector( - onTap: () => setState(() => _agreed = !_agreed), - child: Row( - children: [ - Checkbox( - value: _agreed, - onChanged: (val) => - setState(() => _agreed = val ?? false), - fillColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { - return Colors.blueAccent; - } - return Colors.white; - }), - checkColor: Colors.white, - side: - const BorderSide(color: Colors.blueAccent, width: 2), + const SizedBox(height: 12), + Row( + children: [ + Checkbox( + value: _agreed, + onChanged: (val) => setState(() => _agreed = val ?? false), + fillColor: MaterialStateProperty.resolveWith((states) => + states.contains(MaterialState.selected) + ? contentTheme.brandRed + : Colors.white), + checkColor: Colors.white, + side: const BorderSide(color: Colors.red, width: 2), + ), + Row( + children: [ + MyText( + 'I agree to the ', + color: Colors.black87, + ), + MyText( + 'privacy policy & terms', + color: contentTheme.brandRed, + fontWeight: 600, + ), + ], + ) + ], + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: contentTheme.brandRed, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), ), - const Expanded( - child: Text('I agree to privacy policy & terms'), - ), - ], + ), + onPressed: _loading ? null : _submitForm, + child: _loading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : MyText.labelLarge('Submit', color: Colors.white), ), ), - const SizedBox(height: 10), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent, + const SizedBox(height: 8), + Center( + child: TextButton.icon( + onPressed: () => Navigator.pop(context), + icon: + const Icon(Icons.arrow_back, size: 18, color: Colors.red), + label: MyText.bodySmall( + 'Back to log in', + fontWeight: 600, + color: contentTheme.brandRed, + ), ), - onPressed: _loading ? null : _submitForm, - child: _loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : const Text("Submit"), - ), - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text("Back to login"), ), ], ), @@ -200,6 +230,67 @@ class _OrganizationFormState extends State<_OrganizationForm> { ); } + Widget _sectionHeader(String title) { + return Padding( + padding: const EdgeInsets.only(top: 20, bottom: 8), + child: MyText.titleSmall( + title, + fontWeight: 600, + color: Colors.grey[800], + ), + ); + } + + Widget _buildTextField(String fieldName, String label, + {TextInputType keyboardType = TextInputType.text}) { + final controller = validator.getController(fieldName); + final defaultValidator = validator.getValidation(fieldName); + + String? Function(String?)? validatorFunc = defaultValidator; + if (fieldName == 'contactNumber') { + validatorFunc = (value) { + if (value == null || value.isEmpty) return 'Contact number is required'; + if (!RegExp(r'^\d{10}$').hasMatch(value)) + return 'Enter a valid 10-digit contact number'; + return null; + }; + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: TextFormField( + controller: controller, + keyboardType: keyboardType, + validator: validatorFunc, + style: const TextStyle(fontSize: 15, color: Colors.black87), + decoration: InputDecoration( + labelText: label, + labelStyle: TextStyle(color: Colors.grey[700], fontSize: 14), + filled: true, + fillColor: Colors.grey[100], + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey[400]!), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: contentTheme.brandRed, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.red), + ), + ), + ), + ); + } + Widget _buildPopupMenuField( String label, List items, @@ -218,16 +309,15 @@ class _OrganizationFormState extends State<_OrganizationForm> { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, - style: TextStyle(fontSize: 12, color: Colors.grey[700])), + MyText.bodySmall(label, color: Colors.grey[700]), const SizedBox(height: 6), GestureDetector( key: _key, onTap: () async { - final RenderBox renderBox = + final renderBox = _key.currentContext!.findRenderObject() as RenderBox; - final Offset offset = renderBox.localToGlobal(Offset.zero); - final Size size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; final selected = await showMenu( context: fieldState.context, @@ -240,10 +330,11 @@ class _OrganizationFormState extends State<_OrganizationForm> { items: items .map((item) => PopupMenuItem( value: item, - child: Text(item), + child: MyText.bodyMedium(item), )) .toList(), ); + if (selected != null) { onSelected(selected); fieldState.didChange(selected); @@ -251,17 +342,35 @@ class _OrganizationFormState extends State<_OrganizationForm> { }, child: InputDecorator( decoration: InputDecoration( - border: const OutlineInputBorder(), - errorText: fieldState.errorText, + filled: true, + fillColor: Colors.grey[100], contentPadding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 14), - ), - child: Text( - selectedValue ?? 'Select $label', - style: TextStyle( - color: selectedValue == null ? Colors.grey : Colors.black, - fontSize: 16, + horizontal: 16, vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey[400]!), ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: + BorderSide(color: contentTheme.brandRed, width: 1.5), + ), + errorText: fieldState.errorText, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium( + selectedValue ?? 'Select $label', + color: + selectedValue == null ? Colors.grey : Colors.black, + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], ), ), ), @@ -272,40 +381,6 @@ class _OrganizationFormState extends State<_OrganizationForm> { ); } - Widget _buildTextField(String fieldName, String label, - {TextInputType keyboardType = TextInputType.text}) { - final controller = validator.getController(fieldName); - final defaultValidator = validator.getValidation(fieldName); - - // Custom logic for contact number - String? Function(String?)? validatorFunc = defaultValidator; - if (fieldName == 'contactNumber') { - validatorFunc = (value) { - if (value == null || value.isEmpty) { - return 'Contact number is required'; - } - if (!RegExp(r'^\d{10}$').hasMatch(value)) { - return 'Enter a valid 10-digit contact number'; - } - return null; - }; - } - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: TextFormField( - controller: controller, - keyboardType: keyboardType, - decoration: InputDecoration( - labelText: label, - border: const OutlineInputBorder(), - ), - validator: validatorFunc, - ), - ); -} - - void _submitForm() async { bool isValid = validator.validateForm(); @@ -316,47 +391,48 @@ class _OrganizationFormState extends State<_OrganizationForm> { if (!_agreed) { isValid = false; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please agree to the privacy policy & terms')), + showAppSnackbar( + title: "Agreement Required", + message: "Please agree to the privacy policy & terms", + type: SnackbarType.warning, ); } - if (isValid) { - setState(() => _loading = true); + if (!isValid) return; - final formData = validator.getData(); + setState(() => _loading = true); - final Map requestBody = { - 'organizatioinName': formData['organizationName'], - 'email': formData['email'], - 'about': formData['about'], - 'contactNumber': formData['contactNumber'], - 'contactPerson': formData['contactPerson'], - 'industryId': _selectedIndustryId ?? '', - 'oragnizationSize': _sizeApiMap[_selectedSize] ?? '', - 'terms': _agreed, - 'address': formData['address'], - }; + final formData = validator.getData(); - final error = await AuthService.requestDemo(requestBody); + final requestBody = { + 'organizatioinName': formData['organizationName'], + 'email': formData['email'], + 'about': formData['about'], + 'contactNumber': formData['contactNumber'], + 'contactPerson': formData['contactPerson'], + 'industryId': _selectedIndustryId ?? '', + 'oragnizationSize': _sizeApiMap[_selectedSize] ?? '', + 'terms': _agreed, + 'address': formData['address'], + }; - setState(() => _loading = false); + final error = await AuthService.requestDemo(requestBody); - if (error == null) { - showAppSnackbar( - title: "Success", - message: "Demo request submitted successfully!.", - type: SnackbarType.success, - ); - Navigator.pop(context); - } else { - showAppSnackbar( - title: "Error", - message: (error['error'] ?? 'Unknown error'), - type: SnackbarType.error, - ); - } + setState(() => _loading = false); + + if (error == null) { + showAppSnackbar( + title: "Success", + message: "Demo request submitted successfully!", + type: SnackbarType.success, + ); + Navigator.pop(context); + } else { + showAppSnackbar( + title: "Error", + message: error['error'] ?? 'Unknown error occurred', + type: SnackbarType.error, + ); } } } diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 7cf2f73..e5a8d18 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -8,10 +8,11 @@ import 'package:marco/helpers/widgets/my_container.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/view/layouts/layout.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; +import 'package:marco/helpers/widgets/my_button.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); - static const String dashboardRoute = "/dashboard"; static const String employeesRoute = "/dashboard/employees"; static const String projectsRoute = "/dashboard"; @@ -26,17 +27,91 @@ class DashboardScreen extends StatefulWidget { } class _DashboardScreenState extends State with UIMixin { + bool hasMpin = true; + + @override + void initState() { + super.initState(); + _checkMpinStatus(); + } + + Future _checkMpinStatus() async { + final bool mpinStatus = await LocalStorage.getIsMpin(); + setState(() { + hasMpin = mpinStatus; + }); + } + @override Widget build(BuildContext context) { return Layout( child: SingleChildScrollView( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.titleMedium("Dashboard", fontWeight: 600), MySpacing.height(12), _buildDashboardStats(), + MySpacing.height(350), + if (!hasMpin) ...[ + MyCard( + borderRadiusAll: 12, + paddingAll: 16, + shadow: MyShadow(elevation: 2), + color: Colors.red.withOpacity(0.05), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, + color: Colors.redAccent, size: 28), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium( + "MPIN Not Generated", + color: Colors.redAccent, + fontWeight: 700, + ), + MySpacing.height(4), + MyText.bodySmall( + "To secure your account, please generate your MPIN now.", + color: contentTheme.onBackground.withOpacity(0.8), + ), + MySpacing.height(10), + Align( + alignment: Alignment.center, + child: MyButton.rounded( + onPressed: () { + Get.toNamed("/auth/mpin-auth"); + }, + backgroundColor: contentTheme.brandRed, + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock_outline, + size: 18, color: Colors.white), + MySpacing.width(8), + MyText.bodyMedium( + "Generate MPIN", + color: Colors.white, + fontWeight: 600, + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], ], ), ), @@ -58,8 +133,7 @@ class _DashboardScreenState extends State with UIMixin { return LayoutBuilder( builder: (context, constraints) { double maxWidth = constraints.maxWidth; - int crossAxisCount = - (maxWidth / 100).floor().clamp(2, 4); + int crossAxisCount = (maxWidth / 100).floor().clamp(2, 4); double cardWidth = (maxWidth - (crossAxisCount - 1) * 10) / crossAxisCount; -- 2.43.0 From c253c144810c116ec4eab8c90399b9d05e97aa80 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 10 Jun 2025 12:14:05 +0530 Subject: [PATCH 3/8] feat: Refactor OTP authentication to use email instead of phone number --- lib/controller/auth/otp_controller.dart | 99 ++++++++++--------- lib/helpers/services/auth_service.dart | 120 +++++++++++++++++++----- lib/view/auth/otp_login_form.dart | 120 ++++++++++++------------ 3 files changed, 208 insertions(+), 131 deletions(-) diff --git a/lib/controller/auth/otp_controller.dart b/lib/controller/auth/otp_controller.dart index 764c4d9..61c2901 100644 --- a/lib/controller/auth/otp_controller.dart +++ b/lib/controller/auth/otp_controller.dart @@ -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(); - // 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 otpControllers = List.generate(4, (_) => TextEditingController()); + final TextEditingController emailController = TextEditingController(); + final List otpControllers = + List.generate(4, (_) => TextEditingController()); final List 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 _sendOTP(String phone) async { - await Future.delayed(const Duration(seconds: 2)); - debugPrint('Sending OTP to $phone'); - return true; + Future _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 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 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); } } diff --git a/lib/helpers/services/auth_service.dart b/lib/helpers/services/auth_service.dart index 1366e33..3fe7033 100644 --- a/lib/helpers/services/auth_service.dart +++ b/lib/helpers/services/auth_service.dart @@ -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?> 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?> 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 _handleLoginSuccess(Map 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; + } } diff --git a/lib/view/auth/otp_login_form.dart b/lib/view/auth/otp_login_form.dart index 596b6f3..1276e80 100644 --- a/lib/view/auth/otp_login_form.dart +++ b/lib/view/auth/otp_login_form.dart @@ -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 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 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 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 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 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, -- 2.43.0 From 2ccd2373294668aa8509585a550faa510c821d9f Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 10 Jun 2025 15:32:15 +0530 Subject: [PATCH 4/8] feat: Update navigation logic for MPIN and OTP authentication to redirect to home page; add failed attempts tracking in MPIN controller --- lib/controller/auth/login_controller.dart | 8 +-- lib/controller/auth/mpin_controller.dart | 33 ++++++++++-- lib/controller/auth/otp_controller.dart | 2 +- lib/view/auth/mpin_auth_screen.dart | 65 +++++++++++++---------- 4 files changed, 71 insertions(+), 37 deletions(-) diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index 6ee233b..929527d 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -82,7 +82,7 @@ class LoginController extends MyController { final bool isMpinEnabled = LocalStorage.getIsMpin(); if (isMpinEnabled) { - Get.toNamed('/auth/mpin-auth'); + Get.offAllNamed('/home'); } else { Get.offAllNamed('/home'); } @@ -90,8 +90,10 @@ class LoginController extends MyController { Future _handleRememberMe() async { if (isChecked.value) { - await LocalStorage.setToken('username', basicValidator.getController('username')!.text); - await LocalStorage.setToken('password', basicValidator.getController('password')!.text); + await LocalStorage.setToken( + 'username', basicValidator.getController('username')!.text); + await LocalStorage.setToken( + 'password', basicValidator.getController('password')!.text); await LocalStorage.setBool('remember_me', true); } else { await LocalStorage.removeToken('username'); diff --git a/lib/controller/auth/mpin_controller.dart b/lib/controller/auth/mpin_controller.dart index d84c80d..fa114b9 100644 --- a/lib/controller/auth/mpin_controller.dart +++ b/lib/controller/auth/mpin_controller.dart @@ -20,7 +20,7 @@ class MPINController extends GetxController { final retypeControllers = List.generate(6, (_) => TextEditingController()); final retypeFocusNodes = List.generate(6, (_) => FocusNode()); - + final RxInt failedAttempts = 0.obs; @override void onInit() { super.onInit(); @@ -77,7 +77,12 @@ class MPINController extends GetxController { if (success) { logger.i("[MPINController] MPIN generation successful."); - _navigateToDashboard(message: "MPIN Set Successfully"); + showAppSnackbar( + title: "Success", + message: "MPIN generated successfully. Please login again.", + type: SnackbarType.success, + ); + await LocalStorage.logout(); } else { logger.w("[MPINController] MPIN generation failed."); clearFields(); @@ -186,9 +191,15 @@ class MPINController extends GetxController { isLoading.value = false; if (response == null) { - logger.i( - "[MPINController] MPIN generated successfully (null response treated as success)"); - Get.toNamed('/auth/mpin-auth'); + logger.i("[MPINController] MPIN generated successfully"); + + showAppSnackbar( + title: "Success", + message: "MPIN generated successfully. Please login again.", + type: SnackbarType.success, + ); + + await LocalStorage.logout(); return true; } else { @@ -261,6 +272,7 @@ class MPINController extends GetxController { type: SnackbarType.error, ); clearFields(); + onInvalidMPIN(); } } catch (e) { isLoading.value = false; @@ -273,4 +285,15 @@ class MPINController extends GetxController { ); } } + + void onInvalidMPIN() { + failedAttempts.value++; + if (failedAttempts.value >= 3) { + showAppSnackbar( + title: "Error", + message: "Too many failed attempts. Consider logging in again.", + type: SnackbarType.error, + ); + } + } } diff --git a/lib/controller/auth/otp_controller.dart b/lib/controller/auth/otp_controller.dart index 61c2901..8eaa712 100644 --- a/lib/controller/auth/otp_controller.dart +++ b/lib/controller/auth/otp_controller.dart @@ -125,7 +125,7 @@ class OTPController extends GetxController { print('MPIN Enabled? $isMpinEnabled'); if (isMpinEnabled) { - Get.toNamed('/auth/mpin-auth'); + Get.offAllNamed('/home'); } else { Get.offAllNamed('/home'); } diff --git a/lib/view/auth/mpin_auth_screen.dart b/lib/view/auth/mpin_auth_screen.dart index f1dfbfc..d680d72 100644 --- a/lib/view/auth/mpin_auth_screen.dart +++ b/lib/view/auth/mpin_auth_screen.dart @@ -277,35 +277,44 @@ class _MPINAuthScreenState extends State with UIMixin { } Widget _buildFooterOptions(MPINController controller, bool isNewUser) { - return Column( - children: [ - if (isNewUser) - TextButton.icon( - onPressed: controller.switchToEnterMPIN, - icon: const Icon(Icons.arrow_back, size: 18, color: Colors.black87), - label: MyText.bodyMedium( - 'Back to Enter MPIN', - color: Colors.black87, - fontWeight: 600, - fontSize: 14, + return Obx(() { + final showBackToLogin = + controller.failedAttempts.value >= 3 && !isNewUser; + + return Column( + children: [ + if (isNewUser) + TextButton.icon( + onPressed: () async { + Get.delete(); + Get.toNamed('/dashboard'); + }, + icon: const Icon(Icons.arrow_back, + size: 18, color: Colors.redAccent), + label: MyText.bodyMedium( + 'Back to Home Page', + color: contentTheme.brandRed, + fontWeight: 600, + fontSize: 14, + ), ), - ), - if (isNewUser) - TextButton.icon( - onPressed: () async { - Get.delete(); - Get.toNamed('/dashboard'); - }, - icon: - const Icon(Icons.arrow_back, size: 18, color: Colors.redAccent), - label: MyText.bodyMedium( - 'Back to Home Page', - color: contentTheme.brandRed, - fontWeight: 600, - fontSize: 14, + if (showBackToLogin) + TextButton.icon( + onPressed: () async { + Get.delete(); + await LocalStorage.logout(); + }, + icon: const Icon(Icons.arrow_back, + size: 18, color: Colors.redAccent), + label: MyText.bodyMedium( + 'Go back to Login Screen', + color: contentTheme.brandRed, + fontWeight: 600, + fontSize: 14, + ), ), - ), - ], - ); + ], + ); + }); } } -- 2.43.0 From a2a7eb84b0227eb22b3b339d0a8f1aa59540fa2f Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 10 Jun 2025 15:43:44 +0530 Subject: [PATCH 5/8] feat: Update password validation to require a minimum length of 6 characters --- lib/controller/auth/login_controller.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index 929527d..c145c2c 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -33,7 +33,7 @@ class LoginController extends MyController { 'password', required: true, label: "Password", - validators: [MyLengthValidator(min: 6, max: 10)], + validators: [MyLengthValidator(min: 6)], controller: TextEditingController(), ); } -- 2.43.0 From 040a8f0a2ebe162c0501d06cfe5d4790e8fc8746 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 10 Jun 2025 15:44:24 +0530 Subject: [PATCH 6/8] feat: Add AttendanceLogViewButton for employees with check-in records --- lib/view/dashboard/Attendence/attendance_screen.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/view/dashboard/Attendence/attendance_screen.dart b/lib/view/dashboard/Attendence/attendance_screen.dart index 689d717..8de61e9 100644 --- a/lib/view/dashboard/Attendence/attendance_screen.dart +++ b/lib/view/dashboard/Attendence/attendance_screen.dart @@ -736,8 +736,16 @@ class _AttendanceScreenState extends State with UIMixin { uniqueLogKey: employee.employeeId, action: ButtonActions.reject, ), + const SizedBox(width: 8), + if (employee.checkIn != null) ...[ + AttendanceLogViewButton( + employee: employee, + attendanceController: + attendanceController, + ), + ], ], - ) + ), ], ), ), -- 2.43.0 From 18987aa97a11a57b1e432fdf0d5949da8ff4d4aa Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 10 Jun 2025 16:33:32 +0530 Subject: [PATCH 7/8] feat: Update navigation logic in LoginController to redirect to home page after MPIN check --- lib/controller/auth/login_controller.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/controller/auth/login_controller.dart b/lib/controller/auth/login_controller.dart index c145c2c..1461d27 100644 --- a/lib/controller/auth/login_controller.dart +++ b/lib/controller/auth/login_controller.dart @@ -69,7 +69,7 @@ class LoginController extends MyController { print('MPIN Enabled? $isMpinEnabled'); if (isMpinEnabled) { - Get.toNamed('/auth/mpin-auth'); + Get.toNamed('/home'); } else { Get.toNamed('/home'); } -- 2.43.0 From 8c2d2588485d2f20f7f066514a6cd77418f561a3 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 10 Jun 2025 17:26:35 +0530 Subject: [PATCH 8/8] feat: Implement loading state in ForgotPasswordScreen during password reset process --- lib/view/auth/forgot_password_screen.dart | 37 ++++++++++++++++++----- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/lib/view/auth/forgot_password_screen.dart b/lib/view/auth/forgot_password_screen.dart index b3f925b..e6d0c64 100644 --- a/lib/view/auth/forgot_password_screen.dart +++ b/lib/view/auth/forgot_password_screen.dart @@ -24,6 +24,19 @@ class _ForgotPasswordScreenState extends State Get.put(ForgotPasswordController()); bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); + bool _isLoading = false; + + void _handleForgotPassword() async { + setState(() { + _isLoading = true; + }); + + await controller.onForgotPassword(); + + setState(() { + _isLoading = false; + }); + } @override Widget build(BuildContext context) { @@ -96,16 +109,26 @@ class _ForgotPasswordScreenState extends State ), const SizedBox(height: 40), MyButton.rounded( - onPressed: controller.onForgotPassword, + onPressed: + _isLoading ? null : _handleForgotPassword, elevation: 2, padding: MySpacing.xy(80, 16), borderRadiusAll: 10, - backgroundColor: contentTheme.brandRed, - child: MyText.labelLarge( - 'Send Reset Link', - fontWeight: 700, - color: Colors.white, - ), + backgroundColor: _isLoading ? Colors.red.withOpacity(0.6) : contentTheme.brandRed, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : MyText.labelLarge( + 'Send Reset Link', + fontWeight: 700, + color: Colors.white, + ), ), const SizedBox(height: 24), TextButton( -- 2.43.0