Refactor expense deletion confirmation dialog and add validation to add expense form

- Replaced the custom delete confirmation dialog with a reusable ConfirmDialog widget for better code organization and reusability.
- Improved the add expense bottom sheet by implementing form validation using a GlobalKey and TextFormField.
- Enhanced user experience by adding validation for required fields and specific formats (e.g., GST, transaction ID).
- Updated the expense list to reflect changes in the confirmation dialog and improved the handling of attachments.
- Cleaned up code by removing unnecessary comments and ensuring consistent formatting.
This commit is contained in:
Vaibhav Surve 2025-08-22 16:31:48 +05:30
parent 3ba3129b18
commit 311002f3ba
16 changed files with 1167 additions and 447 deletions

View File

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

View File

@ -10,7 +10,7 @@
<application
android:label="Marco"
android:label="Marco_Stage"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity

View File

@ -6,6 +6,7 @@ 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';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/firebase/firebase_messaging_service.dart';
class MPINController extends GetxController {
final MyFormValidator basicValidator = MyFormValidator();
@ -42,7 +43,8 @@ class MPINController extends GetxController {
/// Handle digit entry and focus movement
void onDigitChanged(String value, int index, {bool isRetype = false}) {
logSafe("onDigitChanged -> 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,

View File

@ -49,7 +49,8 @@ class DailyTaskController extends GetxController {
Future<void> 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
}
}

View File

@ -181,15 +181,27 @@ class AddExpenseController extends GetxController {
// --- Pickers ---
Future<void> 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);
}
}

View File

@ -15,6 +15,31 @@ class AuthService {
};
static bool isLoggedIn = false;
/* -------------------------------------------------------------------------- */
/* Logout API */
/* -------------------------------------------------------------------------- */
static Future<bool> 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<Map<String, String>?> 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,
);

View File

@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> data) {
final projectId = data['ProjectId'];
if (projectId == null) {
_logger.w("⚠️ TaskPlanning update received without ProjectId: $data");
return;
}
_safeControllerUpdate<DailyTaskPlaningController>(
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<String, dynamic> data) {
final expenseId = data['ExpenseId'];
if (expenseId == null) {
_logger.w("⚠️ Expense update received without ExpenseId: $data");
return;
}
// Update Expense List
_safeControllerUpdate<ExpenseController>(
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<ExpenseDetailController>(
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<String, dynamic> data) {
_safeControllerUpdate<AttendanceController>(
onFound: (controller) => controller.refreshDataFromNotification(
projectId: data['ProjectId'],
),
notFoundMessage: '⚠️ AttendanceController not found, cannot update.',
successMessage: '✅ AttendanceController refreshed from notification.',
);
}
static void _handleTaskUpdated(Map<String, dynamic> data,
{required bool isComment}) {
_safeControllerUpdate<DailyTaskController>(
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<T>({
required void Function(T controller) onFound,
required String notFoundMessage,
required String successMessage,
}) {
try {
final controller = Get.find<AttendanceController>();
controller.refreshDataFromNotification(
projectId: data['ProjectId'],
);
_logger.i('✅ AttendanceController refreshed from notification.');
final controller = Get.find<T>();
onFound(controller);
_logger.i(successMessage);
} catch (e) {
_logger.w('⚠️ AttendanceController not found, cannot update.');
_logger.w(notFoundMessage);
}
}
}

View File

@ -36,26 +36,22 @@ class LocalStorage {
}
static Future<void> 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<bool> setUserPermissions(
List<UserPermission> permissions) async {
final jsonList = permissions.map((e) => e.toJson()).toList();
return preferences.setString(
_userPermissionsKey, jsonEncode(jsonList));
return preferences.setString(_userPermissionsKey, jsonEncode(jsonList));
}
static List<UserPermission> getUserPermissions() {
final storedJson = preferences.getString(_userPermissionsKey);
if (storedJson == null) return [];
return (jsonDecode(storedJson) as List)
.map((e) => UserPermission.fromJson(
e as Map<String, dynamic>))
.map((e) => UserPermission.fromJson(e as Map<String, dynamic>))
.toList();
}
@ -63,9 +59,8 @@ class LocalStorage {
preferences.remove(_userPermissionsKey);
/// ================== Employee Info ==================
static Future<bool> setEmployeeInfo(EmployeeInfo employeeInfo) =>
preferences.setString(
_employeeInfoKey, jsonEncode(employeeInfo.toJson()));
static Future<bool> 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<void> 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<bool> setCustomizer(
ThemeCustomizer themeCustomizer) =>
preferences.setString(
_themeCustomizerKey, themeCustomizer.toJSON());
static Future<bool> setCustomizer(ThemeCustomizer themeCustomizer) =>
preferences.setString(_themeCustomizerKey, themeCustomizer.toJSON());
static Future<bool> 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<bool> 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<bool> removeToken(String key) =>
preferences.remove(key);
static Future<bool> removeToken(String key) => preferences.remove(key);
static Future<bool> 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<void> setFcmToken(String token) =>
preferences.setString(_fcmTokenKey, token);
static String? getFcmToken() =>
preferences.getString(_fcmTokenKey);
static String? getFcmToken() => preferences.getString(_fcmTokenKey);
/// ================== MPIN ==================
static Future<bool> setMpinToken(String token) =>
preferences.setString(_mpinTokenKey, token);
static String? getMpinToken() =>
preferences.getString(_mpinTokenKey);
static String? getMpinToken() => preferences.getString(_mpinTokenKey);
static Future<bool> removeMpinToken() =>
preferences.remove(_mpinTokenKey);
static Future<bool> removeMpinToken() => preferences.remove(_mpinTokenKey);
static Future<bool> setIsMpin(bool value) =>
preferences.setBool(_isMpinKey, value);
static bool getIsMpin() =>
preferences.getBool(_isMpinKey) ?? false;
static bool getIsMpin() => preferences.getBool(_isMpinKey) ?? false;
static Future<bool> removeIsMpin() =>
preferences.remove(_isMpinKey);
static Future<bool> removeIsMpin() => preferences.remove(_isMpinKey);
/// ================== Generic Set/Get ==================
static Future<bool> 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<bool> saveString(String key, String value) =>
preferences.setString(key, value);

View File

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

View File

@ -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 19)
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 (918 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, 836 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 (836 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 (918 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<List<int>> _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<List<int>> _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);
}

View File

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

View File

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

View File

@ -66,7 +66,9 @@ class _AttendanceActionButtonState extends State<AttendanceActionButton> {
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<AttendanceActionButton> {
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<AttendanceActionButton> {
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<AttendanceActionButton> {
}
}
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<AttendanceActionButton> {
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<AttendanceActionButton> {
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<String?> _showCommentBottomSheet(BuildContext context, String actionText) async {
Future<String?> _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<String>(
context: context,
isScrollControlled: true,
@ -325,33 +344,28 @@ Future<String?> _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<String?> _showCommentBottomSheet(BuildContext context, String actionText)
);
}
String capitalizeFirstLetter(String text) =>
text.isEmpty ? text : text[0].toUpperCase() + text.substring(1);

View File

@ -217,13 +217,32 @@ class _ReportActionBottomSheetState extends State<ReportActionBottomSheet>
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<Color>((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,
),
);
}),

View File

@ -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<T?> showAddExpenseBottomSheet<T>({
bool isEdit = false,
Map<String, dynamic>? existingExpense,
@ -24,6 +28,7 @@ Future<T?> showAddExpenseBottomSheet<T>({
);
}
/// Bottom sheet widget
class _AddExpenseBottomSheet extends StatefulWidget {
final bool isEdit;
final Map<String, dynamic>? existingExpense;
@ -39,17 +44,21 @@ class _AddExpenseBottomSheet extends StatefulWidget {
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
final AddExpenseController controller = Get.put(AddExpenseController());
final _formKey = GlobalKey<FormState>();
final GlobalKey _projectDropdownKey = GlobalKey();
final GlobalKey _expenseTypeDropdownKey = GlobalKey();
final GlobalKey _paymentModeDropdownKey = GlobalKey();
void _showEmployeeList() async {
/// Show employee list
Future<void> _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<void> _showOptionList<T>(
List<T> options,
String Function(T) getLabel,
ValueChanged<T> 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<T>(
value: option,
child: Text(getLabel(option)),
(opt) => PopupMenuItem<T>(
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<String>(
icon: Icons.work_outline,
title: "Project",
requiredField: true,
value: controller.selectedProject.value.isEmpty
? "Select Project"
: controller.selectedProject.value,
onTap: () => _showOptionList<String>(
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<ExpenseTypeModel>(
icon: Icons.category_outlined,
title: "Expense Type",
requiredField: true,
value: controller.selectedExpenseType.value?.name ??
"Select Expense Type",
onTap: () => _showOptionList<ExpenseTypeModel>(
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<String>(
icon: Icons.work_outline,
title: "Project",
requiredField: true,
value: controller.selectedProject.value.isEmpty
? "Select Project"
: controller.selectedProject.value,
onTap: () => _showOptionList<String>(
controller.globalProjects.toList(),
(p) => p,
(val) => controller.selectedProject.value = val,
_projectDropdownKey,
),
dropdownKey: _projectDropdownKey,
),
MySpacing.height(16),
// 🔹 Expense Type
_buildDropdown<ExpenseTypeModel>(
icon: Icons.category_outlined,
title: "Expense Type",
requiredField: true,
value: controller.selectedExpenseType.value?.name ??
"Select Expense Type",
onTap: () => _showOptionList<ExpenseTypeModel>(
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<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
value: controller.selectedPaymentMode.value?.name ??
"Select Payment Mode",
onTap: () => _showOptionList<PaymentModeModel>(
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<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
value: controller.selectedPaymentMode.value?.name ??
"Select Payment Mode",
onTap: () => _showOptionList<PaymentModeModel>(
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<T>({
@ -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(

View File

@ -72,7 +72,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
e.transactionDate.year == now.year)
.toList();
}
@override
Widget build(BuildContext context) {
return Scaffold(