import 'dart:convert'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; 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'; import 'package:marco/helpers/services/app_logger.dart'; 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'; class AddExpenseController extends GetxController { // --- Text Controllers --- final controllers = [ TextEditingController(), // amount TextEditingController(), // description TextEditingController(), // supplier TextEditingController(), // transactionId TextEditingController(), // gst TextEditingController(), // location TextEditingController(), // transactionDate TextEditingController(), // noOfPersons TextEditingController(), // employeeSearch ]; 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; final isSubmitting = false.obs; final isFetchingLocation = false.obs; final isEditMode = false.obs; final isSearchingEmployees = false.obs; // --- Dropdown Selections & Data --- final selectedPaymentMode = Rxn(); final selectedExpenseType = Rxn(); final selectedPaidBy = Rxn(); final selectedProject = ''.obs; final selectedTransactionDate = Rxn(); final attachments = [].obs; final existingAttachments = >[].obs; final globalProjects = [].obs; final projectsMap = {}.obs; final expenseTypes = [].obs; final paymentModes = [].obs; final allEmployees = [].obs; final employeeSearchResults = [].obs; String? editingExpenseId; final expenseController = Get.find(); final ImagePicker _picker = ImagePicker(); @override void onInit() { super.onInit(); loadMasterData(); employeeSearchController.addListener( () => searchEmployees(employeeSearchController.text), ); } @override void onClose() { for (var c in controllers) { c.dispose(); } super.onClose(); } // --- Employee Search --- Future searchEmployees(String query) async { if (query.trim().isEmpty) return employeeSearchResults.clear(); isSearchingEmployees.value = true; try { final data = await ApiService.searchEmployeesBasic( searchString: query.trim(), ); if (data is List) { employeeSearchResults.assignAll( data .map((e) => EmployeeModel.fromJson(e as Map)) .toList(), ); } else { employeeSearchResults.clear(); } } catch (e) { logSafe("Error searching employees: $e", level: LogLevel.error); employeeSearchResults.clear(); } finally { isSearchingEmployees.value = false; } } // --- Form Population (Edit) --- Future populateFieldsForEdit(Map data) async { isEditMode.value = true; editingExpenseId = '${data['id']}'; selectedProject.value = data['projectName'] ?? ''; 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}'; _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(dateStr); selectedTransactionDate.value = parsed; transactionDateController.text = DateFormat('dd-MM-yyyy').format(parsed); } catch (_) { selectedTransactionDate.value = null; transactionDateController.clear(); } } void _setDropdowns(Map data) { selectedExpenseType.value = expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']); selectedPaymentMode.value = paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']); } Future _setPaidBy(Map 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']}', ); selectedPaidBy.value = employeeSearchResults .firstWhereOrNull((e) => e.id.trim() == paidById.trim()); } } void _setAttachments(dynamic attachmentsData) { existingAttachments.clear(); if (attachmentsData is List) { existingAttachments.addAll( List>.from(attachmentsData).map( (e) => {...e, 'isActive': true}, ), ); } } void _logPrefilledData() { final info = [ 'ID: $editingExpenseId', 'Project: ${selectedProject.value}', 'Amount: ${amountController.text}', 'Supplier: ${supplierController.text}', 'Description: ${descriptionController.text}', 'Transaction ID: ${transactionIdController.text}', 'Location: ${locationController.text}', 'Transaction Date: ${transactionDateController.text}', 'No. of Persons: ${noOfPersonsController.text}', 'Expense Type: ${selectedExpenseType.value?.name}', 'Payment Mode: ${selectedPaymentMode.value?.name}', 'Paid By: ${selectedPaidBy.value?.name}', 'Attachments: ${attachments.length}', 'Existing Attachments: ${existingAttachments.length}', ]; for (var line in info) { logSafe(line, level: LogLevel.info); } } // --- Pickers --- Future pickTransactionDate(BuildContext context) async { final pickedDate = await showDatePicker( context: context, initialDate: selectedTransactionDate.value ?? DateTime.now(), firstDate: DateTime(DateTime.now().year - 5), lastDate: DateTime.now(), ); if (pickedDate != null) { final now = DateTime.now(); final finalDateTime = DateTime( pickedDate.year, pickedDate.month, pickedDate.day, now.hour, now.minute, now.second, ); selectedTransactionDate.value = finalDateTime; transactionDateController.text = DateFormat('dd MMM yyyy').format(finalDateTime); } } Future pickAttachments() async { try { final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'], allowMultiple: true, ); if (result != null) { attachments.addAll( result.paths.whereType().map(File.new), ); } } catch (e) { _errorSnackbar("Attachment error: $e"); } } void removeAttachment(File file) => attachments.remove(file); Future 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 fetchCurrentLocation() async { isFetchingLocation.value = true; try { if (!await _ensureLocationPermission()) return; final position = await Geolocator.getCurrentPosition(); final placemarks = await placemarkFromCoordinates(position.latitude, position.longitude); locationController.text = placemarks.isNotEmpty ? [ placemarks.first.name, placemarks.first.street, placemarks.first.locality, placemarks.first.administrativeArea, placemarks.first.country, ].where((e) => e?.isNotEmpty == true).join(", ") : "${position.latitude}, ${position.longitude}"; } catch (e) { _errorSnackbar("Location error: $e"); } finally { isFetchingLocation.value = false; } } Future _ensureLocationPermission() async { var permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { _errorSnackbar("Location permission denied."); return false; } } if (!await Geolocator.isLocationServiceEnabled()) { _errorSnackbar("Location service disabled."); return false; } return true; } // --- Data Fetching --- Future loadMasterData() async => Future.wait([fetchMasterData(), fetchGlobalProjects()]); Future fetchMasterData() async { try { final types = await ApiService.getMasterExpenseTypes(); if (types is List) { expenseTypes.value = types .map((e) => ExpenseTypeModel.fromJson(e as Map)) .toList(); } final modes = await ApiService.getMasterPaymentModes(); if (modes is List) { paymentModes.value = modes .map((e) => PaymentModeModel.fromJson(e as Map)) .toList(); } } catch (_) { _errorSnackbar("Failed to fetch master data"); } } Future fetchGlobalProjects() async { try { final response = await ApiService.getGlobalProjects(); if (response != null) { final names = []; for (var item in response) { final name = item['name']?.toString().trim(); final id = item['id']?.toString().trim(); if (name != null && id != null) { projectsMap[name] = id; names.add(name); } } globalProjects.assignAll(names); } } catch (e) { logSafe("Error fetching projects: $e", level: LogLevel.error); } } // --- Submission --- Future submitOrUpdateExpense() async { if (isSubmitting.value) return; isSubmitting.value = true; try { final validationMsg = validateForm(); if (validationMsg.isNotEmpty) { _errorSnackbar(validationMsg, "Missing Fields"); return; } final payload = await _buildExpensePayload(); final success = await _submitToApi(payload); if (success) { await expenseController.fetchExpenses(); Get.back(); showAppSnackbar( title: "Success", message: "Expense ${isEditMode.value ? 'updated' : 'created'} successfully!", type: SnackbarType.success, ); } else { _errorSnackbar("Operation failed. Try again."); } } catch (e) { _errorSnackbar("Unexpected error: $e"); } finally { isSubmitting.value = false; } } Future _submitToApi(Map 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> _buildExpensePayload() async { final now = DateTime.now(); final existingPayload = isEditMode.value ? existingAttachments .map((e) => { "documentId": e['documentId'], "fileName": e['fileName'], "contentType": e['contentType'], "fileSize": 0, "description": "", "url": e['url'], "isActive": e['isActive'] ?? true, "base64Data": "", }) .toList() : >[]; final newPayload = await Future.wait( attachments.map((file) async { final bytes = await file.readAsBytes(); return { "fileName": file.path.split('/').last, "base64Data": base64Encode(bytes), "contentType": lookupMimeType(file.path) ?? 'application/octet-stream', "fileSize": await file.length(), "description": "", }; }), ); final type = selectedExpenseType.value!; 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 ?? now).toUtc().toIso8601String(), "transactionId": transactionIdController.text, "description": descriptionController.text, "location": locationController.text, "supplerName": supplierController.text, "amount": double.parse(amountController.text.trim()), "noOfPersons": type.noOfPersonsRequired == true ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 : 0, "billAttachments": [ ...existingPayload, ...newPayload, ].isEmpty ? null : [...existingPayload, ...newPayload], }; } String validateForm() { final missing = []; if (selectedProject.value.isEmpty) missing.add("Project"); if (selectedExpenseType.value == null) missing.add("Expense Type"); if (selectedPaymentMode.value == null) missing.add("Payment Mode"); if (selectedPaidBy.value == null) missing.add("Paid By"); if (amountController.text.trim().isEmpty) missing.add("Amount"); if (descriptionController.text.trim().isEmpty) missing.add("Description"); if (selectedTransactionDate.value == null) { missing.add("Transaction Date"); } else if (selectedTransactionDate.value!.isAfter(DateTime.now())) { missing.add("Valid Transaction Date"); } if (double.tryParse(amountController.text.trim()) == null) { missing.add("Valid Amount"); } final hasActiveExisting = existingAttachments.any((e) => e['isActive'] != false); 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); } }