feat: Add MPIN authentication and OTP login screens

- Implemented MPINAuthScreen for generating and entering MPIN.
- Created MPINScreen for user interaction with MPIN input.
- Developed OTPLoginScreen for OTP verification process.
- Added request demo bottom sheet with organization form.
- Enhanced DashboardScreen to check MPIN status and prompt user to generate MPIN if not set.
This commit is contained in:
Vaibhav Surve 2025-06-10 10:04:18 +05:30
parent a89346fc8a
commit 25dfcf3e08
18 changed files with 2301 additions and 306 deletions

View File

@ -61,6 +61,6 @@ class ForgotPasswordController extends MyController {
}
void gotoLogIn() {
Get.toNamed('/auth/login');
Get.toNamed('/auth/login-option');
}
}

View File

@ -4,9 +4,8 @@ import 'package:marco/controller/my_controller.dart';
import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_validators.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:shared_preferences/shared_preferences.dart';
class LoginController extends MyController {
final MyFormValidator basicValidator = MyFormValidator();
@ -40,7 +39,7 @@ class LoginController extends MyController {
}
void onChangeCheckBox(bool? value) {
isChecked.value = value ?? isChecked.value;
isChecked.value = value ?? false;
}
void onChangeShowPassword() {
@ -66,33 +65,45 @@ class LoginController extends MyController {
basicValidator.clearErrors();
} else {
await _handleRememberMe();
final bool isMpinEnabled = LocalStorage.getIsMpin();
print('MPIN Enabled? $isMpinEnabled');
final currentRoute = ModalRoute.of(Get.context!)?.settings.name ?? "";
final nextUrl = Uri.parse(currentRoute).queryParameters['next'] ?? "/home";
Get.toNamed(nextUrl);
if (isMpinEnabled) {
Get.toNamed('/auth/mpin-auth');
} else {
Get.toNamed('/home');
}
}
isLoading.value = false;
}
Future<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.toNamed('/auth/mpin-auth');
} 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;

View File

@ -0,0 +1,276 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:logger/logger.dart';
import 'package:marco/helpers/services/auth_service.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/widgets/my_form_validator.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/view/dashboard/dashboard_screen.dart';
class MPINController extends GetxController {
final Logger logger = Logger();
final MyFormValidator basicValidator = MyFormValidator();
final isNewUser = false.obs;
final RxBool isLoading = false.obs;
final formKey = GlobalKey<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());
@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.");
_navigateToDashboard(message: "MPIN Set Successfully");
} else {
logger.w("[MPINController] MPIN generation failed.");
clearFields();
clearRetypeFields();
}
} else {
logger.i("[MPINController] Existing user. Proceeding to verify MPIN.");
await verifyMPIN();
}
}
Future<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 (null response treated as success)");
Get.toNamed('/auth/mpin-auth');
return true;
} else {
logger.w(
"[MPINController] MPIN generation returned error response: $response");
showAppSnackbar(
title: "MPIN Generation Failed",
message: "Please check your inputs.",
type: SnackbarType.error,
);
basicValidator.addErrors(response);
basicValidator.validateForm();
basicValidator.clearErrors();
return false;
}
} catch (e) {
isLoading.value = false;
_showError("Failed to generate MPIN: $e");
logger.e("[MPINController] Exception in generateMPIN: $e");
return false;
}
}
Future<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();
}
} 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,
);
}
}
}

View File

@ -0,0 +1,168 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class OTPController extends GetxController {
final formKey = GlobalKey<FormState>();
// Observables
final RxString phoneNumber = ''.obs;
final RxBool isOTPSent = false.obs;
final RxBool isSending = false.obs;
final RxBool isResending = false.obs;
final RxInt timer = 0.obs;
Timer? _countdownTimer;
// Text controllers and focus nodes
final TextEditingController phoneController = TextEditingController();
final List<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();
phoneController.dispose();
for (final controller in otpControllers) {
controller.dispose();
}
for (final node in focusNodes) {
node.dispose();
}
super.onClose();
}
static Future<bool> _sendOTP(String phone) async {
await Future.delayed(const Duration(seconds: 2));
debugPrint('Sending OTP to $phone');
return true;
}
Future<void> sendOTP() async {
final phone = phoneController.text.trim();
if (!_validatePhone(phone)) {
showAppSnackbar(
title: "Error",
message: "Please enter a valid 10-digit mobile number",
type: SnackbarType.error,
);
return;
}
if (isSending.value) return;
isSending.value = true;
final success = await _sendOTP(phone);
if (success) {
phoneNumber.value = phone;
isOTPSent.value = true;
_startTimer();
_clearOTPFields();
} else {
showAppSnackbar(
title: "Error",
message: "Failed to send OTP. Please try again.",
type: SnackbarType.error,
);
}
isSending.value = false;
}
Future<void> onResendOTP() async {
if (isResending.value) return;
isResending.value = true;
_clearOTPFields();
final success = await _sendOTP(phoneNumber.value);
if (success) {
_startTimer();
} else {
showAppSnackbar(
title: "Error",
message: "Failed to resend OTP. Please try again.",
type: SnackbarType.error,
);
}
isResending.value = false;
}
void onOTPChanged(String value, int index) {
if (value.isNotEmpty) {
if (index < otpControllers.length - 1) {
focusNodes[index + 1].requestFocus();
} else {
focusNodes[index].unfocus();
}
} else {
if (index > 0) {
focusNodes[index - 1].requestFocus();
}
}
}
void verifyOTP() {
final enteredOTP = otpControllers.map((c) => c.text).join();
if (enteredOTP.length != otpControllers.length || enteredOTP.contains('')) {
showAppSnackbar(
title: "Error",
message: "Please enter the complete ${otpControllers.length}-digit OTP",
type: SnackbarType.error,
);
return;
}
// TODO: Add your OTP verification logic
debugPrint('Verifying OTP: $enteredOTP');
}
void _clearOTPFields() {
for (final controller in otpControllers) {
controller.clear();
}
focusNodes[0].requestFocus();
}
void _startTimer() {
timer.value = 60;
_countdownTimer?.cancel();
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (this.timer.value > 0) {
this.timer.value--;
} else {
timer.cancel();
}
});
}
void resetForChangeNumber() {
isOTPSent.value = false;
phoneNumber.value = '';
phoneController.clear();
_clearOTPFields();
timer.value = 0;
isSending.value = false;
isResending.value = false;
for (final node in focusNodes) {
node.unfocus();
}
}
bool _validatePhone(String phone) {
final regex = RegExp(r'^\d{10}$');
return regex.hasMatch(phone);
}
}

View File

@ -61,6 +61,6 @@ class RegisterAccountController extends MyController {
}
void gotoLogin() {
Get.toNamed('/auth/login');
Get.toNamed('/auth/login-option');
}
}

View File

@ -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,21 +18,21 @@ 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'];
final mpinToken = responseData['data']['mpinToken'];
// Log the tokens using the logger
logger.i("JWT Token: $jwtToken");
if (refreshToken != null) logger.i("Refresh Token: $refreshToken");
if (mpinToken != null) logger.i("MPIN Token: $mpinToken");
await LocalStorage.setJwtToken(jwtToken);
await LocalStorage.setLoggedInUser(true);
@ -39,9 +40,16 @@ class AuthService {
if (refreshToken != null) {
await LocalStorage.setRefreshToken(refreshToken);
}
if (mpinToken != null && mpinToken.isNotEmpty) {
await LocalStorage.setMpinToken(mpinToken);
await LocalStorage.setIsMpin(true);
} else {
await LocalStorage.setIsMpin(false);
await LocalStorage.removeMpinToken();
}
Get.put(PermissionController());
isLoggedIn = true;
return null;
} else if (response.statusCode == 401) {
return {"password": "Invalid email or password"};
@ -198,4 +206,98 @@ class AuthService {
return null;
}
}
/// Generates a new MPIN for the user.
static Future<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."};
}
}
}

View File

@ -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);
}
}

View File

@ -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),
);
}

View File

@ -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()),
);
}

View File

@ -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

View 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),
),
),
);
}
}

View File

@ -2,15 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:get/get.dart';
import 'package:marco/controller/auth/forgot_password_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/view/layouts/auth_layout.dart';
import 'package:marco/images.dart';
import 'package:marco/helpers/theme/app_theme.dart';
import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key});
@ -19,100 +18,186 @@ class ForgotPasswordScreen extends StatefulWidget {
State<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");
@override
Widget build(BuildContext context) {
return AuthLayout(
child: GetBuilder(
init: controller,
builder: (controller) {
return Form(
key: controller.basicValidator.formKey,
child: SingleChildScrollView(
padding: MySpacing.xy(2, 40),
child: Container(
width: double.infinity,
padding: MySpacing.all(24),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.02),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: contentTheme.primary.withOpacity(0.5),
return Scaffold(
backgroundColor: contentTheme.brandRed,
body: SafeArea(
child: LayoutBuilder(builder: (context, constraints) {
return Column(
children: [
const SizedBox(height: 24),
_buildHeader(),
const SizedBox(height: 16),
_buildWelcomeTextsAndChips(),
const SizedBox(height: 16),
Expanded(
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.vertical(top: Radius.circular(32)),
),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 32),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight - 120,
),
child: Form(
key: controller.basicValidator.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
MyText.titleLarge(
'Forgot Password',
fontWeight: 700,
color: Colors.black87,
),
const SizedBox(height: 8),
MyText.bodyMedium(
"Enter your email and we'll send you instructions to reset your password.",
color: Colors.black54,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
validator: controller.basicValidator
.getValidation('email'),
controller: controller.basicValidator
.getController('email'),
keyboardType: TextInputType.emailAddress,
style: MyTextStyle.labelMedium(),
decoration: InputDecoration(
labelText: "Email Address",
labelStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon:
const Icon(LucideIcons.mail, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 16),
floatingLabelBehavior:
FloatingLabelBehavior.auto,
),
),
const SizedBox(height: 40),
MyButton.rounded(
onPressed: controller.onForgotPassword,
elevation: 2,
padding: MySpacing.xy(80, 16),
borderRadiusAll: 10,
backgroundColor: contentTheme.brandRed,
child: MyText.labelLarge(
'Send Reset Link',
fontWeight: 700,
color: Colors.white,
),
),
const SizedBox(height: 24),
TextButton(
onPressed: () async {
await LocalStorage.logout();
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.arrow_back,
size: 16, color: Colors.red),
const SizedBox(width: 4),
MyText.bodySmall(
'Back to log in',
fontWeight: 600,
fontSize: 14,
color: contentTheme.brandRed,
),
],
),
),
],
),
),
),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Image.asset(
Images.logoDark,
height: 120,
fit: BoxFit.contain,
),
),
MySpacing.height(10),
MyText.titleLarge("Forgot Password", fontWeight: 600),
MySpacing.height(12),
MyText.bodyMedium(
"Enter your email and we'll send you instructions to reset your password.",
fontWeight: 600,
xMuted: true,
),
MySpacing.height(12),
TextFormField(
validator: controller.basicValidator.getValidation('email'),
controller: controller.basicValidator.getController('email'),
keyboardType: TextInputType.emailAddress,
style: MyTextStyle.labelMedium(),
decoration: InputDecoration(
labelText: "Email Address",
labelStyle: MyTextStyle.bodySmall(xMuted: true),
border: OutlineInputBorder(borderSide: BorderSide.none),
filled: true,
fillColor: theme.cardColor,
prefixIcon: Icon(LucideIcons.mail, size: 16),
contentPadding: MySpacing.all(15),
isDense: true,
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
MySpacing.height(20),
Center(
child: MyButton.rounded(
onPressed: controller.onForgotPassword,
elevation: 0,
padding: MySpacing.xy(20, 16),
backgroundColor: Colors.blueAccent,
child: MyText.labelMedium(
'Send Reset Link',
color: contentTheme.onPrimary,
),
),
),
Center(
child: MyButton.text(
onPressed: controller.gotoLogIn,
elevation: 0,
padding: MySpacing.x(16),
splashColor:
contentTheme.secondary.withValues(alpha: 0.1),
child: MyText.labelMedium(
'Back to log in',
color: contentTheme.secondary,
),
),
),
],
),
),
),
],
);
},
}),
),
);
}
Widget _buildWelcomeTextsAndChips() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
MyText.titleMedium(
"Welcome to Marco",
fontWeight: 600,
color: Colors.white,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
MyText.bodySmall(
"Streamline Project Management and Boost Productivity with Automation.",
color: Colors.white70,
textAlign: TextAlign.center,
),
if (_isBetaEnvironment) ...[
const SizedBox(height: 8),
_buildBetaLabel(),
],
],
),
);
}
Widget _buildBetaLabel() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(4),
),
child: MyText.bodySmall(
'BETA',
fontWeight: 600,
color: Colors.white,
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 6,
offset: Offset(0, 3),
),
],
),
child: Image.asset(Images.logoDark, height: 70),
);
}
}

View 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,
),
);
}
}

View File

@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:marco/controller/auth/mpin_controller.dart';
import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/images.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class MPINAuthScreen extends StatefulWidget {
const MPINAuthScreen({super.key});
@override
State<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 Column(
children: [
if (isNewUser)
TextButton.icon(
onPressed: controller.switchToEnterMPIN,
icon: const Icon(Icons.arrow_back, size: 18, color: Colors.black87),
label: MyText.bodyMedium(
'Back to Enter MPIN',
color: Colors.black87,
fontWeight: 600,
fontSize: 14,
),
),
if (isNewUser)
TextButton.icon(
onPressed: () async {
Get.delete<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,
),
),
],
);
}
}

View 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,
),
),
);
}
}

View File

@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_button.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/controller/auth/otp_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
class OTPLoginScreen extends StatefulWidget {
const OTPLoginScreen({super.key});
@override
State<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 Obx(() {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium('OTP Verification', fontWeight: 600),
MySpacing.height(24),
if (!controller.isOTPSent.value) ...[
_buildPhoneInput(),
MySpacing.height(24),
_buildSendOTPButton(theme),
] else ...[
_buildOTPSentInfo(),
MySpacing.height(12),
_buildOTPForm(theme),
MySpacing.height(12),
_buildResendOTPSection(theme),
MySpacing.height(12),
_buildVerifyOTPButton(theme),
],
],
),
);
});
}
Widget _buildPhoneInput() {
return TextFormField(
controller: controller.phoneController,
keyboardType: TextInputType.phone,
maxLength: 10,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
labelText: 'Enter Mobile Number',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
counterText: '',
),
onFieldSubmitted: (_) => controller.sendOTP(),
);
}
Widget _buildSendOTPButton(ThemeData theme) {
final isDisabled = controller.isSending.value || controller.timer.value > 0;
return Align(
alignment: Alignment.center,
child: MyButton.rounded(
onPressed: isDisabled ? null : controller.sendOTP,
elevation: 2,
padding: MySpacing.xy(24, 16),
borderRadiusAll: 10,
backgroundColor: isDisabled ? Colors.grey : contentTheme.brandRed,
child: controller.isSending.value
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: MyText.labelMedium(
'Send OTP',
fontWeight: 600,
color: theme.colorScheme.onPrimary,
),
),
);
}
Widget _buildOTPSentInfo() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.sms_outlined, color: Colors.redAccent, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(
'OTP sent to',
fontWeight: 500,
),
MyText.bodySmall(
'+91-${controller.phoneNumber.value}',
fontWeight: 700,
color: Colors.black,
),
],
),
),
TextButton.icon(
onPressed: controller.resetForChangeNumber,
icon: Icon(Icons.edit, size: 16, color: Colors.redAccent),
label: MyText.bodySmall(
'Change',
fontWeight: 600,
color: Colors.redAccent,
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
foregroundColor: Colors.redAccent,
),
),
],
),
);
}
Widget _buildOTPForm(ThemeData theme) {
return Form(
key: _otpFormKey,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
4, (index) => _buildOTPBox(index: index, theme: theme)),
),
);
}
Widget _buildOTPBox({required int index, required ThemeData theme}) {
return Container(
margin: MySpacing.x(6),
width: 50,
child: TextFormField(
controller: controller.otpControllers[index],
focusNode: controller.focusNodes[index],
maxLength: 1,
textAlign: TextAlign.center,
style: MyTextStyle.titleLarge(fontWeight: 700),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (value) => controller.onOTPChanged(value, index),
decoration: InputDecoration(
counterText: '',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade400, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: contentTheme.brandRed, width: 2),
),
),
),
);
}
Widget _buildResendOTPSection(ThemeData theme) {
if (controller.timer.value > 0) {
return MyText.bodySmall(
'Resend OTP in ${controller.timer.value}s',
color: theme.colorScheme.onBackground.withOpacity(0.6),
textAlign: TextAlign.center,
);
} else {
return Align(
alignment: Alignment.center,
child: TextButton(
onPressed:
controller.isResending.value ? null : controller.onResendOTP,
child: controller.isResending.value
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: MyText.bodySmall(
'Resend OTP',
fontWeight: 600,
color: Colors.blueAccent,
),
),
);
}
}
Widget _buildVerifyOTPButton(ThemeData theme) {
return Align(
alignment: Alignment.center,
child: MyButton.rounded(
onPressed: controller.verifyOTP,
elevation: 2,
padding: MySpacing.xy(24, 16),
borderRadiusAll: 10,
backgroundColor: contentTheme.brandRed,
child: MyText.labelMedium(
'Verify OTP',
fontWeight: 600,
color: theme.colorScheme.onPrimary,
),
),
);
}
}

View File

@ -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,
);
}
}
}

View File

@ -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;