Refactor Attendance Logs and Regularization Requests Tabs
- Changed AttendanceLogsTab from StatelessWidget to StatefulWidget to manage state for showing pending actions. - Added a status header in AttendanceLogsTab to indicate when only pending actions are displayed. - Updated filtering logic in AttendanceLogsTab to use filteredLogs based on the pending actions toggle. - Refactored AttendanceScreen to include a search bar for filtering attendance logs by name. - Introduced a new filter icon in AttendanceScreen for accessing the filter options. - Updated RegularizationRequestsTab to use filteredRegularizationLogs for displaying requests. - Modified TodaysAttendanceTab to utilize filteredEmployees for showing today's attendance. - Cleaned up code formatting and improved readability across various files.
This commit is contained in:
parent
2517f2360e
commit
8fb725a5cf
@ -39,6 +39,7 @@ class AttendanceController extends GetxController {
|
||||
final isLoadingRegularizationLogs = true.obs;
|
||||
final isLoadingLogView = true.obs;
|
||||
final uploadingStates = <String, RxBool>{}.obs;
|
||||
var showPendingOnly = false.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
@ -73,6 +74,36 @@ class AttendanceController extends GetxController {
|
||||
"Attendance data refreshed from notification for project $projectId");
|
||||
}
|
||||
|
||||
// 🔍 Search query
|
||||
final searchQuery = ''.obs;
|
||||
|
||||
// Computed filtered employees
|
||||
List<EmployeeModel> get filteredEmployees {
|
||||
if (searchQuery.value.isEmpty) return employees;
|
||||
return employees
|
||||
.where((e) =>
|
||||
e.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Computed filtered logs
|
||||
List<AttendanceLogModel> get filteredLogs {
|
||||
if (searchQuery.value.isEmpty) return attendanceLogs;
|
||||
return attendanceLogs
|
||||
.where((log) =>
|
||||
(log.name).toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Computed filtered regularization logs
|
||||
List<RegularizationLogModel> get filteredRegularizationLogs {
|
||||
if (searchQuery.value.isEmpty) return regularizationLogs;
|
||||
return regularizationLogs
|
||||
.where((log) =>
|
||||
log.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> fetchProjects() async {
|
||||
isLoadingProjects.value = true;
|
||||
|
||||
|
@ -7,6 +7,8 @@ import 'package:get/get.dart';
|
||||
import 'package:geocoding/geocoding.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
@ -15,21 +17,30 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/model/employees/employee_model.dart';
|
||||
import 'package:marco/model/expense/expense_type_model.dart';
|
||||
import 'package:marco/model/expense/payment_types_model.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
class AddExpenseController extends GetxController {
|
||||
// --- Text Controllers ---
|
||||
final amountController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
final supplierController = TextEditingController();
|
||||
final transactionIdController = TextEditingController();
|
||||
final gstController = TextEditingController();
|
||||
final locationController = TextEditingController();
|
||||
final transactionDateController = TextEditingController();
|
||||
final noOfPersonsController = TextEditingController();
|
||||
final controllers = <TextEditingController>[
|
||||
TextEditingController(), // amount
|
||||
TextEditingController(), // description
|
||||
TextEditingController(), // supplier
|
||||
TextEditingController(), // transactionId
|
||||
TextEditingController(), // gst
|
||||
TextEditingController(), // location
|
||||
TextEditingController(), // transactionDate
|
||||
TextEditingController(), // noOfPersons
|
||||
TextEditingController(), // employeeSearch
|
||||
];
|
||||
|
||||
final employeeSearchController = TextEditingController();
|
||||
TextEditingController get amountController => controllers[0];
|
||||
TextEditingController get descriptionController => controllers[1];
|
||||
TextEditingController get supplierController => controllers[2];
|
||||
TextEditingController get transactionIdController => controllers[3];
|
||||
TextEditingController get gstController => controllers[4];
|
||||
TextEditingController get locationController => controllers[5];
|
||||
TextEditingController get transactionDateController => controllers[6];
|
||||
TextEditingController get noOfPersonsController => controllers[7];
|
||||
TextEditingController get employeeSearchController => controllers[8];
|
||||
|
||||
// --- Reactive State ---
|
||||
final isLoading = false.obs;
|
||||
@ -59,29 +70,19 @@ class AddExpenseController extends GetxController {
|
||||
|
||||
final expenseController = Get.find<ExpenseController>();
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
fetchMasterData();
|
||||
fetchGlobalProjects();
|
||||
employeeSearchController.addListener(() {
|
||||
searchEmployees(employeeSearchController.text);
|
||||
});
|
||||
loadMasterData();
|
||||
employeeSearchController.addListener(
|
||||
() => searchEmployees(employeeSearchController.text),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
for (var c in [
|
||||
amountController,
|
||||
descriptionController,
|
||||
supplierController,
|
||||
transactionIdController,
|
||||
gstController,
|
||||
locationController,
|
||||
transactionDateController,
|
||||
noOfPersonsController,
|
||||
employeeSearchController,
|
||||
]) {
|
||||
for (var c in controllers) {
|
||||
c.dispose();
|
||||
}
|
||||
super.onClose();
|
||||
@ -92,11 +93,19 @@ class AddExpenseController extends GetxController {
|
||||
if (query.trim().isEmpty) return employeeSearchResults.clear();
|
||||
isSearchingEmployees.value = true;
|
||||
try {
|
||||
final data =
|
||||
await ApiService.searchEmployeesBasic(searchString: query.trim());
|
||||
employeeSearchResults.assignAll(
|
||||
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
|
||||
final data = await ApiService.searchEmployeesBasic(
|
||||
searchString: query.trim(),
|
||||
);
|
||||
|
||||
if (data is List) {
|
||||
employeeSearchResults.assignAll(
|
||||
data
|
||||
.map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
} else {
|
||||
employeeSearchResults.clear();
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Error searching employees: $e", level: LogLevel.error);
|
||||
employeeSearchResults.clear();
|
||||
@ -105,64 +114,77 @@ class AddExpenseController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Form Population: Edit Mode ---
|
||||
// --- Form Population (Edit) ---
|
||||
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
|
||||
isEditMode.value = true;
|
||||
editingExpenseId = '${data['id']}';
|
||||
|
||||
selectedProject.value = data['projectName'] ?? '';
|
||||
amountController.text = data['amount']?.toString() ?? '';
|
||||
amountController.text = '${data['amount'] ?? ''}';
|
||||
supplierController.text = data['supplerName'] ?? '';
|
||||
descriptionController.text = data['description'] ?? '';
|
||||
transactionIdController.text = data['transactionId'] ?? '';
|
||||
locationController.text = data['location'] ?? '';
|
||||
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
|
||||
noOfPersonsController.text = '${data['noOfPersons'] ?? 0}';
|
||||
|
||||
// Transaction Date
|
||||
if (data['transactionDate'] != null) {
|
||||
_setTransactionDate(data['transactionDate']);
|
||||
_setDropdowns(data);
|
||||
await _setPaidBy(data);
|
||||
_setAttachments(data['attachments']);
|
||||
|
||||
_logPrefilledData();
|
||||
}
|
||||
|
||||
void _setTransactionDate(dynamic dateStr) {
|
||||
if (dateStr == null) {
|
||||
selectedTransactionDate.value = null;
|
||||
transactionDateController.clear();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final parsed = DateTime.parse(data['transactionDate']);
|
||||
final parsed = DateTime.parse(dateStr);
|
||||
selectedTransactionDate.value = parsed;
|
||||
transactionDateController.text =
|
||||
DateFormat('dd-MM-yyyy').format(parsed);
|
||||
transactionDateController.text = DateFormat('dd-MM-yyyy').format(parsed);
|
||||
} catch (_) {
|
||||
selectedTransactionDate.value = null;
|
||||
transactionDateController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown
|
||||
void _setDropdowns(Map<String, dynamic> data) {
|
||||
selectedExpenseType.value =
|
||||
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
|
||||
selectedPaymentMode.value =
|
||||
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
|
||||
}
|
||||
|
||||
// Paid By
|
||||
Future<void> _setPaidBy(Map<String, dynamic> data) async {
|
||||
final paidById = '${data['paidById']}';
|
||||
selectedPaidBy.value =
|
||||
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
|
||||
|
||||
if (selectedPaidBy.value == null && data['paidByFirstName'] != null) {
|
||||
await searchEmployees(
|
||||
'${data['paidByFirstName']} ${data['paidByLastName']}');
|
||||
'${data['paidByFirstName']} ${data['paidByLastName']}',
|
||||
);
|
||||
selectedPaidBy.value = employeeSearchResults
|
||||
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
|
||||
}
|
||||
|
||||
// Attachments
|
||||
existingAttachments.clear();
|
||||
if (data['attachments'] is List) {
|
||||
existingAttachments.addAll(
|
||||
List<Map<String, dynamic>>.from(data['attachments'])
|
||||
.map((e) => {...e, 'isActive': true}),
|
||||
);
|
||||
}
|
||||
|
||||
_logPrefilledData();
|
||||
void _setAttachments(dynamic attachmentsData) {
|
||||
existingAttachments.clear();
|
||||
if (attachmentsData is List) {
|
||||
existingAttachments.addAll(
|
||||
List<Map<String, dynamic>>.from(attachmentsData).map(
|
||||
(e) => {...e, 'isActive': true},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _logPrefilledData() {
|
||||
logSafe('--- Prefilled Expense Data ---', level: LogLevel.info);
|
||||
[
|
||||
final info = [
|
||||
'ID: $editingExpenseId',
|
||||
'Project: ${selectedProject.value}',
|
||||
'Amount: ${amountController.text}',
|
||||
@ -177,7 +199,10 @@ class AddExpenseController extends GetxController {
|
||||
'Paid By: ${selectedPaidBy.value?.name}',
|
||||
'Attachments: ${attachments.length}',
|
||||
'Existing Attachments: ${existingAttachments.length}',
|
||||
].forEach((str) => logSafe(str, level: LogLevel.info));
|
||||
];
|
||||
for (var line in info) {
|
||||
logSafe(line, level: LogLevel.info);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pickers ---
|
||||
@ -199,7 +224,6 @@ class AddExpenseController extends GetxController {
|
||||
now.minute,
|
||||
now.second,
|
||||
);
|
||||
|
||||
selectedTransactionDate.value = finalDateTime;
|
||||
transactionDateController.text =
|
||||
DateFormat('dd MMM yyyy').format(finalDateTime);
|
||||
@ -214,8 +238,9 @@ class AddExpenseController extends GetxController {
|
||||
allowMultiple: true,
|
||||
);
|
||||
if (result != null) {
|
||||
attachments
|
||||
.addAll(result.paths.whereType<String>().map((path) => File(path)));
|
||||
attachments.addAll(
|
||||
result.paths.whereType<String>().map(File.new),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_errorSnackbar("Attachment error: $e");
|
||||
@ -224,12 +249,20 @@ class AddExpenseController extends GetxController {
|
||||
|
||||
void removeAttachment(File file) => attachments.remove(file);
|
||||
|
||||
Future<void> pickFromCamera() async {
|
||||
try {
|
||||
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
|
||||
if (pickedFile != null) attachments.add(File(pickedFile.path));
|
||||
} catch (e) {
|
||||
_errorSnackbar("Camera error: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Location ---
|
||||
Future<void> fetchCurrentLocation() async {
|
||||
isFetchingLocation.value = true;
|
||||
try {
|
||||
final permission = await _ensureLocationPermission();
|
||||
if (!permission) return;
|
||||
if (!await _ensureLocationPermission()) return;
|
||||
|
||||
final position = await Geolocator.getCurrentPosition();
|
||||
final placemarks =
|
||||
@ -241,7 +274,7 @@ class AddExpenseController extends GetxController {
|
||||
placemarks.first.street,
|
||||
placemarks.first.locality,
|
||||
placemarks.first.administrativeArea,
|
||||
placemarks.first.country
|
||||
placemarks.first.country,
|
||||
].where((e) => e?.isNotEmpty == true).join(", ")
|
||||
: "${position.latitude}, ${position.longitude}";
|
||||
} catch (e) {
|
||||
@ -271,19 +304,23 @@ class AddExpenseController extends GetxController {
|
||||
|
||||
// --- Data Fetching ---
|
||||
Future<void> loadMasterData() async =>
|
||||
await Future.wait([fetchMasterData(), fetchGlobalProjects()]);
|
||||
Future.wait([fetchMasterData(), fetchGlobalProjects()]);
|
||||
|
||||
Future<void> fetchMasterData() async {
|
||||
try {
|
||||
final types = await ApiService.getMasterExpenseTypes();
|
||||
if (types is List)
|
||||
expenseTypes.value =
|
||||
types.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
||||
if (types is List) {
|
||||
expenseTypes.value = types
|
||||
.map((e) => ExpenseTypeModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
final modes = await ApiService.getMasterPaymentModes();
|
||||
if (modes is List)
|
||||
paymentModes.value =
|
||||
modes.map((e) => PaymentModeModel.fromJson(e)).toList();
|
||||
if (modes is List) {
|
||||
paymentModes.value = modes
|
||||
.map((e) => PaymentModeModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
} catch (_) {
|
||||
_errorSnackbar("Failed to fetch master data");
|
||||
}
|
||||
@ -295,8 +332,8 @@ class AddExpenseController extends GetxController {
|
||||
if (response != null) {
|
||||
final names = <String>[];
|
||||
for (var item in response) {
|
||||
final name = item['name']?.toString().trim(),
|
||||
id = item['id']?.toString().trim();
|
||||
final name = item['name']?.toString().trim();
|
||||
final id = item['id']?.toString().trim();
|
||||
if (name != null && id != null) {
|
||||
projectsMap[name] = id;
|
||||
names.add(name);
|
||||
@ -309,17 +346,6 @@ class AddExpenseController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pickFromCamera() async {
|
||||
try {
|
||||
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
|
||||
if (pickedFile != null) {
|
||||
attachments.add(File(pickedFile.path));
|
||||
}
|
||||
} catch (e) {
|
||||
_errorSnackbar("Camera error: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Submission ---
|
||||
Future<void> submitOrUpdateExpense() async {
|
||||
if (isSubmitting.value) return;
|
||||
@ -332,24 +358,7 @@ class AddExpenseController extends GetxController {
|
||||
}
|
||||
|
||||
final payload = await _buildExpensePayload();
|
||||
|
||||
final success = isEditMode.value && editingExpenseId != null
|
||||
? await ApiService.editExpenseApi(
|
||||
expenseId: editingExpenseId!, payload: payload)
|
||||
: await ApiService.createExpenseApi(
|
||||
projectId: payload['projectId'],
|
||||
expensesTypeId: payload['expensesTypeId'],
|
||||
paymentModeId: payload['paymentModeId'],
|
||||
paidById: payload['paidById'],
|
||||
transactionDate: DateTime.parse(payload['transactionDate']),
|
||||
transactionId: payload['transactionId'],
|
||||
description: payload['description'],
|
||||
location: payload['location'],
|
||||
supplerName: payload['supplerName'],
|
||||
amount: payload['amount'],
|
||||
noOfPersons: payload['noOfPersons'],
|
||||
billAttachments: payload['billAttachments'],
|
||||
);
|
||||
final success = await _submitToApi(payload);
|
||||
|
||||
if (success) {
|
||||
await expenseController.fetchExpenses();
|
||||
@ -370,14 +379,35 @@ class AddExpenseController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _submitToApi(Map<String, dynamic> payload) async {
|
||||
if (isEditMode.value && editingExpenseId != null) {
|
||||
return ApiService.editExpenseApi(
|
||||
expenseId: editingExpenseId!,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
return ApiService.createExpenseApi(
|
||||
projectId: payload['projectId'],
|
||||
expensesTypeId: payload['expensesTypeId'],
|
||||
paymentModeId: payload['paymentModeId'],
|
||||
paidById: payload['paidById'],
|
||||
transactionDate: DateTime.parse(payload['transactionDate']),
|
||||
transactionId: payload['transactionId'],
|
||||
description: payload['description'],
|
||||
location: payload['location'],
|
||||
supplerName: payload['supplerName'],
|
||||
amount: payload['amount'],
|
||||
noOfPersons: payload['noOfPersons'],
|
||||
billAttachments: payload['billAttachments'],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _buildExpensePayload() async {
|
||||
final now = DateTime.now();
|
||||
|
||||
// --- Existing Attachments Payload (for edit mode only) ---
|
||||
final List<Map<String, dynamic>> existingAttachmentPayloads =
|
||||
isEditMode.value
|
||||
final existingPayload = isEditMode.value
|
||||
? existingAttachments
|
||||
.map<Map<String, dynamic>>((e) => {
|
||||
.map((e) => {
|
||||
"documentId": e['documentId'],
|
||||
"fileName": e['fileName'],
|
||||
"contentType": e['contentType'],
|
||||
@ -385,46 +415,35 @@ class AddExpenseController extends GetxController {
|
||||
"description": "",
|
||||
"url": e['url'],
|
||||
"isActive": e['isActive'] ?? true,
|
||||
"base64Data": "", // <-- always empty now
|
||||
"base64Data": "",
|
||||
})
|
||||
.toList()
|
||||
: <Map<String, dynamic>>[];
|
||||
|
||||
// --- New Attachments Payload (always include if attachments exist) ---
|
||||
final List<Map<String, dynamic>> newAttachmentPayloads =
|
||||
attachments.isNotEmpty
|
||||
? await Future.wait(attachments.map((file) async {
|
||||
final newPayload = await Future.wait(
|
||||
attachments.map((file) async {
|
||||
final bytes = await file.readAsBytes();
|
||||
final length = await file.length();
|
||||
return <String, dynamic>{
|
||||
return {
|
||||
"fileName": file.path.split('/').last,
|
||||
"base64Data": base64Encode(bytes),
|
||||
"contentType":
|
||||
lookupMimeType(file.path) ?? 'application/octet-stream',
|
||||
"fileSize": length,
|
||||
"fileSize": await file.length(),
|
||||
"description": "",
|
||||
};
|
||||
}))
|
||||
: <Map<String, dynamic>>[];
|
||||
}),
|
||||
);
|
||||
|
||||
// --- Selected Expense Type ---
|
||||
final type = selectedExpenseType.value!;
|
||||
|
||||
// --- Combine all attachments ---
|
||||
final List<Map<String, dynamic>> combinedAttachments = [
|
||||
...existingAttachmentPayloads,
|
||||
...newAttachmentPayloads
|
||||
];
|
||||
|
||||
// --- Build Payload ---
|
||||
final payload = <String, dynamic>{
|
||||
return {
|
||||
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
|
||||
"projectId": projectsMap[selectedProject.value]!,
|
||||
"expensesTypeId": type.id,
|
||||
"paymentModeId": selectedPaymentMode.value!.id,
|
||||
"paidById": selectedPaidBy.value!.id,
|
||||
"transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc())
|
||||
.toIso8601String(),
|
||||
"transactionDate":
|
||||
(selectedTransactionDate.value ?? now).toUtc().toIso8601String(),
|
||||
"transactionId": transactionIdController.text,
|
||||
"description": descriptionController.text,
|
||||
"location": locationController.text,
|
||||
@ -433,11 +452,13 @@ class AddExpenseController extends GetxController {
|
||||
"noOfPersons": type.noOfPersonsRequired == true
|
||||
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
|
||||
: 0,
|
||||
"billAttachments":
|
||||
combinedAttachments.isEmpty ? null : combinedAttachments,
|
||||
"billAttachments": [
|
||||
...existingPayload,
|
||||
...newPayload,
|
||||
].isEmpty
|
||||
? null
|
||||
: [...existingPayload, ...newPayload],
|
||||
};
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
String validateForm() {
|
||||
@ -450,28 +471,27 @@ class AddExpenseController extends GetxController {
|
||||
if (amountController.text.trim().isEmpty) missing.add("Amount");
|
||||
if (descriptionController.text.trim().isEmpty) missing.add("Description");
|
||||
|
||||
// Date Required
|
||||
if (selectedTransactionDate.value == null) missing.add("Transaction Date");
|
||||
if (selectedTransactionDate.value != null &&
|
||||
selectedTransactionDate.value!.isAfter(DateTime.now())) {
|
||||
if (selectedTransactionDate.value == null) {
|
||||
missing.add("Transaction Date");
|
||||
} else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {
|
||||
missing.add("Valid Transaction Date");
|
||||
}
|
||||
|
||||
final amount = double.tryParse(amountController.text.trim());
|
||||
if (amount == null) missing.add("Valid Amount");
|
||||
if (double.tryParse(amountController.text.trim()) == null) {
|
||||
missing.add("Valid Amount");
|
||||
}
|
||||
|
||||
// Attachment: at least one required at all times
|
||||
bool hasActiveExisting =
|
||||
final hasActiveExisting =
|
||||
existingAttachments.any((e) => e['isActive'] != false);
|
||||
if (attachments.isEmpty && !hasActiveExisting) missing.add("Attachment");
|
||||
if (attachments.isEmpty && !hasActiveExisting) {
|
||||
missing.add("Attachment");
|
||||
}
|
||||
|
||||
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
|
||||
}
|
||||
|
||||
// --- Snackbar Helper ---
|
||||
void _errorSnackbar(String msg, [String title = "Error"]) => showAppSnackbar(
|
||||
title: title,
|
||||
message: msg,
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
void _errorSnackbar(String msg, [String title = "Error"]) {
|
||||
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
// import 'package:marco/helpers/services/app_logger.dart';
|
||||
|
||||
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
|
||||
// logSafe('Received UTC string: $utcTimeString'); // 🔹 Log input
|
||||
|
||||
final parsed = DateTime.parse(utcTimeString);
|
||||
final utcDateTime = DateTime.utc(
|
||||
@ -23,13 +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
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
@ -38,10 +38,10 @@ class DateTimeUtils {
|
||||
static String formatDate(DateTime date, String format) {
|
||||
try {
|
||||
final formatted = DateFormat(format).format(date);
|
||||
logSafe('Formatted DateTime ($date) => $formatted'); // 🔹 Log input/output
|
||||
// logSafe('Formatted DateTime ($date) => $formatted'); // 🔹 Log input/output
|
||||
return formatted;
|
||||
} catch (e, stackTrace) {
|
||||
logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace);
|
||||
// logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace);
|
||||
return 'Invalid Date';
|
||||
}
|
||||
}
|
||||
|
420
lib/helpers/widgets/expense/expense_form_widgets.dart
Normal file
420
lib/helpers/widgets/expense/expense_form_widgets.dart
Normal file
@ -0,0 +1,420 @@
|
||||
// expense_form_widgets.dart
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/controller/expense/add_expense_controller.dart';
|
||||
|
||||
/// 🔹 Common Colors & Styles
|
||||
final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]);
|
||||
final _tileDecoration = BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
);
|
||||
|
||||
/// ==========================
|
||||
/// Section Title
|
||||
/// ==========================
|
||||
class SectionTitle extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final bool requiredField;
|
||||
|
||||
const SectionTitle({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.requiredField = false,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Colors.grey[700];
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
children: [
|
||||
TextSpan(text: title),
|
||||
if (requiredField)
|
||||
const TextSpan(
|
||||
text: ' *',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ==========================
|
||||
/// Custom Text Field
|
||||
/// ==========================
|
||||
class CustomTextField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String hint;
|
||||
final int maxLines;
|
||||
final TextInputType keyboardType;
|
||||
final String? Function(String?)? validator;
|
||||
|
||||
const CustomTextField({
|
||||
required this.controller,
|
||||
required this.hint,
|
||||
this.maxLines = 1,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.validator,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
validator: validator,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: _hintStyle,
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ==========================
|
||||
/// Dropdown Tile
|
||||
/// ==========================
|
||||
class DropdownTile extends StatelessWidget {
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const DropdownTile({
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
decoration: _tileDecoration,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ==========================
|
||||
/// Tile Container
|
||||
/// ==========================
|
||||
class TileContainer extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const TileContainer({required this.child, Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Container(padding: const EdgeInsets.all(14), decoration: _tileDecoration, child: child);
|
||||
}
|
||||
|
||||
/// ==========================
|
||||
/// Attachments Section
|
||||
/// ==========================
|
||||
class AttachmentsSection extends StatelessWidget {
|
||||
final RxList<File> attachments;
|
||||
final RxList<Map<String, dynamic>> existingAttachments;
|
||||
final ValueChanged<File> onRemoveNew;
|
||||
final ValueChanged<Map<String, dynamic>>? onRemoveExisting;
|
||||
final VoidCallback onAdd;
|
||||
|
||||
const AttachmentsSection({
|
||||
required this.attachments,
|
||||
required this.existingAttachments,
|
||||
required this.onRemoveNew,
|
||||
this.onRemoveExisting,
|
||||
required this.onAdd,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
static const allowedImageExtensions = ['jpg', 'jpeg', 'png'];
|
||||
|
||||
bool _isImageFile(File file) {
|
||||
final ext = file.path.split('.').last.toLowerCase();
|
||||
return allowedImageExtensions.contains(ext);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
final activeExisting = existingAttachments
|
||||
.where((doc) => doc['isActive'] != false)
|
||||
.toList();
|
||||
|
||||
final imageFiles = attachments.where(_isImageFile).toList();
|
||||
final imageExisting = activeExisting
|
||||
.where((d) =>
|
||||
(d['contentType']?.toString().startsWith('image/') ?? false))
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (activeExisting.isNotEmpty) ...[
|
||||
const Text("Existing Attachments",
|
||||
style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: activeExisting.map((doc) {
|
||||
final isImage =
|
||||
doc['contentType']?.toString().startsWith('image/') ??
|
||||
false;
|
||||
final url = doc['url'];
|
||||
final fileName = doc['fileName'] ?? 'Unnamed';
|
||||
|
||||
return _buildExistingTile(
|
||||
context,
|
||||
doc,
|
||||
isImage,
|
||||
url,
|
||||
fileName,
|
||||
imageExisting,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
...attachments.map((file) => GestureDetector(
|
||||
onTap: () => _onNewTap(context, file, imageFiles),
|
||||
child: _AttachmentTile(
|
||||
file: file,
|
||||
onRemove: () => onRemoveNew(file),
|
||||
),
|
||||
)),
|
||||
_buildActionTile(Icons.attach_file, onAdd),
|
||||
_buildActionTile(Icons.camera_alt,
|
||||
() => Get.find<AddExpenseController>().pickFromCamera()),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// helper for new file tap
|
||||
void _onNewTap(BuildContext context, File file, List<File> imageFiles) {
|
||||
if (_isImageFile(file)) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewerDialog(
|
||||
imageSources: imageFiles,
|
||||
initialIndex: imageFiles.indexOf(file),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: 'Info',
|
||||
message: 'Preview for this file type is not supported.',
|
||||
type: SnackbarType.info,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// helper for existing file tile
|
||||
Widget _buildExistingTile(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> doc,
|
||||
bool isImage,
|
||||
String? url,
|
||||
String fileName,
|
||||
List<Map<String, dynamic>> imageExisting,
|
||||
) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
if (isImage) {
|
||||
final sources = imageExisting.map((e) => e['url']).toList();
|
||||
final idx = imageExisting.indexOf(doc);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
ImageViewerDialog(imageSources: sources, initialIndex: idx),
|
||||
);
|
||||
} else if (url != null && await canLaunchUrlString(url)) {
|
||||
await launchUrlString(url, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message: 'Could not open the document.',
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: _tileDecoration.copyWith(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(isImage ? Icons.image : Icons.insert_drive_file,
|
||||
size: 20, color: Colors.grey[600]),
|
||||
const SizedBox(width: 7),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 120),
|
||||
child: Text(fileName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onRemoveExisting != null)
|
||||
Positioned(
|
||||
top: -6,
|
||||
right: -6,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||
onPressed: () => onRemoveExisting?.call(doc),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionTile(IconData icon, VoidCallback onTap) => GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: _tileDecoration.copyWith(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
),
|
||||
child: Icon(icon, size: 30, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// ==========================
|
||||
/// Attachment Tile
|
||||
/// ==========================
|
||||
class _AttachmentTile extends StatelessWidget {
|
||||
final File file;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _AttachmentTile({required this.file, required this.onRemove});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fileName = file.path.split('/').last;
|
||||
final extension = fileName.split('.').last.toLowerCase();
|
||||
final isImage = AttachmentsSection.allowedImageExtensions.contains(extension);
|
||||
|
||||
final (icon, color) = _fileIcon(extension);
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: _tileDecoration,
|
||||
child: isImage
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(file, fit: BoxFit.cover),
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 30),
|
||||
const SizedBox(height: 4),
|
||||
Text(extension.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -6,
|
||||
right: -6,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||
onPressed: onRemove,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// map extensions to icons/colors
|
||||
static (IconData, Color) _fileIcon(String ext) {
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
return (Icons.picture_as_pdf, Colors.redAccent);
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return (Icons.description, Colors.blueAccent);
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return (Icons.table_chart, Colors.green);
|
||||
case 'txt':
|
||||
return (Icons.article, Colors.grey);
|
||||
default:
|
||||
return (Icons.insert_drive_file, Colors.blueGrey);
|
||||
}
|
||||
}
|
||||
}
|
@ -295,12 +295,15 @@ class _AddEmployeeBottomSheetState extends State<AddEmployeeBottomSheet>
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return "Phone Number is required";
|
||||
}
|
||||
if (value.trim().length != 10) {
|
||||
return "Phone Number must be exactly 10 digits";
|
||||
}
|
||||
if (!RegExp(r'^\d{10}$').hasMatch(value.trim())) {
|
||||
return "Enter a valid 10-digit number";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
keyboardType: TextInputType.phone,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
|
@ -1,7 +1,5 @@
|
||||
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';
|
||||
@ -11,8 +9,8 @@ import 'package:marco/helpers/utils/base_bottom_sheet.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';
|
||||
import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart';
|
||||
|
||||
/// Show bottom sheet wrapper
|
||||
Future<T?> showAddExpenseBottomSheet<T>({
|
||||
@ -95,18 +93,50 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
items: options
|
||||
.map(
|
||||
(opt) => PopupMenuItem<T>(
|
||||
.map((opt) => PopupMenuItem<T>(
|
||||
value: opt,
|
||||
child: Text(getLabel(opt)),
|
||||
),
|
||||
)
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
if (selected != null) onSelected(selected);
|
||||
}
|
||||
|
||||
/// Validate required selections
|
||||
bool _validateSelections() {
|
||||
if (controller.selectedProject.value.isEmpty) {
|
||||
_showError("Please select a project");
|
||||
return false;
|
||||
}
|
||||
if (controller.selectedExpenseType.value == null) {
|
||||
_showError("Please select an expense type");
|
||||
return false;
|
||||
}
|
||||
if (controller.selectedPaymentMode.value == null) {
|
||||
_showError("Please select a payment mode");
|
||||
return false;
|
||||
}
|
||||
if (controller.selectedPaidBy.value == null) {
|
||||
_showError("Please select a person who paid");
|
||||
return false;
|
||||
}
|
||||
if (controller.attachments.isEmpty &&
|
||||
controller.existingAttachments.isEmpty) {
|
||||
_showError("Please attach at least one document");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _showError(String msg) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: msg,
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(
|
||||
@ -117,70 +147,17 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
isSubmitting: controller.isSubmitting.value,
|
||||
onCancel: Get.back,
|
||||
onSubmit: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
// Additional dropdown validation
|
||||
if (controller.selectedProject.value.isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please select a project",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.selectedExpenseType.value == null) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please select an expense type",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.selectedPaymentMode.value == null) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please select a payment mode",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.selectedPaidBy.value == null) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please select a person who paid",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller.attachments.isEmpty &&
|
||||
controller.existingAttachments.isEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please attach at least one document",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation passed, submit
|
||||
if (_formKey.currentState!.validate() && _validateSelections()) {
|
||||
controller.submitOrUpdateExpense();
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please fill all required fields correctly",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
_showError("Please fill all required fields correctly");
|
||||
}
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 🔹 Project
|
||||
_buildDropdown<String>(
|
||||
_buildDropdownField<String>(
|
||||
icon: Icons.work_outline,
|
||||
title: "Project",
|
||||
requiredField: true,
|
||||
@ -195,11 +172,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
),
|
||||
dropdownKey: _projectDropdownKey,
|
||||
),
|
||||
_gap(),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 Expense Type
|
||||
_buildDropdown<ExpenseTypeModel>(
|
||||
_buildDropdownField<ExpenseTypeModel>(
|
||||
icon: Icons.category_outlined,
|
||||
title: "Expense Type",
|
||||
requiredField: true,
|
||||
@ -214,38 +189,30 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
dropdownKey: _expenseTypeDropdownKey,
|
||||
),
|
||||
|
||||
// 🔹 Persons if required
|
||||
// Persons if required
|
||||
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
|
||||
true) ...[
|
||||
MySpacing.height(16),
|
||||
_SectionTitle(
|
||||
_gap(),
|
||||
_buildTextFieldSection(
|
||||
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,
|
||||
validator: Validators.requiredField,
|
||||
),
|
||||
],
|
||||
_gap(),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 GST
|
||||
_SectionTitle(
|
||||
icon: Icons.confirmation_number_outlined, title: "GST No."),
|
||||
MySpacing.height(6),
|
||||
_CustomTextField(
|
||||
_buildTextFieldSection(
|
||||
icon: Icons.confirmation_number_outlined,
|
||||
title: "GST No.",
|
||||
controller: controller.gstController,
|
||||
hint: "Enter GST No.",
|
||||
),
|
||||
_gap(),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 Payment Mode
|
||||
_buildDropdown<PaymentModeModel>(
|
||||
_buildDropdownField<PaymentModeModel>(
|
||||
icon: Icons.payment,
|
||||
title: "Payment Mode",
|
||||
requiredField: true,
|
||||
@ -259,18 +226,125 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
),
|
||||
dropdownKey: _paymentModeDropdownKey,
|
||||
),
|
||||
_gap(),
|
||||
|
||||
MySpacing.height(16),
|
||||
_buildPaidBySection(),
|
||||
_gap(),
|
||||
|
||||
// 🔹 Paid By
|
||||
_SectionTitle(
|
||||
icon: Icons.person_outline,
|
||||
title: "Paid By",
|
||||
requiredField: true),
|
||||
_buildTextFieldSection(
|
||||
icon: Icons.currency_rupee,
|
||||
title: "Amount",
|
||||
controller: controller.amountController,
|
||||
hint: "Enter Amount",
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) => Validators.isNumeric(v ?? "")
|
||||
? null
|
||||
: "Enter valid amount",
|
||||
),
|
||||
_gap(),
|
||||
|
||||
_buildTextFieldSection(
|
||||
icon: Icons.store_mall_directory_outlined,
|
||||
title: "Supplier Name/Transporter Name/Other",
|
||||
controller: controller.supplierController,
|
||||
hint: "Enter Supplier Name/Transporter Name or Other",
|
||||
validator: Validators.nameValidator,
|
||||
),
|
||||
_gap(),
|
||||
|
||||
_buildTextFieldSection(
|
||||
icon: Icons.confirmation_number_outlined,
|
||||
title: "Transaction ID",
|
||||
controller: controller.transactionIdController,
|
||||
hint: "Enter Transaction ID",
|
||||
validator: (v) => (v != null && v.isNotEmpty)
|
||||
? Validators.transactionIdValidator(v)
|
||||
: null,
|
||||
),
|
||||
_gap(),
|
||||
|
||||
_buildTransactionDateField(),
|
||||
_gap(),
|
||||
|
||||
_buildLocationField(),
|
||||
_gap(),
|
||||
|
||||
_buildAttachmentsSection(),
|
||||
_gap(),
|
||||
|
||||
_buildTextFieldSection(
|
||||
icon: Icons.description_outlined,
|
||||
title: "Description",
|
||||
controller: controller.descriptionController,
|
||||
hint: "Enter Description",
|
||||
maxLines: 3,
|
||||
validator: Validators.requiredField,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _gap([double h = 16]) => MySpacing.height(h);
|
||||
|
||||
Widget _buildDropdownField<T>({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required bool requiredField,
|
||||
required String value,
|
||||
required VoidCallback onTap,
|
||||
required GlobalKey dropdownKey,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionTitle(icon: icon, title: title, requiredField: requiredField),
|
||||
MySpacing.height(6),
|
||||
DropdownTile(key: dropdownKey, title: value, onTap: onTap),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextFieldSection({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required TextEditingController controller,
|
||||
String? hint,
|
||||
TextInputType? keyboardType,
|
||||
FormFieldValidator<String>? validator,
|
||||
int maxLines = 1,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionTitle(
|
||||
icon: icon, title: title, requiredField: validator != null),
|
||||
MySpacing.height(6),
|
||||
CustomTextField(
|
||||
controller: controller,
|
||||
hint: hint ?? "",
|
||||
keyboardType:
|
||||
keyboardType ?? TextInputType.text,
|
||||
validator: validator,
|
||||
maxLines: maxLines,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPaidBySection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionTitle(
|
||||
icon: Icons.person_outline, title: "Paid By", requiredField: true),
|
||||
MySpacing.height(6),
|
||||
GestureDetector(
|
||||
onTap: _showEmployeeList,
|
||||
child: _TileContainer(
|
||||
child: TileContainer(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@ -285,61 +359,15 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
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(
|
||||
Widget _buildTransactionDateField() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionTitle(
|
||||
icon: Icons.calendar_today,
|
||||
title: "Transaction Date",
|
||||
requiredField: true),
|
||||
@ -347,19 +375,22 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
GestureDetector(
|
||||
onTap: () => controller.pickTransactionDate(context),
|
||||
child: AbsorbPointer(
|
||||
child: _CustomTextField(
|
||||
child: CustomTextField(
|
||||
controller: controller.transactionDateController,
|
||||
hint: "Select Transaction Date",
|
||||
validator: Validators.requiredField,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 Location
|
||||
_SectionTitle(
|
||||
icon: Icons.location_on_outlined, title: "Location"),
|
||||
Widget _buildLocationField() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionTitle(icon: Icons.location_on_outlined, title: "Location"),
|
||||
MySpacing.height(6),
|
||||
TextFormField(
|
||||
controller: controller.locationController,
|
||||
@ -367,10 +398,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
hintText: "Enter Location",
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
suffixIcon: controller.isFetchingLocation.value
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
@ -387,16 +417,18 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
// 🔹 Attachments
|
||||
_SectionTitle(
|
||||
icon: Icons.attach_file,
|
||||
title: "Attachments",
|
||||
requiredField: true),
|
||||
Widget _buildAttachmentsSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionTitle(
|
||||
icon: Icons.attach_file, title: "Attachments", requiredField: true),
|
||||
MySpacing.height(6),
|
||||
_AttachmentsSection(
|
||||
AttachmentsSection(
|
||||
attachments: controller.attachments,
|
||||
existingAttachments: controller.existingAttachments,
|
||||
onRemoveNew: controller.removeAttachment,
|
||||
@ -406,17 +438,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
barrierDismissible: false,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: "Remove Attachment",
|
||||
message:
|
||||
"Are you sure you want to remove this 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);
|
||||
final index = controller.existingAttachments.indexOf(item);
|
||||
if (index != -1) {
|
||||
controller.existingAttachments[index]['isActive'] =
|
||||
false;
|
||||
controller.existingAttachments[index]['isActive'] = false;
|
||||
controller.existingAttachments.refresh();
|
||||
}
|
||||
showAppSnackbar(
|
||||
@ -430,483 +459,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
},
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdown<T>({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required bool requiredField,
|
||||
required String value,
|
||||
required VoidCallback onTap,
|
||||
required GlobalKey dropdownKey,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(icon: icon, title: title, requiredField: requiredField),
|
||||
MySpacing.height(6),
|
||||
_DropdownTile(key: dropdownKey, title: value, onTap: onTap),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final bool requiredField;
|
||||
|
||||
const _SectionTitle({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.requiredField = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Colors.grey[700];
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
children: [
|
||||
TextSpan(text: title),
|
||||
if (requiredField)
|
||||
const TextSpan(
|
||||
text: ' *',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomTextField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
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 TextFormField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
validator: validator, // 👈 applied
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DropdownTile extends StatelessWidget {
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DropdownTile({
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
Key? key, // Add optional key parameter
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TileContainer extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _TileContainer({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AttachmentsSection extends StatelessWidget {
|
||||
final RxList<File> attachments;
|
||||
final RxList<Map<String, dynamic>> existingAttachments;
|
||||
final ValueChanged<File> onRemoveNew;
|
||||
final ValueChanged<Map<String, dynamic>>? onRemoveExisting;
|
||||
final VoidCallback onAdd;
|
||||
|
||||
const _AttachmentsSection({
|
||||
required this.attachments,
|
||||
required this.existingAttachments,
|
||||
required this.onRemoveNew,
|
||||
this.onRemoveExisting,
|
||||
required this.onAdd,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
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: [
|
||||
if (activeExistingAttachments.isNotEmpty) ...[
|
||||
Text(
|
||||
"Existing Attachments",
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: activeExistingAttachments.map((doc) {
|
||||
final isImage =
|
||||
doc['contentType']?.toString().startsWith('image/') ??
|
||||
false;
|
||||
final url = doc['url'];
|
||||
final fileName = doc['fileName'] ?? 'Unnamed';
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
if (isImage) {
|
||||
// Open dialog only with image attachments (URLs)
|
||||
final imageSources = imageExistingAttachments
|
||||
.map((e) => e['url'])
|
||||
.toList();
|
||||
final initialIndex = imageExistingAttachments
|
||||
.indexWhere((d) => d == doc);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewerDialog(
|
||||
imageSources: imageSources,
|
||||
initialIndex: initialIndex,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Open non-image attachment externally or show error
|
||||
if (url != null && await canLaunchUrlString(url)) {
|
||||
await launchUrlString(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message: 'Could not open the document.',
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isImage ? Icons.image : Icons.insert_drive_file,
|
||||
size: 20,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 120),
|
||||
child: Text(
|
||||
fileName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onRemoveExisting != null)
|
||||
Positioned(
|
||||
top: -6,
|
||||
right: -6,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close,
|
||||
color: Colors.red, size: 18),
|
||||
onPressed: () {
|
||||
onRemoveExisting?.call(doc);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// New attachments section: show all files, but only open dialog for images
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
...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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
// 📎 File Picker Button
|
||||
GestureDetector(
|
||||
onTap: onAdd,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: const Icon(Icons.attach_file,
|
||||
size: 30, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
|
||||
// 📷 Camera Button
|
||||
GestureDetector(
|
||||
onTap: () => Get.find<AddExpenseController>().pickFromCamera(),
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: const Icon(Icons.camera_alt,
|
||||
size: 30, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _AttachmentTile extends StatelessWidget {
|
||||
final File file;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _AttachmentTile({required this.file, required this.onRemove});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fileName = file.path.split('/').last;
|
||||
final extension = fileName.split('.').last.toLowerCase();
|
||||
final isImage = ['jpg', 'jpeg', 'png'].contains(extension);
|
||||
|
||||
IconData fileIcon = Icons.insert_drive_file;
|
||||
Color iconColor = Colors.blueGrey;
|
||||
|
||||
switch (extension) {
|
||||
case 'pdf':
|
||||
fileIcon = Icons.picture_as_pdf;
|
||||
iconColor = Colors.redAccent;
|
||||
break;
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
fileIcon = Icons.description;
|
||||
iconColor = Colors.blueAccent;
|
||||
break;
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
fileIcon = Icons.table_chart;
|
||||
iconColor = Colors.green;
|
||||
break;
|
||||
case 'txt':
|
||||
fileIcon = Icons.article;
|
||||
iconColor = Colors.grey;
|
||||
break;
|
||||
}
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: isImage
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(file, fit: BoxFit.cover),
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(fileIcon, color: iconColor, size: 30),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
extension.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: iconColor),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -6,
|
||||
right: -6,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||
onPressed: onRemove,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -11,37 +11,95 @@ import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||
import 'package:marco/model/attendance/log_details_view.dart';
|
||||
import 'package:marco/model/attendance/attendence_action_button.dart';
|
||||
|
||||
class AttendanceLogsTab extends StatelessWidget {
|
||||
class AttendanceLogsTab extends StatefulWidget {
|
||||
final AttendanceController controller;
|
||||
|
||||
const AttendanceLogsTab({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<AttendanceLogsTab> createState() => _AttendanceLogsTabState();
|
||||
}
|
||||
|
||||
class _AttendanceLogsTabState extends State<AttendanceLogsTab> {
|
||||
Widget _buildStatusHeader() {
|
||||
return Obx(() {
|
||||
final showPending = widget.controller.showPendingOnly.value;
|
||||
if (!showPending) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
color: Colors.orange.shade50,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.pending_actions,
|
||||
color: Colors.orange,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
"Showing Pending Actions Only",
|
||||
style: TextStyle(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
widget.controller.showPendingOnly.value = false;
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 18,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
final logs = List.of(controller.attendanceLogs);
|
||||
final logs = List.of(widget.controller.filteredLogs);
|
||||
logs.sort((a, b) {
|
||||
final aDate = a.checkIn ?? DateTime(0);
|
||||
final bDate = b.checkIn ?? DateTime(0);
|
||||
return bDate.compareTo(aDate);
|
||||
});
|
||||
|
||||
final dateRangeText = controller.startDateAttendance != null &&
|
||||
controller.endDateAttendance != null
|
||||
? '${DateTimeUtils.formatDate(controller.startDateAttendance!, 'dd MMM yyyy')} - '
|
||||
'${DateTimeUtils.formatDate(controller.endDateAttendance!, 'dd MMM yyyy')}'
|
||||
// Use controller's observable for pending filter
|
||||
final showPendingOnly = widget.controller.showPendingOnly.value;
|
||||
|
||||
final filteredLogs = showPendingOnly
|
||||
? logs
|
||||
.where((employee) =>
|
||||
employee.activity == 1 || employee.activity == 2)
|
||||
.toList()
|
||||
: logs;
|
||||
|
||||
final dateRangeText = widget.controller.startDateAttendance != null &&
|
||||
widget.controller.endDateAttendance != null
|
||||
? '${DateTimeUtils.formatDate(widget.controller.startDateAttendance!, 'dd MMM yyyy')} - '
|
||||
'${DateTimeUtils.formatDate(widget.controller.endDateAttendance!, 'dd MMM yyyy')}'
|
||||
: 'Select date range';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row: Title and Date Range
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
MyText.titleMedium("Attendance Logs", fontWeight: 600),
|
||||
controller.isLoading.value
|
||||
widget.controller.isLoading.value
|
||||
? SkeletonLoaders.dateSkeletonLoader()
|
||||
: MyText.bodySmall(
|
||||
dateRangeText,
|
||||
@ -52,29 +110,37 @@ class AttendanceLogsTab extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (controller.isLoadingAttendanceLogs.value)
|
||||
|
||||
// ✅ Pending status header
|
||||
_buildStatusHeader(),
|
||||
MySpacing.height(8),
|
||||
|
||||
// Content: Skeleton, Empty, or List
|
||||
if (widget.controller.isLoadingAttendanceLogs.value)
|
||||
SkeletonLoaders.employeeListSkeletonLoader()
|
||||
else if (logs.isEmpty)
|
||||
const SizedBox(
|
||||
else if (filteredLogs.isEmpty)
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: Center(
|
||||
child: Text("No Attendance Logs Found for this Project"),
|
||||
child: Text(showPendingOnly
|
||||
? "No Pending Actions Found"
|
||||
: "No Attendance Logs Found for this Project"),
|
||||
),
|
||||
)
|
||||
else
|
||||
MyCard.bordered(
|
||||
paddingAll: 8,
|
||||
child: Column(
|
||||
children: List.generate(logs.length, (index) {
|
||||
final employee = logs[index];
|
||||
children: List.generate(filteredLogs.length, (index) {
|
||||
final employee = filteredLogs[index];
|
||||
final currentDate = employee.checkIn != null
|
||||
? DateTimeUtils.formatDate(
|
||||
employee.checkIn!, 'dd MMM yyyy')
|
||||
: '';
|
||||
final previousDate =
|
||||
index > 0 && logs[index - 1].checkIn != null
|
||||
index > 0 && filteredLogs[index - 1].checkIn != null
|
||||
? DateTimeUtils.formatDate(
|
||||
logs[index - 1].checkIn!, 'dd MMM yyyy')
|
||||
filteredLogs[index - 1].checkIn!, 'dd MMM yyyy')
|
||||
: '';
|
||||
final showDateHeader =
|
||||
index == 0 || currentDate != previousDate;
|
||||
@ -159,12 +225,12 @@ class AttendanceLogsTab extends StatelessWidget {
|
||||
children: [
|
||||
AttendanceActionButton(
|
||||
employee: employee,
|
||||
attendanceController: controller,
|
||||
attendanceController: widget.controller,
|
||||
),
|
||||
MySpacing.width(8),
|
||||
AttendanceLogViewButton(
|
||||
employee: employee,
|
||||
attendanceController: controller,
|
||||
attendanceController: widget.controller,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -174,7 +240,7 @@ class AttendanceLogsTab extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (index != logs.length - 1)
|
||||
if (index != filteredLogs.length - 1)
|
||||
Divider(color: Colors.grey.withOpacity(0.3)),
|
||||
],
|
||||
);
|
||||
|
@ -14,6 +14,7 @@ import 'package:marco/view/Attendence/regularization_requests_tab.dart';
|
||||
import 'package:marco/view/Attendence/attendance_logs_tab.dart';
|
||||
import 'package:marco/view/Attendence/todays_attendance_tab.dart';
|
||||
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
||||
|
||||
class AttendanceScreen extends StatefulWidget {
|
||||
const AttendanceScreen({super.key});
|
||||
|
||||
@ -113,22 +114,75 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterAndRefreshRow() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
Widget _buildFilterSearchRow() {
|
||||
return Padding(
|
||||
padding: MySpacing.xy(8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
MyText.bodyMedium("Filter", fontWeight: 600),
|
||||
Tooltip(
|
||||
message: 'Filter Project',
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
onTap: () async {
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 35,
|
||||
child: Obx(() {
|
||||
final query = attendanceController.searchQuery.value;
|
||||
return TextField(
|
||||
controller: TextEditingController(text: query)
|
||||
..selection = TextSelection.collapsed(offset: query.length),
|
||||
onChanged: (value) {
|
||||
attendanceController.searchQuery.value = value;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
prefixIcon:
|
||||
const Icon(Icons.search, size: 20, color: Colors.grey),
|
||||
suffixIcon: query.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close,
|
||||
size: 18, color: Colors.grey),
|
||||
onPressed: () {
|
||||
attendanceController.searchQuery.value = '';
|
||||
},
|
||||
)
|
||||
: null,
|
||||
hintText: 'Search by name',
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
MySpacing.width(8),
|
||||
|
||||
// 🛠️ Filter Icon (no red dot here anymore)
|
||||
Container(
|
||||
height: 35,
|
||||
width: 35,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: BoxConstraints(),
|
||||
icon: const Icon(Icons.tune, size: 20, color: Colors.black87),
|
||||
onPressed: () async {
|
||||
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
borderRadius:
|
||||
BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
builder: (context) => AttendanceFilterBottomSheet(
|
||||
controller: attendanceController,
|
||||
@ -163,14 +217,86 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Icon(Icons.tune, size: 18),
|
||||
),
|
||||
),
|
||||
MySpacing.width(8),
|
||||
|
||||
// ⋮ Pending Actions Menu (red dot here instead)
|
||||
if (selectedTab == 'attendanceLogs')
|
||||
Obx(() {
|
||||
final showPending = attendanceController.showPendingOnly.value;
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 35,
|
||||
width: 35,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: PopupMenuButton<int>(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(Icons.more_vert,
|
||||
size: 20, color: Colors.black87),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem<int>(
|
||||
enabled: false,
|
||||
height: 30,
|
||||
child: Text(
|
||||
"Preferences",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<int>(
|
||||
value: 0,
|
||||
enabled: false,
|
||||
child: Obx(() => Row(
|
||||
children: [
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: Text('Show Pending Actions')),
|
||||
Switch.adaptive(
|
||||
value: attendanceController
|
||||
.showPendingOnly.value,
|
||||
activeColor: Colors.indigo,
|
||||
onChanged: (val) {
|
||||
attendanceController
|
||||
.showPendingOnly.value = val;
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showPending)
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: Container(
|
||||
height: 8,
|
||||
width: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoProjectWidget() {
|
||||
@ -222,8 +348,7 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MySpacing.height(flexSpacing),
|
||||
_buildFilterAndRefreshRow(),
|
||||
MySpacing.height(flexSpacing),
|
||||
_buildFilterSearchRow(),
|
||||
MyFlex(
|
||||
children: [
|
||||
MyFlexItem(
|
||||
|
@ -27,7 +27,7 @@ class RegularizationRequestsTab extends StatelessWidget {
|
||||
child: MyText.titleMedium("Regularization Requests", fontWeight: 600),
|
||||
),
|
||||
Obx(() {
|
||||
final employees = controller.regularizationLogs;
|
||||
final employees = controller.filteredRegularizationLogs;
|
||||
|
||||
if (controller.isLoadingRegularizationLogs.value) {
|
||||
return SkeletonLoaders.employeeListSkeletonLoader();
|
||||
@ -37,7 +37,8 @@ class RegularizationRequestsTab extends StatelessWidget {
|
||||
return const SizedBox(
|
||||
height: 120,
|
||||
child: Center(
|
||||
child: Text("No Regularization Requests Found for this Project"),
|
||||
child:
|
||||
Text("No Regularization Requests Found for this Project"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ class TodaysAttendanceTab extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
final isLoading = controller.isLoadingEmployees.value;
|
||||
final employees = controller.employees;
|
||||
final employees = controller.filteredEmployees;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -30,7 +30,8 @@ class TodaysAttendanceTab extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MyText.titleMedium("Today's Attendance", fontWeight: 600),
|
||||
child:
|
||||
MyText.titleMedium("Today's Attendance", fontWeight: 600),
|
||||
),
|
||||
MyText.bodySmall(
|
||||
DateTimeUtils.formatDate(DateTime.now(), 'dd MMM yyyy'),
|
||||
@ -43,7 +44,9 @@ class TodaysAttendanceTab extends StatelessWidget {
|
||||
if (isLoading)
|
||||
SkeletonLoaders.employeeListSkeletonLoader()
|
||||
else if (employees.isEmpty)
|
||||
const SizedBox(height: 120, child: Center(child: Text("No Employees Assigned")))
|
||||
const SizedBox(
|
||||
height: 120,
|
||||
child: Center(child: Text("No Employees Assigned")))
|
||||
else
|
||||
MyCard.bordered(
|
||||
paddingAll: 8,
|
||||
@ -57,7 +60,10 @@ class TodaysAttendanceTab extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Avatar(firstName: employee.firstName, lastName: employee.lastName, size: 31),
|
||||
Avatar(
|
||||
firstName: employee.firstName,
|
||||
lastName: employee.lastName,
|
||||
size: 31),
|
||||
MySpacing.width(16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@ -66,27 +72,39 @@ class TodaysAttendanceTab extends StatelessWidget {
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
MyText.bodyMedium(employee.name, fontWeight: 600),
|
||||
MyText.bodySmall('(${employee.designation})', fontWeight: 600, color: Colors.grey[700]),
|
||||
MyText.bodyMedium(employee.name,
|
||||
fontWeight: 600),
|
||||
MyText.bodySmall(
|
||||
'(${employee.designation})',
|
||||
fontWeight: 600,
|
||||
color: Colors.grey[700]),
|
||||
],
|
||||
),
|
||||
MySpacing.height(8),
|
||||
if (employee.checkIn != null || employee.checkOut != null)
|
||||
if (employee.checkIn != null ||
|
||||
employee.checkOut != null)
|
||||
Row(
|
||||
children: [
|
||||
if (employee.checkIn != null)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.arrow_circle_right, size: 16, color: Colors.green),
|
||||
const Icon(
|
||||
Icons.arrow_circle_right,
|
||||
size: 16,
|
||||
color: Colors.green),
|
||||
MySpacing.width(4),
|
||||
Text(DateTimeUtils.formatDate(employee.checkIn!, 'hh:mm a')),
|
||||
Text(DateTimeUtils.formatDate(
|
||||
employee.checkIn!,
|
||||
'hh:mm a')),
|
||||
],
|
||||
),
|
||||
if (employee.checkOut != null) ...[
|
||||
MySpacing.width(16),
|
||||
const Icon(Icons.arrow_circle_left, size: 16, color: Colors.red),
|
||||
const Icon(Icons.arrow_circle_left,
|
||||
size: 16, color: Colors.red),
|
||||
MySpacing.width(4),
|
||||
Text(DateTimeUtils.formatDate(employee.checkOut!, 'hh:mm a')),
|
||||
Text(DateTimeUtils.formatDate(
|
||||
employee.checkOut!, 'hh:mm a')),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
@ -18,7 +18,8 @@ class DailyTaskPlanningScreen extends StatefulWidget {
|
||||
DailyTaskPlanningScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DailyTaskPlanningScreen> createState() => _DailyTaskPlanningScreenState();
|
||||
State<DailyTaskPlanningScreen> createState() =>
|
||||
_DailyTaskPlanningScreenState();
|
||||
}
|
||||
|
||||
class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
||||
@ -270,12 +271,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
||||
"${buildingKey}_${floor.floorName}_${area.areaName}";
|
||||
final isExpanded =
|
||||
floorExpansionState[floorWorkAreaKey] ?? false;
|
||||
final totalPlanned = area.workItems
|
||||
.map((wi) => wi.workItem.plannedWork ?? 0)
|
||||
.fold<double>(0, (prev, curr) => prev + curr);
|
||||
final totalCompleted = area.workItems
|
||||
.map((wi) => wi.workItem.completedWork ?? 0)
|
||||
.fold<double>(0, (prev, curr) => prev + curr);
|
||||
final workItems = area.workItems;
|
||||
final totalPlanned = workItems.fold<double>(
|
||||
0, (sum, wi) => sum + (wi.workItem.plannedWork ?? 0));
|
||||
final totalCompleted = workItems.fold<double>(0,
|
||||
(sum, wi) => sum + (wi.workItem.completedWork ?? 0));
|
||||
final totalProgress = totalPlanned == 0
|
||||
? 0.0
|
||||
: (totalCompleted / totalPlanned).clamp(0.0, 1.0);
|
||||
|
Loading…
x
Reference in New Issue
Block a user