Feature_MPIN_OTP #46
@ -7,7 +7,7 @@
|
||||
|
||||
|
||||
<application
|
||||
android:label="marco"
|
||||
android:label="Marco_Stage"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
@ -61,6 +61,6 @@ class ForgotPasswordController extends MyController {
|
||||
}
|
||||
|
||||
void gotoLogIn() {
|
||||
Get.toNamed('/auth/login');
|
||||
Get.toNamed('/auth/login-option');
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
@ -34,13 +33,13 @@ class LoginController extends MyController {
|
||||
'password',
|
||||
required: true,
|
||||
label: "Password",
|
||||
validators: [MyLengthValidator(min: 6, max: 10)],
|
||||
validators: [MyLengthValidator(min: 6)],
|
||||
controller: TextEditingController(),
|
||||
);
|
||||
}
|
||||
|
||||
void onChangeCheckBox(bool? value) {
|
||||
isChecked.value = value ?? isChecked.value;
|
||||
isChecked.value = value ?? false;
|
||||
}
|
||||
|
||||
void onChangeShowPassword() {
|
||||
@ -66,33 +65,47 @@ 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('/home');
|
||||
} else {
|
||||
Get.toNamed('/home');
|
||||
}
|
||||
}
|
||||
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
Future<void> _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.offAllNamed('/home');
|
||||
} else {
|
||||
await prefs.remove('username');
|
||||
await prefs.remove('password');
|
||||
await prefs.setBool('remember_me', false);
|
||||
Get.offAllNamed('/home');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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;
|
||||
|
||||
|
299
lib/controller/auth/mpin_controller.dart
Normal file
299
lib/controller/auth/mpin_controller.dart
Normal file
@ -0,0 +1,299 @@
|
||||
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<FormState>();
|
||||
|
||||
final digitControllers = List.generate(6, (_) => TextEditingController());
|
||||
final focusNodes = List.generate(6, (_) => FocusNode());
|
||||
|
||||
final retypeControllers = List.generate(6, (_) => TextEditingController());
|
||||
final retypeFocusNodes = List.generate(6, (_) => FocusNode());
|
||||
final RxInt failedAttempts = 0.obs;
|
||||
@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<void> 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.");
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "MPIN generated successfully. Please login again.",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
await LocalStorage.logout();
|
||||
} else {
|
||||
logger.w("[MPINController] MPIN generation failed.");
|
||||
clearFields();
|
||||
clearRetypeFields();
|
||||
}
|
||||
} else {
|
||||
logger.i("[MPINController] Existing user. Proceeding to verify MPIN.");
|
||||
await verifyMPIN();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<bool> 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");
|
||||
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "MPIN generated successfully. Please login again.",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
|
||||
await LocalStorage.logout();
|
||||
|
||||
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<void> 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();
|
||||
onInvalidMPIN();
|
||||
}
|
||||
} 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onInvalidMPIN() {
|
||||
failedAttempts.value++;
|
||||
if (failedAttempts.value >= 3) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Too many failed attempts. Consider logging in again.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
179
lib/controller/auth/otp_controller.dart
Normal file
179
lib/controller/auth/otp_controller.dart
Normal file
@ -0,0 +1,179 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/helpers/services/auth_service.dart';
|
||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||
class OTPController extends GetxController {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
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;
|
||||
|
||||
final TextEditingController emailController = TextEditingController();
|
||||
final List<TextEditingController> otpControllers =
|
||||
List.generate(4, (_) => TextEditingController());
|
||||
final List<FocusNode> focusNodes = List.generate(4, (_) => FocusNode());
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
timer.value = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_countdownTimer?.cancel();
|
||||
emailController.dispose();
|
||||
for (final controller in otpControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (final node in focusNodes) {
|
||||
node.dispose();
|
||||
}
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
Future<bool> _sendOTP(String email) async {
|
||||
final result = await AuthService.generateOtp(email);
|
||||
if (result == null) {
|
||||
debugPrint('OTP sent to $email');
|
||||
return true;
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: result['error'] ?? "Failed to send OTP",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendOTP() async {
|
||||
final userEmail = emailController.text.trim();
|
||||
|
||||
if (!_validateEmail(userEmail)) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please enter a valid email address",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSending.value) return;
|
||||
isSending.value = true;
|
||||
|
||||
final success = await _sendOTP(userEmail);
|
||||
if (success) {
|
||||
email.value = userEmail;
|
||||
isOTPSent.value = true;
|
||||
_startTimer();
|
||||
_clearOTPFields();
|
||||
}
|
||||
|
||||
isSending.value = false;
|
||||
}
|
||||
|
||||
Future<void> onResendOTP() async {
|
||||
if (isResending.value) return;
|
||||
isResending.value = true;
|
||||
|
||||
_clearOTPFields();
|
||||
|
||||
final success = await _sendOTP(email.value);
|
||||
if (success) {
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifyOTP() async {
|
||||
final enteredOTP = otpControllers.map((c) => c.text).join();
|
||||
|
||||
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.offAllNamed('/home');
|
||||
} else {
|
||||
Get.offAllNamed('/home');
|
||||
}
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: result['error'] ?? "Failed to verify OTP",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 resetForChangeEmail() {
|
||||
isOTPSent.value = false;
|
||||
email.value = '';
|
||||
emailController.clear();
|
||||
_clearOTPFields();
|
||||
|
||||
timer.value = 0;
|
||||
isSending.value = false;
|
||||
isResending.value = false;
|
||||
|
||||
for (final node in focusNodes) {
|
||||
node.unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
bool _validateEmail(String email) {
|
||||
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$');
|
||||
return regex.hasMatch(email);
|
||||
}
|
||||
}
|
@ -61,6 +61,6 @@ class RegisterAccountController extends MyController {
|
||||
}
|
||||
|
||||
void gotoLogin() {
|
||||
Get.toNamed('/auth/login');
|
||||
Get.toNamed('/auth/login-option');
|
||||
}
|
||||
}
|
||||
|
@ -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<String, String> _headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
@ -17,31 +18,14 @@ class AuthService {
|
||||
Map<String, dynamic> 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'];
|
||||
|
||||
// Log the tokens using the logger
|
||||
logger.i("JWT Token: $jwtToken");
|
||||
if (refreshToken != null) logger.i("Refresh Token: $refreshToken");
|
||||
|
||||
await LocalStorage.setJwtToken(jwtToken);
|
||||
await LocalStorage.setLoggedInUser(true);
|
||||
|
||||
if (refreshToken != null) {
|
||||
await LocalStorage.setRefreshToken(refreshToken);
|
||||
}
|
||||
|
||||
Get.put(PermissionController());
|
||||
|
||||
await _handleLoginSuccess(responseData['data']);
|
||||
return null;
|
||||
} else if (response.statusCode == 401) {
|
||||
return {"password": "Invalid email or password"};
|
||||
@ -198,4 +182,192 @@ class AuthService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a new MPIN for the user.
|
||||
static Future<Map<String, String>?> 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<Map<String, String>?> 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."};
|
||||
}
|
||||
}
|
||||
|
||||
// Generate OTP API
|
||||
static Future<Map<String, String>?> generateOtp(String email) async {
|
||||
final requestBody = {"email": email};
|
||||
|
||||
logger.i("Sending generate OTP request: $requestBody");
|
||||
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse("$_baseUrl/auth/send-otp"),
|
||||
headers: _headers,
|
||||
body: jsonEncode(requestBody),
|
||||
);
|
||||
|
||||
logger.i(
|
||||
"Generate OTP API response (${response.statusCode}): ${response.body}");
|
||||
|
||||
final responseData = jsonDecode(response.body);
|
||||
|
||||
if (response.statusCode == 200 && responseData['success'] == true) {
|
||||
logger.i("OTP generated successfully.");
|
||||
return null;
|
||||
} else {
|
||||
return {"error": responseData['message'] ?? "Failed to generate OTP."};
|
||||
}
|
||||
} catch (e) {
|
||||
logger.e("Exception during generate OTP: $e");
|
||||
return {"error": "Network error. Please check your connection."};
|
||||
}
|
||||
}
|
||||
|
||||
// Verify OTP API
|
||||
static Future<Map<String, String>?> verifyOtp({
|
||||
required String email,
|
||||
required String otp,
|
||||
}) async {
|
||||
final requestBody = {
|
||||
"email": email,
|
||||
"otp": otp,
|
||||
};
|
||||
|
||||
logger.i("Sending verify OTP request: $requestBody");
|
||||
|
||||
try {
|
||||
final response = await http.post(
|
||||
Uri.parse("$_baseUrl/auth/login-otp"),
|
||||
headers: _headers,
|
||||
body: jsonEncode(requestBody),
|
||||
);
|
||||
|
||||
logger.i(
|
||||
"Verify OTP API response (${response.statusCode}): ${response.body}");
|
||||
|
||||
final responseData = jsonDecode(response.body);
|
||||
|
||||
if (response.statusCode == 200 && responseData['data'] != null) {
|
||||
await _handleLoginSuccess(responseData['data']);
|
||||
logger.i("OTP verified and login state initialized successfully.");
|
||||
return null;
|
||||
} else {
|
||||
return {"error": responseData['message'] ?? "Failed to verify OTP."};
|
||||
}
|
||||
} catch (e) {
|
||||
logger.e("Exception during verify OTP: $e");
|
||||
return {"error": "Network error. Please check your connection."};
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
|
||||
final jwtToken = data['token'];
|
||||
final refreshToken = data['refreshToken'];
|
||||
final mpinToken = data['mpinToken'];
|
||||
|
||||
logger.i("JWT Token: $jwtToken");
|
||||
if (refreshToken != null) logger.i("Refresh Token: $refreshToken");
|
||||
if (mpinToken != null) logger.i("MPIN Token: $mpinToken");
|
||||
|
||||
await LocalStorage.setJwtToken(jwtToken);
|
||||
await LocalStorage.setLoggedInUser(true);
|
||||
|
||||
if (refreshToken != null) {
|
||||
await LocalStorage.setRefreshToken(refreshToken);
|
||||
}
|
||||
if (mpinToken != null && mpinToken.isNotEmpty) {
|
||||
await LocalStorage.setMpinToken(mpinToken);
|
||||
await LocalStorage.setIsMpin(true);
|
||||
} else {
|
||||
await LocalStorage.setIsMpin(false);
|
||||
await LocalStorage.removeMpinToken();
|
||||
}
|
||||
|
||||
Get.put(PermissionController());
|
||||
isLoggedIn = true;
|
||||
}
|
||||
}
|
||||
|
@ -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<bool> setRefreshToken(String refreshToken) {
|
||||
return setToken(_refreshTokenKey, refreshToken);
|
||||
}
|
||||
|
||||
static Future<void> 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<bool> setMpinToken(String token) {
|
||||
return preferences.setString(_mpinTokenKey, token);
|
||||
}
|
||||
|
||||
static String? getMpinToken() {
|
||||
return preferences.getString(_mpinTokenKey);
|
||||
}
|
||||
|
||||
static Future<bool> removeMpinToken() {
|
||||
return preferences.remove(_mpinTokenKey);
|
||||
}
|
||||
|
||||
// MPIN Enabled flag
|
||||
static Future<bool> setIsMpin(bool value) {
|
||||
return preferences.setBool(_isMpinKey, value);
|
||||
}
|
||||
|
||||
static bool getIsMpin() {
|
||||
return preferences.getBool(_isMpinKey) ?? false;
|
||||
}
|
||||
|
||||
static Future<bool> removeIsMpin() {
|
||||
return preferences.remove(_isMpinKey);
|
||||
}
|
||||
|
||||
static Future<bool> setBool(String key, bool value) async {
|
||||
return preferences.setBool(key, value);
|
||||
}
|
||||
|
||||
static bool? getBool(String key) {
|
||||
return preferences.getBool(key);
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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<void> 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<AppNotifier>(
|
||||
create: (context) => AppNotifier(),
|
||||
child: MyApp(),
|
||||
child: const MyApp(),
|
||||
));
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
Future<String> _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<AppNotifier>(
|
||||
builder: (_, notifier, ___) {
|
||||
builder: (_, notifier, __) {
|
||||
return FutureBuilder<String>(
|
||||
future: _getInitialRoute(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return MaterialApp(
|
||||
return const MaterialApp(
|
||||
home: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
@ -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
|
||||
|
198
lib/view/auth/email_login_form.dart
Normal file
198
lib/view/auth/email_login_form.dart
Normal file
@ -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<EmailLoginForm> createState() => _EmailLoginFormState();
|
||||
}
|
||||
|
||||
class _EmailLoginFormState extends State<EmailLoginForm> 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<LoginController>(
|
||||
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<Color>(
|
||||
(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<String>? 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,209 @@ class ForgotPasswordScreen extends StatefulWidget {
|
||||
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> with UIMixin {
|
||||
ForgotPasswordController controller = Get.put(ForgotPasswordController());
|
||||
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
|
||||
with UIMixin {
|
||||
final ForgotPasswordController controller =
|
||||
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) {
|
||||
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:
|
||||
_isLoading ? null : _handleForgotPassword,
|
||||
elevation: 2,
|
||||
padding: MySpacing.xy(80, 16),
|
||||
borderRadiusAll: 10,
|
||||
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(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
225
lib/view/auth/login_option_screen.dart
Normal file
225
lib/view/auth/login_option_screen.dart
Normal file
@ -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<LoginOptionScreen> createState() => _LoginOptionScreenState();
|
||||
}
|
||||
|
||||
class _LoginOptionScreenState extends State<LoginOptionScreen> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
320
lib/view/auth/mpin_auth_screen.dart
Normal file
320
lib/view/auth/mpin_auth_screen.dart
Normal file
@ -0,0 +1,320 @@
|
||||
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<MPINAuthScreen> createState() => _MPINAuthScreenState();
|
||||
}
|
||||
|
||||
class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
|
||||
bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage");
|
||||
@override
|
||||
void dispose() {
|
||||
Get.delete<MPINController>();
|
||||
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 Obx(() {
|
||||
final showBackToLogin =
|
||||
controller.failedAttempts.value >= 3 && !isNewUser;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (isNewUser)
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
Get.delete<MPINController>();
|
||||
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<MPINController>();
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
150
lib/view/auth/mpin_screen.dart
Normal file
150
lib/view/auth/mpin_screen.dart
Normal file
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
232
lib/view/auth/otp_login_form.dart
Normal file
232
lib/view/auth/otp_login_form.dart
Normal file
@ -0,0 +1,232 @@
|
||||
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';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
|
||||
class OTPLoginScreen extends StatefulWidget {
|
||||
const OTPLoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OTPLoginScreen> createState() => _OTPLoginScreenState();
|
||||
}
|
||||
|
||||
class _OTPLoginScreenState extends State<OTPLoginScreen> with UIMixin {
|
||||
late final OTPController controller;
|
||||
final GlobalKey<FormState> _otpFormKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
controller = Get.put(OTPController());
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
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 _buildEmailInput() {
|
||||
return TextFormField(
|
||||
controller: controller.emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Enter Email Address',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
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.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(controller.email.value,
|
||||
fontWeight: 700, color: Colors.black),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: controller.resetForChangeEmail,
|
||||
icon: Icon(Icons.edit, size: 16, color: Colors.redAccent),
|
||||
label: MyText.bodySmall('Change',
|
||||
fontWeight: 600, color: 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),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return '';
|
||||
return null;
|
||||
},
|
||||
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: () {
|
||||
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,
|
||||
backgroundColor: contentTheme.brandRed,
|
||||
child: MyText.labelMedium(
|
||||
'Verify OTP',
|
||||
fontWeight: 600,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<Map<String, dynamic>> _industries = [];
|
||||
String? _selectedIndustryId;
|
||||
String? _selectedSize;
|
||||
|
||||
final List<String> _sizes = [
|
||||
'1-10',
|
||||
'11-50',
|
||||
'51-200',
|
||||
'201-1000',
|
||||
'1000+',
|
||||
];
|
||||
|
||||
final List<String> _sizes = ['1-10', '11-50', '51-200', '201-1000', '1000+'];
|
||||
final Map<String, String> _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<String>('organizationName',
|
||||
void _registerFields() {
|
||||
validator.addField('organizationName',
|
||||
required: true, controller: TextEditingController());
|
||||
validator.addField<String>('email',
|
||||
validator.addField('email',
|
||||
required: true, controller: TextEditingController());
|
||||
validator.addField<String>('contactPerson',
|
||||
validator.addField('contactPerson',
|
||||
required: true, controller: TextEditingController());
|
||||
validator.addField<String>('contactNumber',
|
||||
validator.addField('contactNumber',
|
||||
required: true, controller: TextEditingController());
|
||||
validator.addField<String>('about', controller: TextEditingController());
|
||||
validator.addField<String>('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<String>(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<String> 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<String>(
|
||||
context: fieldState.context,
|
||||
@ -240,10 +330,11 @@ class _OrganizationFormState extends State<_OrganizationForm> {
|
||||
items: items
|
||||
.map((item) => PopupMenuItem<String>(
|
||||
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<String>(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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -736,8 +736,16 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
|
||||
uniqueLogKey: employee.employeeId,
|
||||
action: ButtonActions.reject,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (employee.checkIn != null) ...[
|
||||
AttendanceLogViewButton(
|
||||
employee: employee,
|
||||
attendanceController:
|
||||
attendanceController,
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -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<DashboardScreen> with UIMixin {
|
||||
bool hasMpin = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkMpinStatus();
|
||||
}
|
||||
|
||||
Future<void> _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<DashboardScreen> 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;
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user