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: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/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'; 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 employeeSearchController = TextEditingController(); // --- 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(); @override void onInit() { super.onInit(); fetchMasterData(); fetchGlobalProjects(); employeeSearchController.addListener(() { searchEmployees(employeeSearchController.text); }); } @override void onClose() { for (var c in [ amountController, descriptionController, supplierController, transactionIdController, gstController, locationController, transactionDateController, noOfPersonsController, employeeSearchController, ]) { 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()); employeeSearchResults.assignAll( (data ?? []).map((e) => EmployeeModel.fromJson(e)), ); } catch (e) { logSafe("Error searching employees: $e", level: LogLevel.error); employeeSearchResults.clear(); } finally { isSearchingEmployees.value = false; } } // --- Form Population: Edit Mode --- Future populateFieldsForEdit(Map data) async { isEditMode.value = true; editingExpenseId = '${data['id']}'; selectedProject.value = data['projectName'] ?? ''; amountController.text = data['amount']?.toString() ?? ''; supplierController.text = data['supplerName'] ?? ''; descriptionController.text = data['description'] ?? ''; transactionIdController.text = data['transactionId'] ?? ''; locationController.text = data['location'] ?? ''; noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString(); // Transaction Date if (data['transactionDate'] != null) { try { final parsed = DateTime.parse(data['transactionDate']); selectedTransactionDate.value = parsed; transactionDateController.text = DateFormat('dd-MM-yyyy').format(parsed); } catch (_) { selectedTransactionDate.value = null; transactionDateController.clear(); } } // Dropdown selectedExpenseType.value = expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']); selectedPaymentMode.value = paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']); // Paid By 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()); } // Attachments existingAttachments.clear(); if (data['attachments'] is List) { existingAttachments.addAll( List>.from(data['attachments']) .map((e) => {...e, 'isActive': true}), ); } _logPrefilledData(); } void _logPrefilledData() { logSafe('--- Prefilled Expense Data ---', level: LogLevel.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}', ].forEach((str) => logSafe(str, 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((path) => File(path))); } } catch (e) { _errorSnackbar("Attachment error: $e"); } } void removeAttachment(File file) => attachments.remove(file); // --- Location --- Future fetchCurrentLocation() async { isFetchingLocation.value = true; try { final permission = await _ensureLocationPermission(); if (!permission) 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 => await 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)).toList(); final modes = await ApiService.getMasterPaymentModes(); if (modes is List) paymentModes.value = modes.map((e) => PaymentModeModel.fromJson(e)).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(), 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 = 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'], ); 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> _buildExpensePayload() async { final now = DateTime.now(); final existingAttachmentPayloads = existingAttachments .map((e) => { "documentId": e['documentId'], "fileName": e['fileName'], "contentType": e['contentType'], "fileSize": 0, "description": "", "url": e['url'], "isActive": e['isActive'] ?? true, "base64Data": e['isActive'] == false ? null : e['base64Data'], }) .toList(); final newAttachmentPayloads = 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?.toUtc() ?? 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": [ ...existingAttachmentPayloads, ...newAttachmentPayloads ], }; } 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"); // Date Required if (selectedTransactionDate.value == null) missing.add("Transaction Date"); if (selectedTransactionDate.value != null && selectedTransactionDate.value!.isAfter(DateTime.now())) { missing.add("Valid Transaction Date"); } final amount = double.tryParse(amountController.text.trim()); if (amount == null) missing.add("Valid Amount"); // Attachment: at least one required at all times bool 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, ); }