diff --git a/android/app/build.gradle b/android/app/build.gradle index a879d75..ac58f70 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -37,7 +37,7 @@ android { // Default configuration for your application defaultConfig { // Specify your unique Application ID. This identifies your app on Google Play. - applicationId = "com.marco.aiot" + applicationId = "com.marco.aiotstage" // Set minimum and target SDK versions based on Flutter's configuration minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5e83e07..1be2de2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,7 +10,7 @@ index: $index, value: $value, isRetype: $isRetype"); + logSafe( + "onDigitChanged -> index: $index, value: $value, isRetype: $isRetype"); final nodes = isRetype ? retypeFocusNodes : focusNodes; if (value.isNotEmpty && index < 3) { nodes[index + 1].requestFocus(); @@ -212,7 +214,8 @@ class MPINController extends GetxController { if (response == null) { return true; } else { - logSafe("MPIN generation returned error: $response", level: LogLevel.warning); + logSafe("MPIN generation returned error: $response", + level: LogLevel.warning); showAppSnackbar( title: "MPIN Operation Failed", message: "Please check your inputs.", @@ -253,9 +256,13 @@ class MPINController extends GetxController { try { isLoading.value = true; + // ✅ Fetch FCM Token here + final fcmToken = await FirebaseNotificationService().getFcmToken(); + final response = await AuthService.verifyMpin( mpin: enteredMPIN, mpinToken: mpinToken, + fcmToken: fcmToken ?? '', ); isLoading.value = false; @@ -272,7 +279,8 @@ class MPINController extends GetxController { _navigateToDashboard(); } else { final errorMessage = response["error"] ?? "Invalid MPIN"; - logSafe("MPIN verification failed: $errorMessage", level: LogLevel.warning); + logSafe("MPIN verification failed: $errorMessage", + level: LogLevel.warning); showAppSnackbar( title: "Error", message: errorMessage, diff --git a/lib/controller/dashboard/daily_task_controller.dart b/lib/controller/dashboard/daily_task_controller.dart index c83292c..6cb6395 100644 --- a/lib/controller/dashboard/daily_task_controller.dart +++ b/lib/controller/dashboard/daily_task_controller.dart @@ -49,7 +49,8 @@ class DailyTaskController extends GetxController { Future fetchTaskData(String? projectId) async { if (projectId == null) { - logSafe("fetchTaskData: Skipped, projectId is null", level: LogLevel.warning); + logSafe("fetchTaskData: Skipped, projectId is null", + level: LogLevel.warning); return; } @@ -99,7 +100,8 @@ class DailyTaskController extends GetxController { firstDate: DateTime(2022), lastDate: DateTime.now(), initialDateRange: DateTimeRange( - start: startDateTask ?? DateTime.now().subtract(const Duration(days: 7)), + start: + startDateTask ?? DateTime.now().subtract(const Duration(days: 7)), end: endDateTask ?? DateTime.now(), ), ); @@ -119,4 +121,15 @@ class DailyTaskController extends GetxController { await controller.fetchTaskData(controller.selectedProjectId); } + +void refreshTasksFromNotification({ + required String projectId, + required String taskAllocationId, +}) async { + // re-fetch tasks + await fetchTaskData(projectId); + + update(); // rebuilds UI +} + } diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index f4b1d35..cd16b1d 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -181,15 +181,27 @@ class AddExpenseController extends GetxController { // --- Pickers --- Future pickTransactionDate(BuildContext context) async { - final picked = await showDatePicker( + final pickedDate = await showDatePicker( context: context, initialDate: selectedTransactionDate.value ?? DateTime.now(), firstDate: DateTime(DateTime.now().year - 5), lastDate: DateTime.now(), ); - if (picked != null) { - selectedTransactionDate.value = picked; - transactionDateController.text = DateFormat('dd-MM-yyyy').format(picked); + + if (pickedDate != null) { + final now = DateTime.now(); // get current time + final finalDateTime = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + now.hour, + now.minute, + now.second, + ); + + selectedTransactionDate.value = finalDateTime; + transactionDateController.text = + DateFormat('dd-MM-yyyy HH:mm').format(finalDateTime); } } diff --git a/lib/helpers/services/auth_service.dart b/lib/helpers/services/auth_service.dart index ca5729e..1f1d3e8 100644 --- a/lib/helpers/services/auth_service.dart +++ b/lib/helpers/services/auth_service.dart @@ -15,6 +15,31 @@ class AuthService { }; static bool isLoggedIn = false; + /* -------------------------------------------------------------------------- */ + /* Logout API */ + /* -------------------------------------------------------------------------- */ + static Future logoutApi(String refreshToken, String fcmToken) async { + try { + final body = { + "refreshToken": refreshToken, + "fcmToken": fcmToken, + }; + + final response = await _post("/auth/logout", body); + + if (response != null && response['statusCode'] == 200) { + logSafe("✅ Logout API successful"); + return true; + } + + logSafe("⚠️ Logout API failed: ${response?['message']}", + level: LogLevel.warning); + return false; + } catch (e, st) { + _handleError("Logout API error", e, st); + return false; + } + } /* -------------------------------------------------------------------------- */ /* Public Methods */ @@ -133,6 +158,7 @@ class AuthService { static Future?> verifyMpin({ required String mpin, required String mpinToken, + required String fcmToken, }) => _wrapErrorHandling( () async { @@ -144,7 +170,8 @@ class AuthService { { "employeeId": employeeInfo.id, "mpin": mpin, - "mpinToken": mpinToken + "mpinToken": mpinToken, + "fcmToken": fcmToken, }, authToken: token, ); diff --git a/lib/helpers/services/notification_action_handler.dart b/lib/helpers/services/notification_action_handler.dart index cf96f5d..2ac37b8 100644 --- a/lib/helpers/services/notification_action_handler.dart +++ b/lib/helpers/services/notification_action_handler.dart @@ -2,6 +2,10 @@ import 'package:get/get.dart'; import 'package:logger/logger.dart'; import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; +import 'package:marco/controller/dashboard/daily_task_controller.dart'; +import 'package:marco/controller/task_planing/daily_task_planing_controller.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/controller/expense/expense_detail_controller.dart'; /// Handles incoming FCM notification actions and updates UI/controllers. class NotificationActionHandler { @@ -21,32 +25,159 @@ class NotificationActionHandler { final keyword = data['Keyword']; if (type != null) { - switch (type) { - case 'expense_updated': - break; - case 'attendance_updated': - _handleAttendanceUpdated(data); - break; - default: - _logger.w('⚠️ Unknown notification type: $type'); - } - } else if (keyword == 'Attendance' && action == 'CHECK_IN' || action == 'CHECK_OUT' || action == 'REQUEST_REGULARIZE ' || action == 'REQUEST_DELETE '|| action == 'REGULARIZE ' || action == 'REGULARIZE_REJECT ') { - // Matches your current logs - _handleAttendanceUpdated(data); + _handleByType(type, data); + } else if (keyword != null) { + _handleByKeyword(keyword, action, data); } else { _logger.w('⚠️ Unhandled notification: $data'); } } + /// Handle notification if identified by `type` + static void _handleByType(String type, Map data) { + switch (type) { + case 'expense_updated': + // No specific handler yet + break; + case 'attendance_updated': + _handleAttendanceUpdated(data); + break; + default: + _logger.w('⚠️ Unknown notification type: $type'); + } + } + + /// Handle notification if identified by `keyword` + static void _handleByKeyword( + String keyword, String? action, Map data) { + switch (keyword) { + case 'Attendance': + if (_isAttendanceAction(action)) { + _handleAttendanceUpdated(data); + } + break; + + case 'Report_Task': + _handleTaskUpdated(data, isComment: false); + break; + + case 'Task_Comment': + _handleTaskUpdated(data, isComment: true); + break; + case 'Expenses_Modified': + _handleExpenseUpdated(data); + break; + + // ✅ New cases + case 'Task_Modified': + case 'WorkArea_Modified': + case 'Floor_Modified': + case 'Building_Modified': + _handleTaskPlanningUpdated(data); + break; + + default: + _logger.w('⚠️ Unhandled notification keyword: $keyword'); + } + } + + static void _handleTaskPlanningUpdated(Map data) { + final projectId = data['ProjectId']; + if (projectId == null) { + _logger.w("⚠️ TaskPlanning update received without ProjectId: $data"); + return; + } + + _safeControllerUpdate( + onFound: (controller) { + controller.fetchTaskData(projectId); + }, + notFoundMessage: + '⚠️ DailyTaskPlaningController not found, cannot refresh.', + successMessage: + '✅ DailyTaskPlaningController refreshed from notification.', + ); + } + + /// Validates the set of allowed Attendance actions + static bool _isAttendanceAction(String? action) { + const validActions = { + 'CHECK_IN', + 'CHECK_OUT', + 'REQUEST_REGULARIZE', + 'REQUEST_DELETE', + 'REGULARIZE', + 'REGULARIZE_REJECT' + }; + return validActions.contains(action); + } + + static void _handleExpenseUpdated(Map data) { + final expenseId = data['ExpenseId']; + if (expenseId == null) { + _logger.w("⚠️ Expense update received without ExpenseId: $data"); + return; + } + + // Update Expense List + _safeControllerUpdate( + onFound: (controller) async { + await controller.fetchExpenses(); + }, + notFoundMessage: '⚠️ ExpenseController not found, cannot refresh list.', + successMessage: + '✅ ExpenseController refreshed from expense notification.', + ); + + // Update Expense Detail (if open and matches this expenseId) + _safeControllerUpdate( + onFound: (controller) async { + // only refresh if the open screen is for this expense + if (controller.expense.value?.id == expenseId) { + await controller.fetchExpenseDetails(); + _logger + .i("✅ ExpenseDetailController refreshed for Expense $expenseId"); + } + }, + notFoundMessage: 'ℹ️ ExpenseDetailController not active, skipping.', + successMessage: '✅ ExpenseDetailController checked for refresh.', + ); + } + static void _handleAttendanceUpdated(Map data) { + _safeControllerUpdate( + onFound: (controller) => controller.refreshDataFromNotification( + projectId: data['ProjectId'], + ), + notFoundMessage: '⚠️ AttendanceController not found, cannot update.', + successMessage: '✅ AttendanceController refreshed from notification.', + ); + } + + static void _handleTaskUpdated(Map data, + {required bool isComment}) { + _safeControllerUpdate( + onFound: (controller) => controller.refreshTasksFromNotification( + projectId: data['ProjectId'], + taskAllocationId: data['TaskAllocationId'], + ), + notFoundMessage: '⚠️ DailyTaskController not found, cannot update.', + successMessage: '✅ DailyTaskController refreshed from notification.', + ); + } + + /// Generic reusable method for safe GetX controller access + log handling + static void _safeControllerUpdate({ + required void Function(T controller) onFound, + required String notFoundMessage, + required String successMessage, + }) { try { - final controller = Get.find(); - controller.refreshDataFromNotification( - projectId: data['ProjectId'], - ); - _logger.i('✅ AttendanceController refreshed from notification.'); + final controller = Get.find(); + onFound(controller); + _logger.i(successMessage); } catch (e) { - _logger.w('⚠️ AttendanceController not found, cannot update.'); + _logger.w(notFoundMessage); } } } diff --git a/lib/helpers/services/storage/local_storage.dart b/lib/helpers/services/storage/local_storage.dart index e883797..7264067 100644 --- a/lib/helpers/services/storage/local_storage.dart +++ b/lib/helpers/services/storage/local_storage.dart @@ -36,26 +36,22 @@ class LocalStorage { } static Future initData() async { - AuthService.isLoggedIn = - preferences.getBool(_loggedInUserKey) ?? false; - ThemeCustomizer.fromJSON( - preferences.getString(_themeCustomizerKey)); + AuthService.isLoggedIn = preferences.getBool(_loggedInUserKey) ?? false; + ThemeCustomizer.fromJSON(preferences.getString(_themeCustomizerKey)); } /// ================== User Permissions ================== static Future setUserPermissions( List permissions) async { final jsonList = permissions.map((e) => e.toJson()).toList(); - return preferences.setString( - _userPermissionsKey, jsonEncode(jsonList)); + return preferences.setString(_userPermissionsKey, jsonEncode(jsonList)); } static List getUserPermissions() { final storedJson = preferences.getString(_userPermissionsKey); if (storedJson == null) return []; return (jsonDecode(storedJson) as List) - .map((e) => UserPermission.fromJson( - e as Map)) + .map((e) => UserPermission.fromJson(e as Map)) .toList(); } @@ -63,9 +59,8 @@ class LocalStorage { preferences.remove(_userPermissionsKey); /// ================== Employee Info ================== - static Future setEmployeeInfo(EmployeeInfo employeeInfo) => - preferences.setString( - _employeeInfoKey, jsonEncode(employeeInfo.toJson())); + static Future setEmployeeInfo(EmployeeInfo employeeInfo) => preferences + .setString(_employeeInfoKey, jsonEncode(employeeInfo.toJson())); static EmployeeInfo? getEmployeeInfo() { final storedJson = preferences.getString(_employeeInfoKey); @@ -85,6 +80,19 @@ class LocalStorage { preferences.remove(_loggedInUserKey); static Future logout() async { + try { + final refreshToken = getRefreshToken(); + final fcmToken = getFcmToken(); + + // Call API only if both tokens exist + if (refreshToken != null && fcmToken != null) { + await AuthService.logoutApi(refreshToken, fcmToken); + } + } catch (e) { + print("Logout API error: $e"); + } + + // ===== Local Cleanup ===== await removeLoggedInUser(); await removeToken(_jwtTokenKey); await removeToken(_refreshTokenKey); @@ -105,27 +113,21 @@ class LocalStorage { } /// ================== Theme & Language ================== - static Future setCustomizer( - ThemeCustomizer themeCustomizer) => - preferences.setString( - _themeCustomizerKey, themeCustomizer.toJSON()); + static Future setCustomizer(ThemeCustomizer themeCustomizer) => + preferences.setString(_themeCustomizerKey, themeCustomizer.toJSON()); static Future setLanguage(Language language) => - preferences.setString( - _languageKey, language.locale.languageCode); + preferences.setString(_languageKey, language.locale.languageCode); - static String? getLanguage() => - preferences.getString(_languageKey); + static String? getLanguage() => preferences.getString(_languageKey); /// ================== Tokens ================== static Future setToken(String key, String token) => preferences.setString(key, token); - static String? getToken(String key) => - preferences.getString(key); + static String? getToken(String key) => preferences.getString(key); - static Future removeToken(String key) => - preferences.remove(key); + static Future removeToken(String key) => preferences.remove(key); static Future setJwtToken(String jwtToken) => setToken(_jwtTokenKey, jwtToken); @@ -135,44 +137,36 @@ class LocalStorage { static String? getJwtToken() => getToken(_jwtTokenKey); - static String? getRefreshToken() => - getToken(_refreshTokenKey); + static String? getRefreshToken() => getToken(_refreshTokenKey); /// ================== FCM Token ================== static Future setFcmToken(String token) => preferences.setString(_fcmTokenKey, token); - static String? getFcmToken() => - preferences.getString(_fcmTokenKey); + static String? getFcmToken() => preferences.getString(_fcmTokenKey); /// ================== MPIN ================== static Future setMpinToken(String token) => preferences.setString(_mpinTokenKey, token); - static String? getMpinToken() => - preferences.getString(_mpinTokenKey); + static String? getMpinToken() => preferences.getString(_mpinTokenKey); - static Future removeMpinToken() => - preferences.remove(_mpinTokenKey); + static Future removeMpinToken() => preferences.remove(_mpinTokenKey); static Future setIsMpin(bool value) => preferences.setBool(_isMpinKey, value); - static bool getIsMpin() => - preferences.getBool(_isMpinKey) ?? false; + static bool getIsMpin() => preferences.getBool(_isMpinKey) ?? false; - static Future removeIsMpin() => - preferences.remove(_isMpinKey); + static Future removeIsMpin() => preferences.remove(_isMpinKey); /// ================== Generic Set/Get ================== static Future setBool(String key, bool value) => preferences.setBool(key, value); - static bool? getBool(String key) => - preferences.getBool(key); + static bool? getBool(String key) => preferences.getBool(key); - static String? getString(String key) => - preferences.getString(key); + static String? getString(String key) => preferences.getString(key); static Future saveString(String key, String value) => preferences.setString(key, value); diff --git a/lib/helpers/utils/date_time_utils.dart b/lib/helpers/utils/date_time_utils.dart index 01bae1f..4a7512c 100644 --- a/lib/helpers/utils/date_time_utils.dart +++ b/lib/helpers/utils/date_time_utils.dart @@ -5,6 +5,8 @@ class DateTimeUtils { /// Converts a UTC datetime string to local time and formats it. static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) { try { + logSafe('Received UTC string: $utcTimeString'); // 🔹 Log input + final parsed = DateTime.parse(utcTimeString); final utcDateTime = DateTime.utc( parsed.year, @@ -21,9 +23,13 @@ class DateTimeUtils { final formatted = _formatDateTime(localDateTime, format: format); + logSafe('Converted Local DateTime: $localDateTime'); // 🔹 Log raw local datetime + logSafe('Formatted Local DateTime: $formatted'); // 🔹 Log formatted string + return formatted; } catch (e, stackTrace) { - logSafe('DateTime conversion failed: $e', error: e, stackTrace: stackTrace); + logSafe('DateTime conversion failed: $e', + error: e, stackTrace: stackTrace); return 'Invalid Date'; } } @@ -31,7 +37,9 @@ class DateTimeUtils { /// Public utility for formatting any DateTime. static String formatDate(DateTime date, String format) { try { - return DateFormat(format).format(date); + final formatted = DateFormat(format).format(date); + logSafe('Formatted DateTime ($date) => $formatted'); // 🔹 Log input/output + return formatted; } catch (e, stackTrace) { logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace); return 'Invalid Date'; diff --git a/lib/helpers/utils/validators.dart b/lib/helpers/utils/validators.dart new file mode 100644 index 0000000..a001bba --- /dev/null +++ b/lib/helpers/utils/validators.dart @@ -0,0 +1,271 @@ +// lib/utils/validators.dart +import 'package:flutter/services.dart'; + +/// Common validators for Indian IDs, payments, and typical form fields. +class Validators { + // ----------------------------- + // Regexes (compiled once) + // ----------------------------- + static final RegExp _panRegex = RegExp(r'^[A-Z]{5}[0-9]{4}[A-Z]$'); + // GSTIN: 2-digit/valid state code, PAN, entity code (1-9A-Z), 'Z', checksum (0-9A-Z) + static final RegExp _gstRegex = RegExp( + r'^(0[1-9]|1[0-9]|2[0-9]|3[0-7])[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$', + ); + // Aadhaar digits only + static final RegExp _aadhaarRegex = RegExp(r'^[2-9]\d{11}$'); + // Name (letters + spaces + dots + hyphen/apostrophe) + static final RegExp _nameRegex = RegExp(r"^[A-Za-z][A-Za-z .'\-]{1,49}$"); + // Email (generic) + static final RegExp _emailRegex = + RegExp(r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"); + // Indian mobile + static final RegExp _mobileRegex = RegExp(r'^[6-9]\d{9}$'); + // Pincode (India: 6 digits starting 1–9) + static final RegExp _pincodeRegex = RegExp(r'^[1-9][0-9]{5}$'); + // IFSC (4 letters + 0 + 6 alphanumeric) + static final RegExp _ifscRegex = RegExp(r'^[A-Z]{4}0[A-Z0-9]{6}$'); + // Bank account number (9–18 digits) + static final RegExp _bankAccountRegex = RegExp(r'^\d{9,18}$'); + // UPI ID (name@bank, simple check) + static final RegExp _upiRegex = + RegExp(r'^[\w.\-]{2,}@[\w]{2,}$', caseSensitive: false); + // Strong password (8+ chars, upper, lower, digit, special) + static final RegExp _passwordRegex = + RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$'); + // Date dd/mm/yyyy (basic validation) + static final RegExp _dateRegex = + RegExp(r'^([0-2][0-9]|3[0-1])/(0[1-9]|1[0-2])/[0-9]{4}$'); + // URL + static final RegExp _urlRegex = RegExp( + r'^(https?:\/\/)?([a-zA-Z0-9.-]+)\.[a-zA-Z]{2,}(:\d+)?(\/\S*)?$'); + // Transaction ID (alphanumeric, dashes/underscores, 8–36 chars) + static final RegExp _transactionIdRegex = + RegExp(r'^[A-Za-z0-9\-_]{8,36}$'); + + // ----------------------------- + // PAN + // ----------------------------- + static bool isValidPAN(String? input) { + if (input == null) return false; + return _panRegex.hasMatch(input.trim().toUpperCase()); + } + + // ----------------------------- + // GSTIN + // ----------------------------- + static bool isValidGSTIN(String? input) { + if (input == null) return false; + return _gstRegex.hasMatch(_compact(input).toUpperCase()); + } + + // ----------------------------- + // Aadhaar + // ----------------------------- + static bool isValidAadhaar(String? input, {bool enforceChecksum = true}) { + if (input == null) return false; + final a = _digitsOnly(input); + if (!_aadhaarRegex.hasMatch(a)) return false; + return enforceChecksum ? _verhoeffValidate(a) : true; + } + + // ----------------------------- + // Mobile + // ----------------------------- + static bool isValidIndianMobile(String? input) { + if (input == null) return false; + final s = _digitsOnly(input.replaceFirst(RegExp(r'^(?:\+?91|0)'), '')); + return _mobileRegex.hasMatch(s); + } + + // ----------------------------- + // Email + // ----------------------------- + static bool isValidEmail(String? input, {bool gmailOnly = false}) { + if (input == null) return false; + final e = input.trim(); + if (!_emailRegex.hasMatch(e)) return false; + if (!gmailOnly) return true; + final domain = e.split('@').last.toLowerCase(); + return domain == 'gmail.com' || domain == 'googlemail.com'; + } + + static bool isValidGmail(String? input) => + isValidEmail(input, gmailOnly: true); + + // ----------------------------- + // Name + // ----------------------------- + static bool isValidName(String? input, {int minLen = 2, int maxLen = 50}) { + if (input == null) return false; + final s = input.trim(); + if (s.length < minLen || s.length > maxLen) return false; + return _nameRegex.hasMatch(s); + } + + // ----------------------------- + // Transaction ID + // ----------------------------- + static bool isValidTransactionId(String? input) { + if (input == null) return false; + return _transactionIdRegex.hasMatch(input.trim()); + } + + // ----------------------------- + // Other fields + // ----------------------------- + static bool isValidPincode(String? input) => + input != null && _pincodeRegex.hasMatch(input.trim()); + + static bool isValidIFSC(String? input) => + input != null && _ifscRegex.hasMatch(input.trim().toUpperCase()); + + static bool isValidBankAccount(String? input) => + input != null && _bankAccountRegex.hasMatch(_digitsOnly(input)); + + static bool isValidUPI(String? input) => + input != null && _upiRegex.hasMatch(input.trim()); + + static bool isValidPassword(String? input) => + input != null && _passwordRegex.hasMatch(input.trim()); + + static bool isValidDate(String? input) => + input != null && _dateRegex.hasMatch(input.trim()); + + static bool isValidURL(String? input) => + input != null && _urlRegex.hasMatch(input.trim()); + + // ----------------------------- + // Numbers + // ----------------------------- + static bool isInt(String? input) => + input != null && int.tryParse(input.trim()) != null; + + static bool isDouble(String? input) => + input != null && double.tryParse(input.trim()) != null; + + static bool isNumeric(String? input) => isInt(input) || isDouble(input); + + static bool isInRange(num? value, + {num? min, num? max, bool inclusive = true}) { + if (value == null) return false; + if (min != null && (inclusive ? value < min : value <= min)) return false; + if (max != null && (inclusive ? value > max : value >= max)) return false; + return true; + } + + // ----------------------------- + // Flutter-friendly validator lambdas (return null when valid) + // ----------------------------- + static String? requiredField(String? v, {String fieldName = 'This field'}) => + (v == null || v.trim().isEmpty) ? '$fieldName is required' : null; + + static String? panValidator(String? v) => + isValidPAN(v) ? null : 'Enter a valid PAN (e.g., ABCDE1234F)'; + + static String? gstValidator(String? v, {bool optional = false}) { + if (optional && (v == null || v.trim().isEmpty)) return null; + return isValidGSTIN(v) ? null : 'Enter a valid GSTIN'; + } + + static String? aadhaarValidator(String? v) => + isValidAadhaar(v) ? null : 'Enter a valid Aadhaar (12 digits)'; + + static String? mobileValidator(String? v) => + isValidIndianMobile(v) ? null : 'Enter a valid 10-digit mobile'; + + static String? emailValidator(String? v, {bool gmailOnly = false}) => + isValidEmail(v, gmailOnly: gmailOnly) + ? null + : gmailOnly + ? 'Enter a valid Gmail address' + : 'Enter a valid email address'; + + static String? nameValidator(String? v, {int minLen = 2, int maxLen = 50}) => + isValidName(v, minLen: minLen, maxLen: maxLen) + ? null + : 'Enter a valid name ($minLen–$maxLen chars)'; + + static String? transactionIdValidator(String? v) => + isValidTransactionId(v) + ? null + : 'Enter a valid Transaction ID (8–36 chars, letters/numbers)'; + + static String? pincodeValidator(String? v) => + isValidPincode(v) ? null : 'Enter a valid 6-digit pincode'; + + static String? ifscValidator(String? v) => + isValidIFSC(v) ? null : 'Enter a valid IFSC code'; + + static String? bankAccountValidator(String? v) => + isValidBankAccount(v) ? null : 'Enter a valid bank account (9–18 digits)'; + + static String? upiValidator(String? v) => + isValidUPI(v) ? null : 'Enter a valid UPI ID'; + + static String? passwordValidator(String? v) => + isValidPassword(v) + ? null + : 'Password must be 8+ chars with upper, lower, digit, special'; + + static String? dateValidator(String? v) => + isValidDate(v) ? null : 'Enter date in dd/mm/yyyy format'; + + static String? urlValidator(String? v) => + isValidURL(v) ? null : 'Enter a valid URL'; + + // ----------------------------- + // Helpers + // ----------------------------- + static String _digitsOnly(String s) => s.replaceAll(RegExp(r'\D'), ''); + static String _compact(String s) => s.replaceAll(RegExp(r'\s'), ''); + + // ----------------------------- + // Verhoeff checksum (for Aadhaar) + // ----------------------------- + static const List> _verhoeffD = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], + [2, 3, 4, 0, 1, 7, 8, 9, 5, 6], + [3, 4, 0, 1, 2, 8, 9, 5, 6, 7], + [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], + [5, 9, 8, 7, 6, 0, 4, 3, 2, 1], + [6, 5, 9, 8, 7, 1, 0, 4, 3, 2], + [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], + [8, 7, 6, 5, 9, 3, 2, 1, 0, 4], + [9, 8, 7, 6, 5, 4, 3, 2, 1, 0], + ]; + static const List> _verhoeffP = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], + [5, 8, 0, 3, 7, 9, 6, 1, 4, 2], + [8, 9, 1, 6, 0, 4, 3, 5, 2, 7], + [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], + [4, 2, 8, 6, 5, 7, 3, 9, 0, 1], + [2, 7, 9, 3, 8, 0, 5, 4, 1, 6], + [7, 0, 4, 6, 9, 1, 2, 3, 5, 8], + ]; + + static bool _verhoeffValidate(String numStr) { + int c = 0; + final rev = numStr.split('').reversed.map(int.parse).toList(); + for (int i = 0; i < rev.length; i++) { + c = _verhoeffD[c][_verhoeffP[(i % 8)][rev[i]]]; + } + return c == 0; + } +} + +/// Common input formatters/masks useful in TextFields. +class InputFormatters { + static final digitsOnly = FilteringTextInputFormatter.digitsOnly; + static final upperAlnum = + FilteringTextInputFormatter.allow(RegExp(r'[A-Z0-9]')); + static final upperLetters = + FilteringTextInputFormatter.allow(RegExp(r'[A-Z]')); + static final name = + FilteringTextInputFormatter.allow(RegExp(r"[A-Za-z .'\-]")); + static final alnumWithSpace = + FilteringTextInputFormatter.allow(RegExp(r"[A-Za-z0-9 ]")); + static LengthLimitingTextInputFormatter maxLen(int n) => + LengthLimitingTextInputFormatter(n); +} diff --git a/lib/helpers/widgets/expense_main_components.dart b/lib/helpers/widgets/expense_main_components.dart index b6d0720..31c3ac9 100644 --- a/lib/helpers/widgets/expense_main_components.dart +++ b/lib/helpers/widgets/expense_main_components.dart @@ -6,9 +6,9 @@ import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/view/expense/expense_detail_screen.dart'; +import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { final ProjectController projectController; @@ -251,99 +251,20 @@ class ExpenseList extends StatelessWidget { void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) { final ExpenseController controller = Get.find(); - final RxBool isDeleting = false.obs; showDialog( context: context, barrierDismissible: false, - builder: (_) => Dialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Obx(() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), - child: isDeleting.value - ? const SizedBox( - height: 100, - child: Center(child: CircularProgressIndicator()), - ) - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.delete, - size: 48, color: Colors.redAccent), - const SizedBox(height: 16), - MyText.titleLarge("Delete Expense", - fontWeight: 600, - color: Theme.of(context).colorScheme.onBackground), - const SizedBox(height: 12), - MyText.bodySmall( - "Are you sure you want to delete this draft expense?", - textAlign: TextAlign.center, - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(0.7), - ), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => Navigator.pop(context), - icon: - const Icon(Icons.close, color: Colors.white), - label: MyText.bodyMedium( - "Cancel", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: - const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - isDeleting.value = true; - await controller.deleteExpense(expense.id); - isDeleting.value = false; - Navigator.pop(context); - showAppSnackbar( - title: 'Deleted', - message: 'Expense has been deleted.', - type: SnackbarType.success, - ); - }, - icon: const Icon(Icons.delete_forever, - color: Colors.white), - label: MyText.bodyMedium( - "Delete", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.redAccent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: - const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - ], - ), - ], - ), - ); - }), + builder: (_) => ConfirmDialog( + title: "Delete Expense", + message: "Are you sure you want to delete this draft expense?", + confirmText: "Delete", + cancelText: "Cancel", + icon: Icons.delete_forever, + confirmColor: Colors.redAccent, + onConfirm: () async { + await controller.deleteExpense(expense.id); + }, ), ); } @@ -391,7 +312,7 @@ class ExpenseList extends StatelessWidget { fontWeight: 600), Row( children: [ - MyText.bodyMedium('₹ ${expense.formattedAmount}', + MyText.bodyMedium('${expense.formattedAmount}', fontWeight: 600), if (expense.status.name.toLowerCase() == 'draft') ...[ const SizedBox(width: 8), diff --git a/lib/helpers/widgets/my_confirmation_dialog.dart b/lib/helpers/widgets/my_confirmation_dialog.dart new file mode 100644 index 0000000..f1155d6 --- /dev/null +++ b/lib/helpers/widgets/my_confirmation_dialog.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +class ConfirmDialog extends StatelessWidget { + final String title; + final String message; + final String confirmText; + final String cancelText; + final IconData icon; + final Color confirmColor; + final Future Function() onConfirm; + final RxBool? isProcessing; + + const ConfirmDialog({ + super.key, + required this.title, + required this.message, + required this.onConfirm, + this.confirmText = "Delete", + this.cancelText = "Cancel", + this.icon = Icons.delete, + this.confirmColor = Colors.redAccent, + this.isProcessing, + }); + + @override + Widget build(BuildContext context) { + // Use provided RxBool, or create one internally + final RxBool loading = isProcessing ?? false.obs; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: _ContentView( + title: title, + message: message, + icon: icon, + confirmColor: confirmColor, + confirmText: confirmText, + cancelText: cancelText, + loading: loading, + onConfirm: onConfirm, + ), + ), + ); + } +} + +class _ContentView extends StatelessWidget { + final String title, message, confirmText, cancelText; + final IconData icon; + final Color confirmColor; + final RxBool loading; + final Future Function() onConfirm; + + const _ContentView({ + required this.title, + required this.message, + required this.icon, + required this.confirmColor, + required this.confirmText, + required this.cancelText, + required this.loading, + required this.onConfirm, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: confirmColor), + const SizedBox(height: 16), + MyText.titleLarge( + title, + fontWeight: 600, + color: theme.colorScheme.onBackground, + ), + const SizedBox(height: 12), + MyText.bodySmall( + message, + textAlign: TextAlign.center, + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: Obx(() => _DialogButton( + text: cancelText, + icon: Icons.close, + color: Colors.grey, + isLoading: false, + onPressed: loading.value + ? null // disable while loading + : () => Navigator.pop(context, false), + )), + ), + const SizedBox(width: 12), + Expanded( + child: Obx(() => _DialogButton( + text: confirmText, + icon: Icons.delete_forever, + color: confirmColor, + isLoading: loading.value, + onPressed: () async { + try { + loading.value = true; + await onConfirm(); // 🔥 call API + Navigator.pop(context, true); // close on success + } catch (e) { + // Show error, dialog stays open + Get.snackbar("Error", "Failed to delete. Try again."); + } finally { + loading.value = false; + } + }, + )), + ), + ], + ), + ], + ); + } +} + +class _DialogButton extends StatelessWidget { + final String text; + final IconData icon; + final Color color; + final VoidCallback? onPressed; + final bool isLoading; + + const _DialogButton({ + required this.text, + required this.icon, + required this.color, + required this.onPressed, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + return ElevatedButton.icon( + onPressed: isLoading ? null : onPressed, + icon: isLoading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Icon(icon, color: Colors.white), + label: MyText.bodyMedium( + isLoading ? "Submitting.." : text, + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ); + } +} diff --git a/lib/model/attendance/attendence_action_button.dart b/lib/model/attendance/attendence_action_button.dart index ebbbd9a..7d61c5d 100644 --- a/lib/model/attendance/attendence_action_button.dart +++ b/lib/model/attendance/attendence_action_button.dart @@ -66,7 +66,9 @@ class _AttendanceActionButtonState extends State { type: SnackbarType.warning, ); return null; - } else if (selected.isAfter(now)) { + } + + if (selected.isAfter(now)) { showAppSnackbar( title: "Invalid Time", message: "Future time is not allowed.", @@ -104,15 +106,16 @@ class _AttendanceActionButtonState extends State { action = 0; actionText = ButtonActions.checkIn; break; - case 1: - final isOld = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2); - final isOldCheckout = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2); - if (widget.employee.checkOut == null && isOld) { + case 1: + final isOldCheckIn = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2); + final isOldCheckOut = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2); + + if (widget.employee.checkOut == null && isOldCheckIn) { action = 2; actionText = ButtonActions.requestRegularize; imageCapture = false; - } else if (widget.employee.checkOut != null && isOldCheckout) { + } else if (widget.employee.checkOut != null && isOldCheckOut) { action = 2; actionText = ButtonActions.requestRegularize; } else { @@ -120,10 +123,12 @@ class _AttendanceActionButtonState extends State { actionText = ButtonActions.checkOut; } break; + case 2: action = 2; actionText = ButtonActions.requestRegularize; break; + default: action = 0; actionText = "Unknown Action"; @@ -148,25 +153,26 @@ class _AttendanceActionButtonState extends State { } } - final comment = await _showCommentBottomSheet(context, actionText); + final comment = await _showCommentBottomSheet( + context, + actionText, + selectedTime: selectedTime, + checkInDate: widget.employee.checkIn, + ); if (comment == null || comment.isEmpty) { controller.uploadingStates[uniqueLogKey]?.value = false; return; } - bool success = false; String? markTime; - if (actionText == ButtonActions.requestRegularize) { selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!); - if (selectedTime != null) { - markTime = DateFormat("hh:mm a").format(selectedTime); - } + markTime = selectedTime != null ? DateFormat("hh:mm a").format(selectedTime) : null; } else if (selectedTime != null) { markTime = DateFormat("hh:mm a").format(selectedTime); } - success = await controller.captureAndUploadAttendance( + final success = await controller.captureAndUploadAttendance( widget.employee.id, widget.employee.employeeId, selectedProjectId, @@ -187,8 +193,8 @@ class _AttendanceActionButtonState extends State { controller.uploadingStates[uniqueLogKey]?.value = false; if (success) { - controller.fetchEmployeesByProject(selectedProjectId); - controller.fetchAttendanceLogs(selectedProjectId); + await controller.fetchEmployeesByProject(selectedProjectId); + await controller.fetchAttendanceLogs(selectedProjectId); await controller.fetchRegularizationLogs(selectedProjectId); await controller.fetchProjectData(selectedProjectId); controller.update(); @@ -199,13 +205,13 @@ class _AttendanceActionButtonState extends State { Widget build(BuildContext context) { return Obx(() { final controller = widget.attendanceController; - final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false; final emp = widget.employee; final isYesterday = AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut); final isTodayApproved = AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn); - final isApprovedButNotToday = AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved); + final isApprovedButNotToday = + AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved); final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled( isUploading: isUploading, @@ -283,7 +289,8 @@ class AttendanceActionButtonUI extends StatelessWidget { const Icon(Icons.close, size: 16, color: Colors.red), if (buttonText.toLowerCase() == 'requested') const Icon(Icons.hourglass_top, size: 16, color: Colors.orange), - if (['approved', 'rejected', 'requested'].contains(buttonText.toLowerCase())) + if (['approved', 'rejected', 'requested'] + .contains(buttonText.toLowerCase())) const SizedBox(width: 4), Flexible( child: Text( @@ -299,10 +306,22 @@ class AttendanceActionButtonUI extends StatelessWidget { } } -Future _showCommentBottomSheet(BuildContext context, String actionText) async { +Future _showCommentBottomSheet( + BuildContext context, + String actionText, { + DateTime? selectedTime, + DateTime? checkInDate, +}) async { final commentController = TextEditingController(); String? errorText; + // Prepare title + String sheetTitle = "Add Comment for ${capitalizeFirstLetter(actionText)}"; + if (selectedTime != null && checkInDate != null) { + sheetTitle = + "${capitalizeFirstLetter(actionText)} for ${DateFormat('dd MMM yyyy').format(checkInDate)} at ${DateFormat('hh:mm a').format(selectedTime)}"; + } + return showModalBottomSheet( context: context, isScrollControlled: true, @@ -325,33 +344,28 @@ Future _showCommentBottomSheet(BuildContext context, String actionText) return Padding( padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: BaseBottomSheet( - title: 'Add Comment for ${capitalizeFirstLetter(actionText)}', + title: sheetTitle, // 👈 now showing full sentence as title onCancel: () => Navigator.of(context).pop(), onSubmit: submit, isSubmitting: false, submitText: 'Submit', - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: commentController, - maxLines: 4, - decoration: InputDecoration( - hintText: 'Type your comment here...', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - filled: true, - fillColor: Colors.grey.shade100, - errorText: errorText, - ), - onChanged: (_) { - if (errorText != null) { - setModalState(() => errorText = null); - } - }, + child: TextField( + controller: commentController, + maxLines: 4, + decoration: InputDecoration( + hintText: 'Type your comment here...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), - ], + filled: true, + fillColor: Colors.grey.shade100, + errorText: errorText, + ), + onChanged: (_) { + if (errorText != null) { + setModalState(() => errorText = null); + } + }, ), ), ); @@ -361,5 +375,6 @@ Future _showCommentBottomSheet(BuildContext context, String actionText) ); } + String capitalizeFirstLetter(String text) => text.isEmpty ? text : text[0].toUpperCase() + text.substring(1); diff --git a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart index 84974ce..d1002f7 100644 --- a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart @@ -217,13 +217,32 @@ class _ReportActionBottomSheetState extends State Obx(() { if (!controller.showAddTaskCheckbox.value) return const SizedBox.shrink(); - return CheckboxListTile( - title: MyText.titleSmall("Add new task", fontWeight: 600), - value: controller.isAddTaskChecked.value, - onChanged: (val) => - controller.isAddTaskChecked.value = val ?? false, - controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, + return Theme( + data: Theme.of(context).copyWith( + checkboxTheme: CheckboxThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + side: const BorderSide( + color: Colors.black, width: 2), + fillColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return Colors.blueAccent; + } + return Colors.white; + }), + checkColor: + MaterialStateProperty.all(Colors.white), + ), + ), + child: CheckboxListTile( + title: MyText.titleSmall("Add new task", fontWeight: 600), + value: controller.isAddTaskChecked.value, + onChanged: (val) => + controller.isAddTaskChecked.value = val ?? false, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), ); }), diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index d18e3b4..2c3e43b 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -1,16 +1,20 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; -import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; -import 'package:url_launcher/url_launcher_string.dart'; +import 'package:marco/helpers/utils/validators.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; +import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; +/// Show bottom sheet wrapper Future showAddExpenseBottomSheet({ bool isEdit = false, Map? existingExpense, @@ -24,6 +28,7 @@ Future showAddExpenseBottomSheet({ ); } +/// Bottom sheet widget class _AddExpenseBottomSheet extends StatefulWidget { final bool isEdit; final Map? existingExpense; @@ -39,17 +44,21 @@ class _AddExpenseBottomSheet extends StatefulWidget { class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { final AddExpenseController controller = Get.put(AddExpenseController()); + final _formKey = GlobalKey(); + final GlobalKey _projectDropdownKey = GlobalKey(); final GlobalKey _expenseTypeDropdownKey = GlobalKey(); final GlobalKey _paymentModeDropdownKey = GlobalKey(); - void _showEmployeeList() async { + + /// Show employee list + Future _showEmployeeList() async { await showModalBottomSheet( context: context, isScrollControlled: true, + backgroundColor: Colors.transparent, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - backgroundColor: Colors.transparent, builder: (_) => ReusableEmployeeSelectorBottomSheet( searchController: controller.employeeSearchController, searchResults: controller.employeeSearchResults, @@ -59,16 +68,16 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ), ); - // Optional cleanup controller.employeeSearchController.clear(); controller.employeeSearchResults.clear(); } + /// Generic option list Future _showOptionList( List options, String Function(T) getLabel, ValueChanged onSelected, - GlobalKey triggerKey, // add this param + GlobalKey triggerKey, ) async { final RenderBox button = triggerKey.currentContext!.findRenderObject() as RenderBox; @@ -87,9 +96,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), items: options .map( - (option) => PopupMenuItem( - value: option, - child: Text(getLabel(option)), + (opt) => PopupMenuItem( + value: opt, + child: Text(getLabel(opt)), ), ) .toList(), @@ -100,222 +109,300 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { @override Widget build(BuildContext context) { - return Obx(() { - return BaseBottomSheet( - title: widget.isEdit ? "Edit Expense" : "Add Expense", - isSubmitting: controller.isSubmitting.value, - onCancel: Get.back, - onSubmit: () { - if (!controller.isSubmitting.value) { - controller.submitOrUpdateExpense(); - } - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDropdown( - icon: Icons.work_outline, - title: "Project", - requiredField: true, - value: controller.selectedProject.value.isEmpty - ? "Select Project" - : controller.selectedProject.value, - onTap: () => _showOptionList( - controller.globalProjects.toList(), - (p) => p, - (val) => controller.selectedProject.value = val, - _projectDropdownKey, // pass the relevant GlobalKey here - ), - dropdownKey: _projectDropdownKey, // pass key also here - ), - MySpacing.height(16), - _buildDropdown( - icon: Icons.category_outlined, - title: "Expense Type", - requiredField: true, - value: controller.selectedExpenseType.value?.name ?? - "Select Expense Type", - onTap: () => _showOptionList( - controller.expenseTypes.toList(), - (e) => e.name, - (val) => controller.selectedExpenseType.value = val, - _expenseTypeDropdownKey, - ), - dropdownKey: _expenseTypeDropdownKey, - ), - if (controller.selectedExpenseType.value?.noOfPersonsRequired == - true) - Padding( - padding: const EdgeInsets.only(top: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle( + return Obx( + () => Form( + key: _formKey, + child: BaseBottomSheet( + title: widget.isEdit ? "Edit Expense" : "Add Expense", + isSubmitting: controller.isSubmitting.value, + onCancel: Get.back, + onSubmit: () { + if (_formKey.currentState!.validate()) { + controller.submitOrUpdateExpense(); + } + }, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 🔹 Project + _buildDropdown( + icon: Icons.work_outline, + title: "Project", + requiredField: true, + value: controller.selectedProject.value.isEmpty + ? "Select Project" + : controller.selectedProject.value, + onTap: () => _showOptionList( + controller.globalProjects.toList(), + (p) => p, + (val) => controller.selectedProject.value = val, + _projectDropdownKey, + ), + dropdownKey: _projectDropdownKey, + ), + + MySpacing.height(16), + + // 🔹 Expense Type + _buildDropdown( + icon: Icons.category_outlined, + title: "Expense Type", + requiredField: true, + value: controller.selectedExpenseType.value?.name ?? + "Select Expense Type", + onTap: () => _showOptionList( + controller.expenseTypes.toList(), + (e) => e.name, + (val) => controller.selectedExpenseType.value = val, + _expenseTypeDropdownKey, + ), + dropdownKey: _expenseTypeDropdownKey, + ), + + // 🔹 Persons if required + if (controller.selectedExpenseType.value?.noOfPersonsRequired == + true) ...[ + MySpacing.height(16), + _SectionTitle( icon: Icons.people_outline, title: "No. of Persons", - requiredField: true, - ), - MySpacing.height(6), - _CustomTextField( - controller: controller.noOfPersonsController, - hint: "Enter No. of Persons", - keyboardType: TextInputType.number, - ), - ], + requiredField: true), + MySpacing.height(6), + _CustomTextField( + controller: controller.noOfPersonsController, + hint: "Enter No. of Persons", + keyboardType: TextInputType.number, + validator: Validators.requiredField, + ), + ], + + MySpacing.height(16), + + // 🔹 GST + _SectionTitle( + icon: Icons.confirmation_number_outlined, title: "GST No."), + MySpacing.height(6), + _CustomTextField( + controller: controller.gstController, + hint: "Enter GST No.", + validator: (value) { + if (value != null && value.isNotEmpty) { + return Validators.gstValidator(value); + } + return null; + }, ), - ), - MySpacing.height(16), - _SectionTitle( - icon: Icons.confirmation_number_outlined, title: "GST No."), - MySpacing.height(6), - _CustomTextField( - controller: controller.gstController, hint: "Enter GST No."), - MySpacing.height(16), - _buildDropdown( - icon: Icons.payment, - title: "Payment Mode", - requiredField: true, - value: controller.selectedPaymentMode.value?.name ?? - "Select Payment Mode", - onTap: () => _showOptionList( - controller.paymentModes.toList(), - (p) => p.name, - (val) => controller.selectedPaymentMode.value = val, - _paymentModeDropdownKey, - ), - dropdownKey: _paymentModeDropdownKey, - ), - MySpacing.height(16), - _SectionTitle( - icon: Icons.person_outline, - title: "Paid By", - requiredField: true), - MySpacing.height(6), - GestureDetector( - onTap: _showEmployeeList, - child: _TileContainer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - controller.selectedPaidBy.value == null - ? "Select Paid By" - : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', - style: const TextStyle(fontSize: 14), - ), - const Icon(Icons.arrow_drop_down, size: 22), - ], + + MySpacing.height(16), + + // 🔹 Payment Mode + _buildDropdown( + icon: Icons.payment, + title: "Payment Mode", + requiredField: true, + value: controller.selectedPaymentMode.value?.name ?? + "Select Payment Mode", + onTap: () => _showOptionList( + controller.paymentModes.toList(), + (p) => p.name, + (val) => controller.selectedPaymentMode.value = val, + _paymentModeDropdownKey, + ), + dropdownKey: _paymentModeDropdownKey, ), - ), - ), - MySpacing.height(16), - _SectionTitle( - icon: Icons.currency_rupee, - title: "Amount", - requiredField: true), - MySpacing.height(6), - _CustomTextField( - controller: controller.amountController, - hint: "Enter Amount", - keyboardType: TextInputType.number, - ), - MySpacing.height(16), - _SectionTitle( - icon: Icons.store_mall_directory_outlined, - title: "Supplier Name", - requiredField: true, - ), - MySpacing.height(6), - _CustomTextField( - controller: controller.supplierController, - hint: "Enter Supplier Name"), - MySpacing.height(16), - _SectionTitle( - icon: Icons.confirmation_number_outlined, - title: "Transaction ID"), - MySpacing.height(6), - _CustomTextField( - controller: controller.transactionIdController, - hint: "Enter Transaction ID"), - MySpacing.height(16), - _SectionTitle( - icon: Icons.calendar_today, - title: "Transaction Date", - requiredField: true, - ), - MySpacing.height(6), - GestureDetector( - onTap: () => controller.pickTransactionDate(context), - child: AbsorbPointer( - child: _CustomTextField( - controller: controller.transactionDateController, - hint: "Select Transaction Date", - ), - ), - ), - MySpacing.height(16), - _SectionTitle(icon: Icons.location_on_outlined, title: "Location"), - MySpacing.height(6), - TextField( - controller: controller.locationController, - decoration: InputDecoration( - hintText: "Enter Location", - filled: true, - fillColor: Colors.grey.shade100, - border: - OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - suffixIcon: controller.isFetchingLocation.value - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), + + MySpacing.height(16), + + // 🔹 Paid By + _SectionTitle( + icon: Icons.person_outline, + title: "Paid By", + requiredField: true), + MySpacing.height(6), + GestureDetector( + onTap: _showEmployeeList, + child: _TileContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + controller.selectedPaidBy.value == null + ? "Select Paid By" + : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', + style: const TextStyle(fontSize: 14), ), - ) - : IconButton( - icon: const Icon(Icons.my_location), - tooltip: "Use Current Location", - onPressed: controller.fetchCurrentLocation, + const Icon(Icons.arrow_drop_down, size: 22), + ], + ), + ), + ), + + MySpacing.height(16), + + // 🔹 Amount + _SectionTitle( + icon: Icons.currency_rupee, + title: "Amount", + requiredField: true), + MySpacing.height(6), + _CustomTextField( + controller: controller.amountController, + hint: "Enter Amount", + keyboardType: TextInputType.number, + validator: (v) => Validators.isNumeric(v ?? "") + ? null + : "Enter valid amount", + ), + + MySpacing.height(16), + + // 🔹 Supplier + _SectionTitle( + icon: Icons.store_mall_directory_outlined, + title: "Supplier Name/Transporter Name/Other", + requiredField: true, + ), + MySpacing.height(6), + _CustomTextField( + controller: controller.supplierController, + hint: "Enter Supplier Name/Transporter Name or Other", + validator: Validators.nameValidator, + ), + + MySpacing.height(16), + + // 🔹 Transaction ID + _SectionTitle( + icon: Icons.confirmation_number_outlined, + title: "Transaction ID"), + MySpacing.height(6), + _CustomTextField( + controller: controller.transactionIdController, + hint: "Enter Transaction ID", + validator: (value) { + if (value != null && value.isNotEmpty) { + return Validators.transactionIdValidator(value); + } + return null; + }, + ), + + MySpacing.height(16), + + // 🔹 Transaction Date + _SectionTitle( + icon: Icons.calendar_today, + title: "Transaction Date", + requiredField: true), + MySpacing.height(6), + GestureDetector( + onTap: () => controller.pickTransactionDate(context), + child: AbsorbPointer( + child: _CustomTextField( + controller: controller.transactionDateController, + hint: "Select Transaction Date", + validator: Validators.requiredField, + ), + ), + ), + + MySpacing.height(16), + + // 🔹 Location + _SectionTitle( + icon: Icons.location_on_outlined, title: "Location"), + MySpacing.height(6), + TextFormField( + controller: controller.locationController, + decoration: InputDecoration( + hintText: "Enter Location", + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + suffixIcon: controller.isFetchingLocation.value + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.my_location), + tooltip: "Use Current Location", + onPressed: controller.fetchCurrentLocation, + ), + ), + ), + + MySpacing.height(16), + + // 🔹 Attachments + _SectionTitle( + icon: Icons.attach_file, + title: "Attachments", + requiredField: true), + MySpacing.height(6), + _AttachmentsSection( + attachments: controller.attachments, + existingAttachments: controller.existingAttachments, + onRemoveNew: controller.removeAttachment, + onRemoveExisting: (item) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => ConfirmDialog( + title: "Remove Attachment", + message: + "Are you sure you want to remove this attachment?", + confirmText: "Remove", + icon: Icons.delete, + confirmColor: Colors.redAccent, + onConfirm: () async { + final index = + controller.existingAttachments.indexOf(item); + if (index != -1) { + controller.existingAttachments[index]['isActive'] = + false; + controller.existingAttachments.refresh(); + } + showAppSnackbar( + title: 'Removed', + message: 'Attachment has been removed.', + type: SnackbarType.success, + ); + }, ), - ), + ); + }, + onAdd: controller.pickAttachments, + ), + + MySpacing.height(16), + + // 🔹 Description + _SectionTitle( + icon: Icons.description_outlined, + title: "Description", + requiredField: true), + MySpacing.height(6), + _CustomTextField( + controller: controller.descriptionController, + hint: "Enter Description", + maxLines: 3, + validator: Validators.requiredField, + ), + ], ), - MySpacing.height(16), - _SectionTitle( - icon: Icons.attach_file, - title: "Attachments", - requiredField: true), - MySpacing.height(6), - _AttachmentsSection( - attachments: controller.attachments, - existingAttachments: controller.existingAttachments, - onRemoveNew: controller.removeAttachment, - onRemoveExisting: (item) { - final index = controller.existingAttachments.indexOf(item); - if (index != -1) { - controller.existingAttachments[index]['isActive'] = false; - controller.existingAttachments.refresh(); - } - }, - onAdd: controller.pickAttachments, - ), - MySpacing.height(16), - _SectionTitle( - icon: Icons.description_outlined, - title: "Description", - requiredField: true), - MySpacing.height(6), - _CustomTextField( - controller: controller.descriptionController, - hint: "Enter Description", - maxLines: 3, - ), - ], + ), ), - ); - }); + ), + ); } Widget _buildDropdown({ @@ -324,18 +411,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { required bool requiredField, required String value, required VoidCallback onTap, - required GlobalKey dropdownKey, // new param + required GlobalKey dropdownKey, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle(icon: icon, title: title, requiredField: requiredField), MySpacing.height(6), - _DropdownTile( - key: dropdownKey, // Pass the key here - title: value, - onTap: onTap, - ), + _DropdownTile(key: dropdownKey, title: value, onTap: onTap), ], ); } @@ -386,20 +469,23 @@ class _CustomTextField extends StatelessWidget { final String hint; final int maxLines; final TextInputType keyboardType; + final String? Function(String?)? validator; // 👈 for validation const _CustomTextField({ required this.controller, required this.hint, this.maxLines = 1, this.keyboardType = TextInputType.text, + this.validator, }); @override Widget build(BuildContext context) { - return TextField( + return TextFormField( controller: controller, maxLines: maxLines, keyboardType: keyboardType, + validator: validator, // 👈 applied decoration: InputDecoration( hintText: hint, hintStyle: TextStyle(fontSize: 14, color: Colors.grey[600]), @@ -503,6 +589,21 @@ class _AttachmentsSection extends StatelessWidget { final activeExistingAttachments = existingAttachments.where((doc) => doc['isActive'] != false).toList(); + // Allowed image extensions for local files + final allowedImageExtensions = ['jpg', 'jpeg', 'png']; + + // To show all new attachments in UI but filter only images for dialog + final imageFiles = attachments.where((file) { + final extension = file.path.split('.').last.toLowerCase(); + return allowedImageExtensions.contains(extension); + }).toList(); + + // Filter existing attachments to only images (for dialog) + final imageExistingAttachments = activeExistingAttachments + .where((d) => + (d['contentType']?.toString().startsWith('image/') ?? false)) + .toList(); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -528,23 +629,22 @@ class _AttachmentsSection extends StatelessWidget { GestureDetector( onTap: () async { if (isImage) { - final imageDocs = activeExistingAttachments - .where((d) => (d['contentType'] - ?.toString() - .startsWith('image/') ?? - false)) + // Open dialog only with image attachments (URLs) + final imageSources = imageExistingAttachments + .map((e) => e['url']) .toList(); - final initialIndex = - imageDocs.indexWhere((d) => d == doc); + final initialIndex = imageExistingAttachments + .indexWhere((d) => d == doc); + showDialog( context: context, builder: (_) => ImageViewerDialog( - imageSources: - imageDocs.map((e) => e['url']).toList(), + imageSources: imageSources, initialIndex: initialIndex, ), ); } else { + // Open non-image attachment externally or show error if (url != null && await canLaunchUrlString(url)) { await launchUrlString( url, @@ -607,15 +707,42 @@ class _AttachmentsSection extends StatelessWidget { const SizedBox(height: 16), ], - // New attachments section + // New attachments section: show all files, but only open dialog for images Wrap( spacing: 8, runSpacing: 8, children: [ - ...attachments.map((file) => _AttachmentTile( + ...attachments.map((file) { + final extension = file.path.split('.').last.toLowerCase(); + final isImage = allowedImageExtensions.contains(extension); + + return GestureDetector( + onTap: () { + if (isImage) { + // Show dialog only for image files + final initialIndex = imageFiles.indexOf(file); + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: imageFiles, + initialIndex: initialIndex, + ), + ); + } else { + // For non-image, you can show snackbar or do nothing or handle differently + showAppSnackbar( + title: 'Info', + message: 'Preview for this file type is not supported.', + type: SnackbarType.info, + ); + } + }, + child: _AttachmentTile( file: file, onRemove: () => onRemoveNew(file), - )), + ), + ); + }), GestureDetector( onTap: onAdd, child: Container( diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 6ca157f..0c404eb 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -72,7 +72,7 @@ class _ExpenseMainScreenState extends State { e.transactionDate.year == now.year) .toList(); } - + @override Widget build(BuildContext context) { return Scaffold(