added edit functioanllity in expense

This commit is contained in:
Vaibhav Surve 2025-08-05 17:41:36 +05:30
parent f1220cc018
commit 0acd619d78
5 changed files with 788 additions and 511 deletions

View File

@ -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<PaymentModeModel?> selectedPaymentMode = Rx<PaymentModeModel?>(null);
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null);
final Rx<ExpenseStatusModel?> selectedExpenseStatus = Rx<ExpenseStatusModel?>(null);
final Rx<EmployeeModel?> selectedPaidBy = Rx<EmployeeModel?>(null);
final RxString selectedProject = ''.obs;
final Rx<DateTime?> selectedTransactionDate = Rx<DateTime?>(null);
// Dropdown Selections
final selectedPaymentMode = Rx<PaymentModeModel?>(null);
final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
final selectedPaidBy = Rx<EmployeeModel?>(null);
final selectedProject = ''.obs;
final selectedTransactionDate = Rx<DateTime?>(null);
final RxList<File> attachments = <File>[].obs;
final RxList<String> globalProjects = <String>[].obs;
final RxList<String> projects = <String>[].obs;
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
final RxList<ExpenseStatusModel> expenseStatuses = <ExpenseStatusModel>[].obs;
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
// Data Lists
final attachments = <File>[].obs;
final globalProjects = <String>[].obs;
final projectsMap = <String, String>{}.obs;
final expenseTypes = <ExpenseTypeModel>[].obs;
final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs;
final existingAttachments = <Map<String, dynamic>>[].obs;
final RxMap<String, String> projectsMap = <String, String>{}.obs;
// Editing
String? editingExpenseId;
final ExpenseController expenseController = Get.find<ExpenseController>();
final expenseController = Get.find<ExpenseController>();
@override
void onInit() {
@ -71,6 +77,117 @@ class AddExpenseController extends GetxController {
super.onClose();
}
// ---------- Form Population for Edit ----------
void populateFieldsForEdit(Map<String, dynamic> 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<Map<String, dynamic>>.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<void> 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<void> loadMasterData() async {
await Future.wait([
fetchMasterData(),
fetchGlobalProjects(),
fetchAllEmployees(),
]);
}
Future<void> 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<String>().map((e) => File(e)).toList();
attachments.addAll(files);
if (result != null) {
attachments.addAll(
result.paths.whereType<String>().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<void> 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<void> submitExpense() async {
// ---------- Submission ----------
Future<void> submitOrUpdateExpense() async {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
List<String> 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<Map<String, dynamic>> _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<String, dynamic> 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 = <String>[];
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<void> 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();
}
}
}

View File

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

View File

@ -240,6 +240,48 @@ class ApiService {
}
// === Expense APIs === //
/// Edit Expense API
static Future<bool> editExpenseApi({
required String expenseId,
required Map<String, dynamic> 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<bool> deleteExpense(String expenseId) async {
final endpoint = "${ApiEndpoints.deleteExpense}/$expenseId";

View File

@ -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<T?> showAddExpenseBottomSheet<T>() {
return Get.bottomSheet<T>(
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<File> attachments;
final ValueChanged<File> onRemove;
final List<Map<String, dynamic>> existingAttachments;
final ValueChanged<File> onRemoveNew;
final ValueChanged<Map<String, dynamic>>? 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),
),
),
],
),
],
));

View File

@ -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<ProjectController>(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<ProjectController>(
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<ExpenseDocument> 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<ExpandableDescription> createState() => _ExpandableDescriptionState();
}
class _ExpandableDescriptionState extends State<ExpandableDescription> {
bool isExpanded = false;
@override
Widget build(BuildContext context) {
final isLong = widget.description.length > 100;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [