From 0acd619d7855aaa69336d5abc85463ff69735b38 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 5 Aug 2025 17:41:36 +0530 Subject: [PATCH] added edit functioanllity in expense --- .../expense/add_expense_controller.dart | 429 ++++++++---- lib/helpers/services/api_endpoints.dart | 6 +- lib/helpers/services/api_service.dart | 42 ++ .../expense/add_expense_bottom_sheet.dart | 160 ++++- lib/view/expense/expense_detail_screen.dart | 662 +++++++++--------- 5 files changed, 788 insertions(+), 511 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index a3c6b8a..adaa258 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -3,21 +3,23 @@ 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:get/get.dart'; +import 'package:intl/intl.dart'; import 'package:mime/mime.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_status_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 amountController = TextEditingController(); final descriptionController = TextEditingController(); final supplierController = TextEditingController(); @@ -25,30 +27,34 @@ class AddExpenseController extends GetxController { final gstController = TextEditingController(); final locationController = TextEditingController(); final transactionDateController = TextEditingController(); - final TextEditingController noOfPersonsController = TextEditingController(); + final noOfPersonsController = TextEditingController(); - final RxBool isLoading = false.obs; - final RxBool isSubmitting = false.obs; - final RxBool isFetchingLocation = false.obs; + // State + final isLoading = false.obs; + final isSubmitting = false.obs; + final isFetchingLocation = false.obs; + final isEditMode = false.obs; - final Rx selectedPaymentMode = Rx(null); - final Rx selectedExpenseType = Rx(null); - final Rx selectedExpenseStatus = Rx(null); - final Rx selectedPaidBy = Rx(null); - final RxString selectedProject = ''.obs; - final Rx selectedTransactionDate = Rx(null); + // Dropdown Selections + final selectedPaymentMode = Rx(null); + final selectedExpenseType = Rx(null); + final selectedPaidBy = Rx(null); + final selectedProject = ''.obs; + final selectedTransactionDate = Rx(null); - final RxList attachments = [].obs; - final RxList globalProjects = [].obs; - final RxList projects = [].obs; - final RxList expenseTypes = [].obs; - final RxList paymentModes = [].obs; - final RxList expenseStatuses = [].obs; - final RxList allEmployees = [].obs; + // Data Lists + final attachments = [].obs; + final globalProjects = [].obs; + final projectsMap = {}.obs; + final expenseTypes = [].obs; + final paymentModes = [].obs; + final allEmployees = [].obs; + final existingAttachments = >[].obs; - final RxMap projectsMap = {}.obs; + // Editing + String? editingExpenseId; - final ExpenseController expenseController = Get.find(); + final expenseController = Get.find(); @override void onInit() { @@ -71,6 +77,117 @@ class AddExpenseController extends GetxController { super.onClose(); } + // ---------- Form Population for Edit ---------- + void populateFieldsForEdit(Map data) { + isEditMode.value = true; + editingExpenseId = data['id']; + + // Basic fields + 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'] ?? ''; + + // Transaction Date + if (data['transactionDate'] != null) { + try { + final parsedDate = DateTime.parse(data['transactionDate']); + selectedTransactionDate.value = parsedDate; + transactionDateController.text = + DateFormat('dd-MM-yyyy').format(parsedDate); + } catch (e) { + logSafe('Error parsing transactionDate: $e', level: LogLevel.warning); + selectedTransactionDate.value = null; + transactionDateController.clear(); + } + } else { + selectedTransactionDate.value = null; + transactionDateController.clear(); + } + + // No of Persons + noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString(); + + // Select Expense Type and Payment Mode by matching IDs + selectedExpenseType.value = + expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']); + selectedPaymentMode.value = + paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']); + + // Select Paid By employee matching id (case insensitive, trimmed) + final paidById = data['paidById']?.toString().trim().toLowerCase() ?? ''; + selectedPaidBy.value = allEmployees + .firstWhereOrNull((e) => e.id.trim().toLowerCase() == paidById); + + if (selectedPaidBy.value == null && paidById.isNotEmpty) { + logSafe('⚠️ Could not match paidById: "$paidById"', + level: LogLevel.warning); + for (var emp in allEmployees) { + logSafe( + 'Employee ID: "${emp.id.trim().toLowerCase()}", Name: "${emp.name}"', + level: LogLevel.warning); + } + } + + // Populate existing attachments if present + existingAttachments.clear(); + if (data['attachments'] != null && data['attachments'] is List) { + existingAttachments + .addAll(List>.from(data['attachments'])); + } + + _logPrefilledData(); + } + + void _logPrefilledData() { + logSafe('--- Prefilled Expense Data ---', level: LogLevel.info); + logSafe('ID: $editingExpenseId', level: LogLevel.info); + logSafe('Project: ${selectedProject.value}', level: LogLevel.info); + logSafe('Amount: ${amountController.text}', level: LogLevel.info); + logSafe('Supplier: ${supplierController.text}', level: LogLevel.info); + logSafe('Description: ${descriptionController.text}', level: LogLevel.info); + logSafe('Transaction ID: ${transactionIdController.text}', + level: LogLevel.info); + logSafe('Location: ${locationController.text}', level: LogLevel.info); + logSafe('Transaction Date: ${transactionDateController.text}', + level: LogLevel.info); + logSafe('No. of Persons: ${noOfPersonsController.text}', + level: LogLevel.info); + logSafe('Expense Type: ${selectedExpenseType.value?.name}', + level: LogLevel.info); + logSafe('Payment Mode: ${selectedPaymentMode.value?.name}', + level: LogLevel.info); + logSafe('Paid By: ${selectedPaidBy.value?.name}', level: LogLevel.info); + logSafe('Attachments: ${attachments.length}', level: LogLevel.info); + logSafe('Existing Attachments: ${existingAttachments.length}', + level: LogLevel.info); + } + + // ---------- Form Actions ---------- + Future pickTransactionDate(BuildContext context) async { + final picked = await showDatePicker( + context: context, + initialDate: selectedTransactionDate.value ?? DateTime.now(), + firstDate: DateTime(DateTime.now().year - 5), + lastDate: DateTime.now(), + ); + + if (picked != null) { + selectedTransactionDate.value = picked; + transactionDateController.text = DateFormat('dd-MM-yyyy').format(picked); + } + } + + Future loadMasterData() async { + await Future.wait([ + fetchMasterData(), + fetchGlobalProjects(), + fetchAllEmployees(), + ]); + } + Future pickAttachments() async { try { final result = await FilePicker.platform.pickFiles( @@ -78,48 +195,34 @@ class AddExpenseController extends GetxController { allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'], allowMultiple: true, ); - if (result != null && result.paths.isNotEmpty) { - final files = result.paths.whereType().map((e) => File(e)).toList(); - attachments.addAll(files); + if (result != null) { + attachments.addAll( + result.paths.whereType().map((path) => File(path)), + ); } } catch (e) { showAppSnackbar( title: "Error", - message: "Failed to pick attachments: $e", + message: "Attachment error: $e", type: SnackbarType.error, ); } } - void removeAttachment(File file) { - attachments.remove(file); - } - - void pickTransactionDate(BuildContext context) async { - final now = DateTime.now(); - final picked = await showDatePicker( - context: context, - initialDate: selectedTransactionDate.value ?? now, - firstDate: DateTime(now.year - 5), - lastDate: now, - ); - if (picked != null) { - selectedTransactionDate.value = picked; - transactionDateController.text = - "${picked.day.toString().padLeft(2, '0')}-${picked.month.toString().padLeft(2, '0')}-${picked.year}"; - } - } + void removeAttachment(File file) => attachments.remove(file); Future fetchCurrentLocation() async { isFetchingLocation.value = true; try { - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { showAppSnackbar( title: "Error", - message: "Location permission denied. Enable in settings.", + message: "Location permission denied.", type: SnackbarType.error, ); return; @@ -129,24 +232,24 @@ class AddExpenseController extends GetxController { if (!await Geolocator.isLocationServiceEnabled()) { showAppSnackbar( title: "Error", - message: "Location services are disabled. Enable them.", + message: "Location service disabled.", type: SnackbarType.error, ); return; } - final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); - final placemarks = await placemarkFromCoordinates(position.latitude, position.longitude); + final position = await Geolocator.getCurrentPosition(); + final placemarks = + await placemarkFromCoordinates(position.latitude, position.longitude); if (placemarks.isNotEmpty) { final place = placemarks.first; final address = [ place.name, place.street, - place.subLocality, place.locality, place.administrativeArea, - place.country, + place.country ].where((e) => e != null && e.isNotEmpty).join(", "); locationController.text = address; } else { @@ -155,7 +258,7 @@ class AddExpenseController extends GetxController { } catch (e) { showAppSnackbar( title: "Error", - message: "Error fetching location: $e", + message: "Location error: $e", type: SnackbarType.error, ); } finally { @@ -163,112 +266,62 @@ class AddExpenseController extends GetxController { } } - Future submitExpense() async { + // ---------- Submission ---------- + Future submitOrUpdateExpense() async { if (isSubmitting.value) return; isSubmitting.value = true; try { - List 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.isEmpty) missing.add("Amount"); - if (supplierController.text.isEmpty) missing.add("Supplier Name"); - if (descriptionController.text.isEmpty) missing.add("Description"); - if (attachments.isEmpty) missing.add("Attachments"); - - if (missing.isNotEmpty) { + final validation = validateForm(); + if (validation.isNotEmpty) { showAppSnackbar( title: "Missing Fields", - message: "Please provide: ${missing.join(', ')}.", + message: validation, type: SnackbarType.error, ); return; } - final amount = double.tryParse(amountController.text); - if (amount == null) { - showAppSnackbar( - title: "Error", - message: "Please enter a valid amount.", - type: SnackbarType.error, - ); - return; - } + final payload = await _buildExpensePayload(); - final selectedDate = selectedTransactionDate.value ?? DateTime.now(); - if (selectedDate.isAfter(DateTime.now())) { - showAppSnackbar( - title: "Invalid Date", - message: "Transaction date cannot be in the future.", - type: SnackbarType.error, - ); - return; - } - - final projectId = projectsMap[selectedProject.value]; - if (projectId == null) { - showAppSnackbar( - title: "Error", - message: "Invalid project selected.", - type: SnackbarType.error, - ); - return; - } - - final billAttachments = await Future.wait(attachments.map((file) async { - final bytes = await file.readAsBytes(); - final base64 = base64Encode(bytes); - final mime = lookupMimeType(file.path) ?? 'application/octet-stream'; - final size = await file.length(); - - return { - "fileName": file.path.split('/').last, - "base64Data": base64, - "contentType": mime, - "fileSize": size, - "description": "", - }; - })); - - final success = await ApiService.createExpenseApi( - projectId: projectId, - expensesTypeId: selectedExpenseType.value!.id, - paymentModeId: selectedPaymentMode.value!.id, - paidById: selectedPaidBy.value!.id, - transactionDate: selectedTransactionDate.value?.toUtc() ?? DateTime.now().toUtc(), - transactionId: transactionIdController.text, - description: descriptionController.text, - location: locationController.text, - supplerName: supplierController.text, - amount: amount, - noOfPersons: selectedExpenseType.value?.noOfPersonsRequired == true - ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 - : 0, - billAttachments: billAttachments, - ); + 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 created successfully!", + message: + "Expense ${isEditMode.value ? 'updated' : 'created'} successfully!", type: SnackbarType.success, ); } else { showAppSnackbar( title: "Error", - message: "Failed to create expense. Try again.", + message: "Operation failed. Try again.", type: SnackbarType.error, ); } } catch (e) { showAppSnackbar( title: "Error", - message: "Something went wrong: $e", + message: "Unexpected error: $e", type: SnackbarType.error, ); } finally { @@ -276,27 +329,104 @@ class AddExpenseController extends GetxController { } } + Future> _buildExpensePayload() async { + final amount = double.parse(amountController.text.trim()); + final projectId = projectsMap[selectedProject.value]!; + final selectedDate = + selectedTransactionDate.value?.toUtc() ?? DateTime.now().toUtc(); + final existingAttachmentPayloads = existingAttachments + .map((e) => { + "fileName": e['fileName'], + "contentType": e['contentType'], + "fileSize": 0, // optional or populate if known + "description": "", + "url": e['url'], // custom field if your backend accepts + }) + .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 billAttachments = [ + ...existingAttachmentPayloads, + ...newAttachmentPayloads + ]; + + final Map payload = { + "projectId": projectId, + "expensesTypeId": selectedExpenseType.value!.id, + "paymentModeId": selectedPaymentMode.value!.id, + "paidById": selectedPaidBy.value!.id, + "transactionDate": selectedDate.toIso8601String(), + "transactionId": transactionIdController.text, + "description": descriptionController.text, + "location": locationController.text, + "supplerName": supplierController.text, + "amount": amount, + "noOfPersons": selectedExpenseType.value?.noOfPersonsRequired == true + ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 + : 0, + "billAttachments": billAttachments, + }; + + // ✅ Add expense ID if in edit mode + if (isEditMode.value && editingExpenseId != null) { + payload['id'] = editingExpenseId; + } + + return payload; + } + + 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 (supplierController.text.trim().isEmpty) missing.add("Supplier Name"); + if (descriptionController.text.trim().isEmpty) missing.add("Description"); + if (!isEditMode.value && attachments.isEmpty) missing.add("Attachments"); + + final amount = double.tryParse(amountController.text.trim()); + if (amount == null) missing.add("Valid Amount"); + + final selectedDate = selectedTransactionDate.value; + if (selectedDate != null && selectedDate.isAfter(DateTime.now())) { + missing.add("Valid Transaction Date"); + } + + return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}."; + } + + // ---------- Data Fetching ---------- Future fetchMasterData() async { try { - final expenseTypesData = await ApiService.getMasterExpenseTypes(); - final paymentModesData = await ApiService.getMasterPaymentModes(); - final expenseStatusData = await ApiService.getMasterExpenseStatus(); + final types = await ApiService.getMasterExpenseTypes(); + final modes = await ApiService.getMasterPaymentModes(); - if (expenseTypesData is List) { - expenseTypes.value = expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + if (types is List) { + expenseTypes.value = + types.map((e) => ExpenseTypeModel.fromJson(e)).toList(); } - if (paymentModesData is List) { - paymentModes.value = paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); - } - - if (expenseStatusData is List) { - expenseStatuses.value = expenseStatusData.map((e) => ExpenseStatusModel.fromJson(e)).toList(); + if (modes is List) { + paymentModes.value = + modes.map((e) => PaymentModeModel.fromJson(e)).toList(); } } catch (e) { showAppSnackbar( title: "Error", - message: "Failed to fetch master data: $e", + message: "Failed to fetch master data", type: SnackbarType.error, ); } @@ -310,16 +440,15 @@ class AddExpenseController extends GetxController { for (var item in response) { final name = item['name']?.toString().trim(); final id = item['id']?.toString().trim(); - if (name != null && id != null && name.isNotEmpty) { + if (name != null && id != null) { projectsMap[name] = id; names.add(name); } } globalProjects.assignAll(names); - logSafe("Fetched ${names.length} global projects"); } } catch (e) { - logSafe("Failed to fetch global projects: $e", level: LogLevel.error); + logSafe("Error fetching projects: $e", level: LogLevel.error); } } @@ -327,19 +456,13 @@ class AddExpenseController extends GetxController { isLoading.value = true; try { final response = await ApiService.getAllEmployees(); - if (response != null && response.isNotEmpty) { + if (response != null) { allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); - logSafe("All Employees fetched: ${allEmployees.length}", level: LogLevel.info); - } else { - allEmployees.clear(); - logSafe("No employees found.", level: LogLevel.warning); } } catch (e) { - allEmployees.clear(); - logSafe("Error fetching employees", level: LogLevel.error, error: e); + logSafe("Error fetching employees: $e", level: LogLevel.error); } finally { isLoading.value = false; - update(); } } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 438a6f8..1a98492 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -15,8 +15,8 @@ class ApiEndpoints { static const String uploadAttendanceImage = "/attendance/record-image"; // Employee Screen API Endpoints - static const String getAllEmployeesByProject = "/Employee/basic"; - static const String getAllEmployees = "/Employee/basic"; + static const String getAllEmployeesByProject = "/employee/list"; + static const String getAllEmployees = "/employee/list"; static const String getRoles = "/roles/jobrole"; static const String createEmployee = "/employee/manage-mobile"; static const String getEmployeeInfo = "/employee/profile/get"; @@ -55,7 +55,7 @@ class ApiEndpoints { static const String getExpenseList = "/expense/list"; static const String getExpenseDetails = "/expense/details"; static const String createExpense = "/expense/create"; - static const String updateExpense = "/expense/manage"; + static const String editExpense = "/Expense/edit"; static const String getMasterPaymentModes = "/master/payment-modes"; static const String getMasterExpenseStatus = "/master/expenses-status"; static const String getMasterExpenseTypes = "/master/expenses-types"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 1ab3685..350eb8b 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -240,6 +240,48 @@ class ApiService { } // === Expense APIs === // + + /// Edit Expense API + static Future editExpenseApi({ + required String expenseId, + required Map payload, + }) async { + final endpoint = "${ApiEndpoints.editExpense}/$expenseId"; + logSafe("Editing expense $expenseId with payload: $payload"); + + try { + final response = await _putRequest( + endpoint, + payload, + customTimeout: extendedTimeout, + ); + + if (response == null) { + logSafe("Edit expense failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Edit expense response status: ${response.statusCode}"); + logSafe("Edit expense response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Expense updated successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to update expense: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during editExpenseApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + static Future deleteExpense(String expenseId) async { final endpoint = "${ApiEndpoints.deleteExpense}/$expenseId"; diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 2d71f5b..b5b4cec 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -6,9 +6,15 @@ import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; -void showAddExpenseBottomSheet() { - Get.bottomSheet(const _AddExpenseBottomSheet(), isScrollControlled: true); +Future showAddExpenseBottomSheet() { + return Get.bottomSheet( + const _AddExpenseBottomSheet(), + isScrollControlled: true, + ); } class _AddExpenseBottomSheet extends StatefulWidget { @@ -90,7 +96,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { onCancel: Get.back, onSubmit: () { if (!controller.isSubmitting.value) { - controller.submitExpense(); + controller.submitOrUpdateExpense(); } }, child: Column( @@ -267,7 +273,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { MySpacing.height(6), _AttachmentsSection( attachments: controller.attachments, - onRemove: controller.removeAttachment, + existingAttachments: controller.existingAttachments, + onRemoveNew: controller.removeAttachment, + onRemoveExisting: (item) => + controller.existingAttachments.remove(item), onAdd: controller.pickAttachments, ), MySpacing.height(16), @@ -447,37 +456,140 @@ class _TileContainer extends StatelessWidget { class _AttachmentsSection extends StatelessWidget { final RxList attachments; - final ValueChanged onRemove; + final List> existingAttachments; + final ValueChanged onRemoveNew; + final ValueChanged>? onRemoveExisting; final VoidCallback onAdd; const _AttachmentsSection({ required this.attachments, - required this.onRemove, + required this.existingAttachments, + required this.onRemoveNew, + this.onRemoveExisting, required this.onAdd, }); @override Widget build(BuildContext context) { - return Obx(() => Wrap( - spacing: 8, - runSpacing: 8, + return Obx(() => Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ...attachments.map((file) => _AttachmentTile( - file: file, - onRemove: () => onRemove(file), - )), - 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.add, size: 30, color: Colors.grey), + if (existingAttachments.isNotEmpty) ...[ + Text( + "Existing Attachments", + style: const TextStyle(fontWeight: FontWeight.w600), ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: existingAttachments.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) { + final imageDocs = existingAttachments + .where((d) => (d['contentType'] + ?.toString() + .startsWith('image/') ?? + false)) + .toList(); + final initialIndex = imageDocs.indexWhere((d) => d == doc); + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: imageDocs.map((e) => e['url']).toList(), + initialIndex: initialIndex, + ), + ); + } 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: 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!(doc), + ), + ), + ], + ); + }).toList(), + ), + const SizedBox(height: 16), + ], + + // New attachments section - shows preview tiles + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...attachments.map((file) => _AttachmentTile( + file: file, + onRemove: () => onRemoveNew(file), + )), + 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.add, size: 30, color: Colors.grey), + ), + ), + ], ), ], )); diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 102bb97..99a9c96 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; +import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; + import 'package:marco/controller/expense/expense_detail_controller.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/permission_controller.dart'; - import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; @@ -14,6 +15,8 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:marco/model/expense/reimbursement_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/expense/comment_bottom_sheet.dart'; +import 'package:marco/controller/expense/add_expense_controller.dart'; +import 'package:marco/helpers/services/app_logger.dart'; class ExpenseDetailScreen extends StatelessWidget { final String expenseId; @@ -48,26 +51,275 @@ class ExpenseDetailScreen extends StatelessWidget { return Scaffold( backgroundColor: const Color(0xFFF7F7F7), - appBar: AppBar( - automaticallyImplyLeading: false, - elevation: 1, - backgroundColor: Colors.white, - title: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'), + appBar: _AppBar(projectController: projectController), + body: SafeArea( + child: Obx(() { + if (controller.isLoading.value) return _buildLoadingSkeleton(); + final expense = controller.expense.value; + if (controller.errorMessage.isNotEmpty || expense == null) { + return Center(child: MyText.bodyMedium("No data to display.")); + } + final statusColor = getStatusColor(expense.status.name, + colorCode: expense.status.color); + final formattedAmount = _formatAmount(expense.amount); + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 8, 8, 8, 30 + MediaQuery.of(context).padding.bottom), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 520), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 3, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, horizontal: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InvoiceHeader(expense: expense), + const Divider(height: 30, thickness: 1.2), + _InvoiceParties(expense: expense), + const Divider(height: 30, thickness: 1.2), + _InvoiceDetailsTable(expense: expense), + const Divider(height: 30, thickness: 1.2), + _InvoiceDocuments(documents: expense.documents), + const Divider(height: 30, thickness: 1.2), + _InvoiceTotals( + expense: expense, + formattedAmount: formattedAmount, + statusColor: statusColor), + ], + ), + ), + ), + ), ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleLarge('Expense Details', - fontWeight: 700, color: Colors.black), - MySpacing.height(2), - GetBuilder(builder: (_) { + ); + }), + ), + floatingActionButton: Obx(() { + final expense = controller.expense.value; + if (expense == null) return const SizedBox.shrink(); + + // Allowed status Ids + const allowedStatusIds = [ + "d1ee5eec-24b6-4364-8673-a8f859c60729", + "965eda62-7907-4963-b4a1-657fb0b2724b", + "297e0d8f-f668-41b5-bfea-e03b354251c8" + ]; + + // Show edit button only if status id is in allowedStatusIds + if (!allowedStatusIds.contains(expense.status.id)) { + return const SizedBox.shrink(); + } + + return FloatingActionButton( + onPressed: () async { + final editData = { + 'id': expense.id, + 'projectName': expense.project.name, + 'amount': expense.amount, + 'supplerName': expense.supplerName, + 'description': expense.description, + 'transactionId': expense.transactionId, + 'location': expense.location, + 'transactionDate': expense.transactionDate, + 'noOfPersons': expense.noOfPersons, + 'expensesTypeId': expense.expensesType.id, + 'paymentModeId': expense.paymentMode.id, + 'paidById': expense.paidBy.id, + 'attachments': expense.documents + .map((doc) => { + 'url': doc.preSignedUrl, + 'fileName': doc.fileName, + 'documentId': doc.documentId, + 'contentType': doc.contentType, + }) + .toList(), + }; + logSafe('editData: $editData', level: LogLevel.info); + + final addCtrl = Get.put(AddExpenseController()); + + await addCtrl.loadMasterData(); + addCtrl.populateFieldsForEdit(editData); + + await showAddExpenseBottomSheet(); + + // Refresh expense details after editing + await controller.fetchExpenseDetails(); + }, + backgroundColor: Colors.red, + tooltip: 'Edit Expense', + child: Icon(Icons.edit), + ); + }), + bottomNavigationBar: Obx(() { + final expense = controller.expense.value; + if (expense == null || expense.nextStatus.isEmpty) { + return const SizedBox(); + } + return SafeArea( + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Color(0x11000000))), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: expense.nextStatus + .where((next) => permissionController.hasAnyPermission( + controller.parsePermissionIds(next.permissionIds))) + .map((next) => + _statusButton(context, controller, expense, next)) + .toList(), + ), + ), + ); + }), + ); + } + + Widget _statusButton(BuildContext context, ExpenseDetailController controller, + ExpenseDetailModel expense, dynamic next) { + Color buttonColor = Colors.red; + if (next.color.isNotEmpty) { + try { + buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff'))); + } catch (_) {} + } + return ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(100, 40), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + backgroundColor: buttonColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + onPressed: () async { + // For brevity, couldn't refactor the logic since it's business-specific. + const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; + if (expense.status.id == reimbursementId) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (context) => ReimbursementBottomSheet( + expenseId: expense.id, + statusId: next.id, + onClose: () {}, + onSubmit: ({ + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + required String statusId, + }) async { + final success = + await controller.updateExpenseStatusWithReimbursement( + comment: comment, + reimburseTransactionId: reimburseTransactionId, + reimburseDate: reimburseDate, + reimburseById: reimburseById, + statusId: statusId, + ); + if (success) { + showAppSnackbar( + title: 'Success', + message: 'Expense reimbursed successfully.', + type: SnackbarType.success); + await controller.fetchExpenseDetails(); + return true; + } else { + showAppSnackbar( + title: 'Error', + message: 'Failed to reimburse expense.', + type: SnackbarType.error); + return false; + } + }, + ), + ); + } else { + final comment = await showCommentBottomSheet(context, next.name); + if (comment == null) return; + final success = + await controller.updateExpenseStatus(next.id, comment: comment); + if (success) { + showAppSnackbar( + title: 'Success', + message: + 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', + type: SnackbarType.success); + await controller.fetchExpenseDetails(); + } else { + showAppSnackbar( + title: 'Error', + message: 'Failed to update status.', + type: SnackbarType.error); + } + } + }, + child: MyText.labelMedium( + next.displayName.isNotEmpty ? next.displayName : next.name, + color: Colors.white, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + ), + ); + } + + static String _formatAmount(double amount) { + return NumberFormat.currency( + locale: 'en_IN', symbol: '₹ ', decimalDigits: 2) + .format(amount); + } + + Widget _buildLoadingSkeleton() { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 5, + itemBuilder: (_, __) => Container( + margin: const EdgeInsets.only(bottom: 16), + height: 80, + decoration: BoxDecoration( + color: Colors.grey[300], borderRadius: BorderRadius.circular(10)), + ), + ); + } +} + +class _AppBar extends StatelessWidget implements PreferredSizeWidget { + final ProjectController projectController; + const _AppBar({required this.projectController}); + @override + Widget build(BuildContext context) { + return AppBar( + automaticallyImplyLeading: false, + elevation: 1, + backgroundColor: Colors.white, + title: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge('Expense Details', + fontWeight: 700, color: Colors.black), + MySpacing.height(2), + GetBuilder( + builder: (_) { final projectName = projectController.selectedProject?.name ?? 'Select Project'; @@ -86,277 +338,58 @@ class ExpenseDetailScreen extends StatelessWidget { ), ], ); - }), - ], - ), - ), - ], - ), - ), - body: SafeArea( - child: Obx(() { - if (controller.isLoading.value) { - return _buildLoadingSkeleton(); - } - - final expense = controller.expense.value; - - if (controller.errorMessage.isNotEmpty || expense == null) { - return Center( - child: MyText.bodyMedium("No data to display."), - ); - } - - final statusColor = getStatusColor(expense.status.name, - colorCode: expense.status.color); - final formattedAmount = NumberFormat.currency( - locale: 'en_IN', - symbol: '₹ ', - decimalDigits: 2, - ).format(expense.amount); - - // === CHANGE: Add proper bottom padding to always keep content away from device nav bar === - return SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - 8, - 8, - 8, - 16 + MediaQuery.of(context).padding.bottom, // KEY LINE - ), - child: Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 520), - child: Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - elevation: 3, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 14, horizontal: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _InvoiceHeader(expense: expense), - Divider(height: 30, thickness: 1.2), - _InvoiceParties(expense: expense), - Divider(height: 30, thickness: 1.2), - _InvoiceDetailsTable(expense: expense), - Divider(height: 30, thickness: 1.2), - _InvoiceDocuments(documents: expense.documents), - Divider(height: 30, thickness: 1.2), - _InvoiceTotals( - expense: expense, - formattedAmount: formattedAmount, - statusColor: statusColor, - ), - ], - ), - ), - ), - ), - ), - ); - }), - ), - bottomNavigationBar: Obx(() { - final expense = controller.expense.value; - if (expense == null || expense.nextStatus.isEmpty) { - return const SizedBox(); - } - return SafeArea( - child: Container( - decoration: const BoxDecoration( - color: Colors.white, - border: Border(top: BorderSide(color: Color(0x11000000))), - ), - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - child: Wrap( - alignment: WrapAlignment.center, - spacing: 10, - runSpacing: 10, - children: expense.nextStatus.where((next) { - return permissionController.hasAnyPermission( - controller.parsePermissionIds(next.permissionIds), - ); - }).map((next) { - Color buttonColor = Colors.red; - if (next.color.isNotEmpty) { - try { - buttonColor = - Color(int.parse(next.color.replaceFirst('#', '0xff'))); - } catch (_) {} - } - return ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(100, 40), - padding: - const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - backgroundColor: buttonColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6)), - ), - onPressed: () async { - const reimbursementId = - 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; - - if (expense.status.id == reimbursementId) { - // Open reimbursement flow - showModalBottomSheet( - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: - BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (context) => ReimbursementBottomSheet( - expenseId: expense.id, - statusId: next.id, - onClose: () {}, - onSubmit: ({ - required String comment, - required String reimburseTransactionId, - required String reimburseDate, - required String reimburseById, - required String statusId, - }) async { - final success = await controller - .updateExpenseStatusWithReimbursement( - comment: comment, - reimburseTransactionId: reimburseTransactionId, - reimburseDate: reimburseDate, - reimburseById: reimburseById, - statusId: statusId, - ); - - if (success) { - showAppSnackbar( - title: 'Success', - message: 'Expense reimbursed successfully.', - type: SnackbarType.success, - ); - await controller.fetchExpenseDetails(); - return true; - } else { - showAppSnackbar( - title: 'Error', - message: 'Failed to reimburse expense.', - type: SnackbarType.error, - ); - return false; - } - }, - ), - ); - } else { - // ✨ New: Show comment sheet - final comment = - await showCommentBottomSheet(context, next.name); - if (comment == null) return; - - final success = await controller.updateExpenseStatus( - next.id, - comment: comment, - ); - - if (success) { - showAppSnackbar( - title: 'Success', - message: - 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', - type: SnackbarType.success, - ); - await controller.fetchExpenseDetails(); - } else { - showAppSnackbar( - title: 'Error', - message: 'Failed to update status.', - type: SnackbarType.error, - ); - } - } }, - child: MyText.labelMedium( - next.displayName.isNotEmpty ? next.displayName : next.name, - color: Colors.white, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - ), - ); - }).toList(), + ), + ], ), ), - ); - }), + ], + ), ); } - Widget _buildLoadingSkeleton() { - return ListView( - padding: const EdgeInsets.all(16), - children: List.generate(5, (index) { - return Container( - margin: const EdgeInsets.only(bottom: 16), - height: 80, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(10), - ), - ); - }), - ); - } + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); } -// ---------------- INVOICE SUB-COMPONENTS ---------------- +// -------- Invoice Sub-Components, unchanged except formatting/const ---------------- class _InvoiceHeader extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceHeader({required this.expense}); - @override Widget build(BuildContext context) { final dateString = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toString(), format: 'dd-MM-yyyy'); - final statusColor = ExpenseDetailScreen.getStatusColor(expense.status.name, colorCode: expense.status.color); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Row(children: [ + const Icon(Icons.calendar_month, size: 18, color: Colors.grey), + MySpacing.width(6), + MyText.bodySmall('Date:', fontWeight: 600), + MySpacing.width(6), + MyText.bodySmall(dateString, fontWeight: 600), + ]), + Container( + decoration: BoxDecoration( + color: statusColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Row( children: [ - const Icon(Icons.calendar_month, size: 18, color: Colors.grey), - MySpacing.width(6), - MyText.bodySmall('Date:', fontWeight: 600), - MySpacing.width(6), - MyText.bodySmall(dateString, fontWeight: 600), + Icon(Icons.flag, size: 16, color: statusColor), + MySpacing.width(4), + MyText.labelSmall(expense.status.name, + color: statusColor, fontWeight: 600), ], ), - Container( - decoration: BoxDecoration( - color: statusColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - child: Row( - children: [ - Icon(Icons.flag, size: 16, color: statusColor), - MySpacing.width(4), - MyText.labelSmall( - expense.status.name, - color: statusColor, - fontWeight: 600, - ), - ], - ), - ), - ], - ) + ), + ]) ], ); } @@ -365,7 +398,6 @@ class _InvoiceHeader extends StatelessWidget { class _InvoiceParties extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceParties({required this.expense}); - @override Widget build(BuildContext context) { return Column( @@ -373,45 +405,31 @@ class _InvoiceParties extends StatelessWidget { children: [ _labelValueBlock('Project', expense.project.name), MySpacing.height(16), - _labelValueBlock( - 'Paid By:', - '${expense.paidBy.firstName} ${expense.paidBy.lastName}', - ), + _labelValueBlock('Paid By:', + '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), MySpacing.height(16), _labelValueBlock('Supplier', expense.supplerName), MySpacing.height(16), - _labelValueBlock( - 'Created By:', - '${expense.createdBy.firstName} ${expense.createdBy.lastName}', - ), + _labelValueBlock('Created By:', + '${expense.createdBy.firstName} ${expense.createdBy.lastName}'), ], ); } - Widget _labelValueBlock(String label, String value) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall( - label, - fontWeight: 600, - ), - MySpacing.height(4), - MyText.bodySmall( - value, - fontWeight: 500, - softWrap: true, - maxLines: null, // Allow full wrapping - ), - ], - ); - } + Widget _labelValueBlock(String label, String value) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(label, fontWeight: 600), + MySpacing.height(4), + MyText.bodySmall(value, + fontWeight: 500, softWrap: true, maxLines: null), + ], + ); } class _InvoiceDetailsTable extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceDetailsTable({required this.expense}); - @override Widget build(BuildContext context) { final transactionDate = DateTimeUtils.convertUtcToLocal( @@ -420,7 +438,6 @@ class _InvoiceDetailsTable extends StatelessWidget { final createdAt = DateTimeUtils.convertUtcToLocal( expense.createdAt.toString(), format: 'dd-MM-yyyy hh:mm a'); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -436,39 +453,30 @@ class _InvoiceDetailsTable extends StatelessWidget { ); } - Widget _detailItem(String title, String value, {bool isDescription = false}) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall( - title, - fontWeight: 600, - ), - MySpacing.height(3), - isDescription - ? ExpandableDescription(description: value) - : MyText.bodySmall( - value, - fontWeight: 500, - ), - ], - ), - ); - } + Widget _detailItem(String title, String value, + {bool isDescription = false}) => + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(title, fontWeight: 600), + MySpacing.height(3), + isDescription + ? ExpandableDescription(description: value) + : MyText.bodySmall(value, fontWeight: 500), + ], + ), + ); } class _InvoiceDocuments extends StatelessWidget { final List documents; const _InvoiceDocuments({required this.documents}); - @override Widget build(BuildContext context) { - if (documents.isEmpty) { + if (documents.isEmpty) return MyText.bodyMedium('No Supporting Documents', color: Colors.grey); - } - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -481,16 +489,13 @@ class _InvoiceDocuments extends StatelessWidget { separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, index) { final doc = documents[index]; - return GestureDetector( onTap: () async { final imageDocs = documents .where((d) => d.contentType.startsWith('image/')) .toList(); - final initialIndex = imageDocs.indexWhere((d) => d.documentId == doc.documentId); - if (imageDocs.isNotEmpty && initialIndex != -1) { showDialog( context: context, @@ -506,10 +511,9 @@ class _InvoiceDocuments extends StatelessWidget { await launchUrl(url, mode: LaunchMode.externalApplication); } else { showAppSnackbar( - title: 'Error', - message: 'Could not open the document.', - type: SnackbarType.error, - ); + title: 'Error', + message: 'Could not open the document.', + type: SnackbarType.error); } } }, @@ -557,7 +561,6 @@ class _InvoiceTotals extends StatelessWidget { required this.formattedAmount, required this.statusColor, }); - @override Widget build(BuildContext context) { return Row( @@ -573,18 +576,15 @@ class _InvoiceTotals extends StatelessWidget { class ExpandableDescription extends StatefulWidget { final String description; const ExpandableDescription({super.key, required this.description}); - @override State createState() => _ExpandableDescriptionState(); } class _ExpandableDescriptionState extends State { bool isExpanded = false; - @override Widget build(BuildContext context) { final isLong = widget.description.length > 100; - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [