From af83d66390118935912f6a2d5f3ca1c0284d84a8 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 19 Jul 2025 20:15:54 +0530 Subject: [PATCH] feat: Add expense models and update expense detail screen - Created ExpenseModel, Project, ExpenseType, PaymentMode, PaidBy, CreatedBy, and Status classes for expense management. - Implemented JSON serialization and deserialization for expense models. - Added ExpenseStatusModel and ExpenseTypeModel for handling status and type of expenses. - Introduced PaymentModeModel for managing payment modes. - Refactored ExpenseDetailScreen to utilize the new ExpenseModel structure. - Enhanced UI components for better display of expense details. - Added search and filter functionality in ExpenseMainScreen. - Updated dependencies in pubspec.yaml to include geocoding package. --- android/app/src/main/AndroidManifest.xml | 1 + .../expense/add_expense_controller.dart | 310 +++++++ .../expense/expense_screen_controller.dart | 42 + lib/helpers/services/api_endpoints.dart | 19 +- lib/helpers/services/api_service.dart | 107 +++ .../expense/add_expense_bottom_sheet.dart | 767 ++++++++++++------ lib/model/expense/expense_list_model.dart | 268 ++++++ lib/model/expense/expense_status_model.dart | 25 + lib/model/expense/expense_type_model.dart | 25 + lib/model/expense/payment_types_model.dart | 22 + lib/view/expense/expense_detail_screen.dart | 305 ++++--- lib/view/expense/expense_screen.dart | 746 +++++++++-------- pubspec.yaml | 1 + 13 files changed, 1918 insertions(+), 720 deletions(-) create mode 100644 lib/controller/expense/add_expense_controller.dart create mode 100644 lib/controller/expense/expense_screen_controller.dart create mode 100644 lib/model/expense/expense_list_model.dart create mode 100644 lib/model/expense/expense_status_model.dart create mode 100644 lib/model/expense/expense_type_model.dart create mode 100644 lib/model/expense/payment_types_model.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8ae7375..1e9ad59 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart new file mode 100644 index 0000000..bb09b1c --- /dev/null +++ b/lib/controller/expense/add_expense_controller.dart @@ -0,0 +1,310 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:mime/mime.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/expense_status_model.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/employee_model.dart'; + +class AddExpenseController extends GetxController { + // === Text Controllers === + final amountController = TextEditingController(); + final descriptionController = TextEditingController(); + final supplierController = TextEditingController(); + final transactionIdController = TextEditingController(); + final gstController = TextEditingController(); + final locationController = TextEditingController(); + + // === Project Mapping === + final RxMap projectsMap = {}.obs; + + // === Selected Models === + final Rx selectedPaymentMode = Rx(null); + final Rx selectedExpenseType = Rx(null); + final Rx selectedExpenseStatus = + Rx(null); + final RxString selectedProject = ''.obs; + final Rx selectedPaidBy = Rx(null); + // === States === + final RxBool preApproved = false.obs; + final RxBool isFetchingLocation = false.obs; + final Rx selectedTransactionDate = Rx(null); + + // === Master Data === + final RxList projects = [].obs; + final RxList expenseTypes = [].obs; + final RxList paymentModes = [].obs; + final RxList expenseStatuses = [].obs; + final RxList globalProjects = [].obs; + + // === Attachments === + final RxList attachments = [].obs; + RxList allEmployees = [].obs; + RxBool isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + fetchMasterData(); + fetchGlobalProjects(); + fetchAllEmployees(); + } + + @override + void onClose() { + amountController.dispose(); + descriptionController.dispose(); + supplierController.dispose(); + transactionIdController.dispose(); + gstController.dispose(); + locationController.dispose(); + super.onClose(); + } + + // === Pick Attachments === + Future pickAttachments() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'], + allowMultiple: true, + ); + + if (result != null && result.paths.isNotEmpty) { + final newFiles = + result.paths.whereType().map((e) => File(e)).toList(); + attachments.addAll(newFiles); + } + } catch (e) { + Get.snackbar("Error", "Failed to pick attachments: $e"); + } + } + + void removeAttachment(File file) { + attachments.remove(file); + } + + // === Fetch Master Data === + Future fetchMasterData() async { + try { + final expenseTypesData = await ApiService.getMasterExpenseTypes(); + if (expenseTypesData is List) { + expenseTypes.value = + expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + } + + final paymentModesData = await ApiService.getMasterPaymentModes(); + if (paymentModesData is List) { + paymentModes.value = + paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); + } + + final expenseStatusData = await ApiService.getMasterExpenseStatus(); + if (expenseStatusData is List) { + expenseStatuses.value = expenseStatusData + .map((e) => ExpenseStatusModel.fromJson(e)) + .toList(); + } + } catch (e) { + Get.snackbar("Error", "Failed to fetch master data: $e"); + } + } + + // === Fetch Current Location === + Future fetchCurrentLocation() async { + isFetchingLocation.value = true; + try { + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + Get.snackbar( + "Error", "Location permission denied. Enable in settings."); + return; + } + } + + if (!await Geolocator.isLocationServiceEnabled()) { + Get.snackbar("Error", "Location services are disabled. Enable them."); + return; + } + + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + + final placemarks = await placemarkFromCoordinates( + position.latitude, + position.longitude, + ); + + if (placemarks.isNotEmpty) { + final place = placemarks.first; + final addressParts = [ + place.name, + place.street, + place.subLocality, + place.locality, + place.administrativeArea, + place.country, + ].where((part) => part != null && part.isNotEmpty).toList(); + + locationController.text = addressParts.join(", "); + } else { + locationController.text = "${position.latitude}, ${position.longitude}"; + } + } catch (e) { + Get.snackbar("Error", "Error fetching location: $e"); + } finally { + isFetchingLocation.value = false; + } + } + + // === Submit Expense === + Future submitExpense() async { + // Validation for required fields + if (selectedProject.value.isEmpty || + selectedExpenseType.value == null || + selectedPaymentMode.value == null || + descriptionController.text.isEmpty || + supplierController.text.isEmpty || + amountController.text.isEmpty || + selectedExpenseStatus.value == null || + attachments.isEmpty) { + showAppSnackbar( + title: "Error", + message: "Please fill all required fields.", + type: SnackbarType.error, + ); + return; + } + + final double? amount = double.tryParse(amountController.text); + if (amount == null) { + showAppSnackbar( + title: "Error", + message: "Please enter a valid amount.", + type: SnackbarType.error, + ); + return; + } + + final projectId = projectsMap[selectedProject.value]; + if (projectId == null) { + showAppSnackbar( + title: "Error", + message: "Invalid project selection.", + type: SnackbarType.error, + ); + return; + } + + // Convert attachments to base64 + meta + final attachmentData = await Future.wait(attachments.map((file) async { + final bytes = await file.readAsBytes(); + final base64String = base64Encode(bytes); + final mimeType = lookupMimeType(file.path) ?? 'application/octet-stream'; + final fileSize = await file.length(); + + return { + "fileName": file.path.split('/').last, + "base64Data": base64String, + "contentType": mimeType, + "fileSize": fileSize, + "description": "", + }; + }).toList()); + + // Submit API call + final success = await ApiService.createExpenseApi( + projectId: projectId, + expensesTypeId: selectedExpenseType.value!.id, + paymentModeId: selectedPaymentMode.value!.id, + paidById: selectedPaidBy.value?.id ?? "", + transactionDate:(selectedTransactionDate.value ?? DateTime.now()).toUtc(), + transactionId: transactionIdController.text, + description: descriptionController.text, + location: locationController.text, + supplerName: supplierController.text, + amount: amount, + noOfPersons: 0, + statusId: selectedExpenseStatus.value!.id, + billAttachments: attachmentData, + ); + + if (success) { + Get.back(); + showAppSnackbar( + title: "Success", + message: "Expense created successfully!", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to create expense. Try again.", + type: SnackbarType.error, + ); + } + } + + // === Fetch Projects === + Future fetchGlobalProjects() async { + try { + final response = await ApiService.getGlobalProjects(); + if (response != null) { + final names = []; + for (var item in response) { + final name = item['name']?.toString().trim(); + final id = item['id']?.toString().trim(); + if (name != null && id != null && name.isNotEmpty) { + 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); + } + } + + // === Fetch All Employees === + Future fetchAllEmployees() async { + isLoading.value = true; + + try { + final response = await ApiService.getAllEmployees(); + if (response != null && response.isNotEmpty) { + allEmployees + .assignAll(response.map((json) => EmployeeModel.fromJson(json))); + logSafe( + "All Employees fetched for Manage Bucket: ${allEmployees.length}", + level: LogLevel.info, + ); + } else { + allEmployees.clear(); + logSafe("No employees found for Manage Bucket.", + level: LogLevel.warning); + } + } catch (e) { + allEmployees.clear(); + logSafe("Error fetching employees in Manage Bucket", + level: LogLevel.error, error: e); + } + + isLoading.value = false; + update(); + } +} diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart new file mode 100644 index 0000000..6568aa3 --- /dev/null +++ b/lib/controller/expense/expense_screen_controller.dart @@ -0,0 +1,42 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; + +class ExpenseController extends GetxController { + final RxList expenses = [].obs; + final RxBool isLoading = false.obs; + final RxString errorMessage = ''.obs; + + /// Fetch all expenses from API + Future fetchExpenses() async { + isLoading.value = true; + errorMessage.value = ''; + + try { + final result = await ApiService.getExpenseListApi(); + + if (result != null) { + try { + // Convert the raw result (List) to List + final List parsed = List.from( + result.map((e) => ExpenseModel.fromJson(e))); + expenses.assignAll(parsed); + logSafe("Expenses loaded: ${parsed.length}"); + } catch (e) { + errorMessage.value = 'Failed to parse expenses: $e'; + logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error); + } + } else { + errorMessage.value = 'Failed to fetch expenses from server.'; + logSafe("fetchExpenses failed: null response", level: LogLevel.error); + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in fetchExpenses: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 3949b40..4b7b24a 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -2,10 +2,10 @@ class ApiEndpoints { static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api"; - // Dashboard Screen API Endpoints + // Dashboard Module API Endpoints static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; - // Attendance Screen API Endpoints + // Attendance Module API Endpoints static const String getProjects = "/project/list"; static const String getGlobalProjects = "/project/list/basic"; static const String getEmployeesByProject = "/attendance/project/team"; @@ -24,7 +24,7 @@ class ApiEndpoints { static const String getAssignedProjects = "/project/assigned-projects"; static const String assignProjects = "/project/assign-projects"; - // Daily Task Screen API Endpoints + // Daily Task Module API Endpoints static const String getDailyTask = "/task/list"; static const String reportTask = "/task/report"; static const String commentTask = "/task/comment"; @@ -35,7 +35,7 @@ class ApiEndpoints { static const String assignTask = "/project/task"; static const String getmasterWorkCategories = "/Master/work-categories"; - ////// Directory Screen API Endpoints + ////// Directory Module API Endpoints static const String getDirectoryContacts = "/directory"; static const String getDirectoryBucketList = "/directory/buckets"; static const String getDirectoryContactDetail = "/directory/notes"; @@ -49,4 +49,15 @@ class ApiEndpoints { static const String createBucket = "/directory/bucket"; static const String updateBucket = "/directory/bucket"; static const String assignBucket = "/directory/assign-bucket"; + + ////// Expense Module API Endpoints + static const String getExpenseCategories = "/expense/categories"; + 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 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 bd6e005..0427760 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -239,6 +239,113 @@ class ApiService { } } +// === Expense APIs === // + + static Future?> getExpenseListApi() async { + const endpoint = ApiEndpoints.getExpenseList; + + logSafe("Fetching expense list..."); + + try { + final response = await _getRequest(endpoint); + if (response == null) return null; + + return _parseResponse(response, label: 'Expense List'); + } catch (e, stack) { + logSafe("Exception during getExpenseListApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return null; + } + } + + /// Fetch Master Payment Modes + static Future?> getMasterPaymentModes() async { + const endpoint = ApiEndpoints.getMasterPaymentModes; + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Master Payment Modes') + : null); + } + + /// Fetch Master Expense Status + static Future?> getMasterExpenseStatus() async { + const endpoint = ApiEndpoints.getMasterExpenseStatus; + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Master Expense Status') + : null); + } + + /// Fetch Master Expense Types + static Future?> getMasterExpenseTypes() async { + const endpoint = ApiEndpoints.getMasterExpenseTypes; + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Master Expense Types') + : null); + } + + /// Create Expense API + static Future createExpenseApi({ + required String projectId, + required String expensesTypeId, + required String paymentModeId, + required String paidById, + required DateTime transactionDate, + required String transactionId, + required String description, + required String location, + required String supplerName, + required double amount, + required int noOfPersons, + required String statusId, + required List> billAttachments, + }) async { + final payload = { + "projectId": projectId, + "expensesTypeId": expensesTypeId, + "paymentModeId": paymentModeId, + "paidById": paidById, + "transactionDate": transactionDate.toIso8601String(), + "transactionId": transactionId, + "description": description, + "location": location, + "supplerName": supplerName, + "amount": amount, + "noOfPersons": noOfPersons, + "statusId": statusId, + "billAttachments": billAttachments, + }; + + const endpoint = ApiEndpoints.createExpense; + logSafe("Creating expense with payload: $payload"); + + try { + final response = + await _postRequest(endpoint, payload, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Create expense failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Create expense response status: ${response.statusCode}"); + logSafe("Create expense response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Expense created successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to create expense: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during createExpense API: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + // === Dashboard Endpoints === static Future?> getDashboardAttendanceOverview( diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 3dfd6cb..3abd58a 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -1,54 +1,79 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/expense_status_model.dart'; void showAddExpenseBottomSheet() { - final TextEditingController amountController = TextEditingController(); - final TextEditingController descriptionController = TextEditingController(); - final TextEditingController supplierController = TextEditingController(); - final TextEditingController transactionIdController = TextEditingController(); - final TextEditingController gstController = TextEditingController(); - - String selectedProject = "Select Project"; - String selectedCategory = "Select Expense Type"; - String selectedPaymentMode = "Select Payment Mode"; - String selectedLocation = "Select Location"; - bool preApproved = false; - Get.bottomSheet( - StatefulBuilder( - builder: (context, setState) { - return SafeArea( - child: Padding( - padding: - const EdgeInsets.only(top: 60), - child: Material( - color: Colors.white, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), - child: Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height - 60, - ), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - child: SingleChildScrollView( + const _AddExpenseBottomSheet(), + isScrollControlled: true, + ); +} + +class _AddExpenseBottomSheet extends StatefulWidget { + const _AddExpenseBottomSheet(); + + @override + State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState(); +} + +class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { + final AddExpenseController controller = Get.put(AddExpenseController()); + final RxBool isProjectExpanded = false.obs; + void _showEmployeeList(BuildContext context) { + final employees = controller.allEmployees; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (BuildContext context) { + return Obx(() { + return SizedBox( + height: 300, + child: ListView.builder( + itemCount: employees.length, + itemBuilder: (context, index) { + final emp = employees[index]; + final fullName = '${emp.firstName} ${emp.lastName}'.trim(); + + return ListTile( + title: Text(fullName.isNotEmpty ? fullName : "Unnamed"), + onTap: () { + controller.selectedPaidBy.value = emp; + Navigator.pop(context); + }, + ); + }, + ), + ); + }); + }, + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Material( + color: Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + child: Stack( + children: [ + Obx(() { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Drag Handle - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - - // Title + _buildDragHandle(), Center( child: MyText.titleLarge( "Add Expense", @@ -57,151 +82,306 @@ void showAddExpenseBottomSheet() { ), const SizedBox(height: 20), - // Project - _sectionTitle(Icons.work_outline, "Project"), + // Project Dropdown + const _SectionTitle( + icon: Icons.work_outline, title: "Project"), const SizedBox(height: 6), - _dropdownTile( - title: selectedProject, - onTap: () { - setState(() { - selectedProject = "Project A"; - }); - }, - ), + Obx(() { + return _DropdownTile( + title: controller.selectedProject.value.isEmpty + ? "Select Project" + : controller.selectedProject.value, + onTap: () => _showOptionList( + context, + controller.globalProjects.toList(), + (p) => p, + (val) => controller.selectedProject.value = val, + ), + ); + }), const SizedBox(height: 16), - // Expense Type + GST - _sectionTitle( - Icons.category_outlined, "Expense Type & GST No."), - const SizedBox(height: 6), - _dropdownTile( - title: selectedCategory, - onTap: () { - setState(() { - selectedCategory = "Travel Expense"; - }); - }, + // Expense Type & GST + const _SectionTitle( + icon: Icons.category_outlined, + title: "Expense Type & GST No.", ), + const SizedBox(height: 6), + Obx(() { + return _DropdownTile( + title: controller.selectedExpenseType.value?.name ?? + "Select Expense Type", + onTap: () => _showOptionList( + context, + controller.expenseTypes.toList(), + (e) => e.name, + (val) => controller.selectedExpenseType.value = val, + ), + ); + }), const SizedBox(height: 8), - _customTextField( - controller: gstController, + _CustomTextField( + controller: controller.gstController, hint: "Enter GST No.", ), const SizedBox(height: 16), // Payment Mode - _sectionTitle(Icons.payment, "Payment Mode"), + const _SectionTitle( + icon: Icons.payment, title: "Payment Mode"), const SizedBox(height: 6), - _dropdownTile( - title: selectedPaymentMode, - onTap: () { - setState(() { - selectedPaymentMode = "UPI"; - }); - }, - ), + Obx(() { + return _DropdownTile( + title: controller.selectedPaymentMode.value?.name ?? + "Select Payment Mode", + onTap: () => _showOptionList( + context, + controller.paymentModes.toList(), + (m) => m.name, + (val) => controller.selectedPaymentMode.value = val, + ), + ); + }), const SizedBox(height: 16), - - // Paid By - _sectionTitle(Icons.person, "Paid By (Employee)"), - const SizedBox(height: 6), - _dropdownTile( - title: "Self (Default)", - onTap: () {}, - ), + Obx(() { + final selected = controller.selectedPaidBy.value; + return GestureDetector( + onTap: () => _showEmployeeList(context), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade400), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selected == null + ? "Select Paid By" + : '${selected.firstName} ${selected.lastName}', + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.arrow_drop_down, size: 22), + ], + ), + ), + ); + }), const SizedBox(height: 16), - - // Transaction Date - _sectionTitle(Icons.calendar_today, "Transaction Date"), + // Expense Status + const _SectionTitle( + icon: Icons.flag_outlined, title: "Status"), const SizedBox(height: 6), - _dropdownTile( - title: "Select Date & Time", - onTap: () async { - // Add date/time picker - }, - ), + Obx(() { + return _DropdownTile( + title: controller.selectedExpenseStatus.value?.name ?? + "Select Status", + onTap: () => _showOptionList( + context, + controller.expenseStatuses.toList(), + (s) => s.name, + (val) => + controller.selectedExpenseStatus.value = val, + ), + ); + }), const SizedBox(height: 16), // Amount - _sectionTitle(Icons.currency_rupee, "Amount"), + const _SectionTitle( + icon: Icons.currency_rupee, title: "Amount"), const SizedBox(height: 6), - _customTextField( - controller: amountController, + _CustomTextField( + controller: controller.amountController, hint: "Enter Amount", keyboardType: TextInputType.number, ), const SizedBox(height: 16), - - // Supplier - _sectionTitle(Icons.store_mall_directory, - "Supplier Name / Expense Done At"), + // Supplier Name + const _SectionTitle( + icon: Icons.store_mall_directory_outlined, + title: "Supplier Name", + ), const SizedBox(height: 6), - _customTextField( - controller: supplierController, + _CustomTextField( + controller: controller.supplierController, hint: "Enter Supplier Name", ), const SizedBox(height: 16), - - // Location - _sectionTitle(Icons.location_on_outlined, "Location"), - const SizedBox(height: 6), - _dropdownTile( - title: selectedLocation, - onTap: () { - setState(() { - selectedLocation = "Pune"; - }); - }, - ), - const SizedBox(height: 16), - - // Description - _sectionTitle(Icons.description_outlined, "Description"), - const SizedBox(height: 6), - _customTextField( - controller: descriptionController, - hint: "Enter Description", - maxLines: 3, - ), - const SizedBox(height: 16), - - // Bill Attachment - _sectionTitle(Icons.attachment, "Bill Attachment"), - const SizedBox(height: 6), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.upload_file), - label: const Text("Upload Bill"), - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - const SizedBox(height: 16), - // Transaction ID - _sectionTitle( - Icons.confirmation_num_outlined, "Transaction ID"), + const _SectionTitle( + icon: Icons.confirmation_number_outlined, + title: "Transaction ID"), const SizedBox(height: 6), - _customTextField( - controller: transactionIdController, + _CustomTextField( + controller: controller.transactionIdController, hint: "Enter Transaction ID", ), const SizedBox(height: 16), - // Pre-Approved Switch - Row( - children: [ - Switch( - value: preApproved, - onChanged: (val) => - setState(() => preApproved = val), - activeColor: Colors.red, + // Location + const _SectionTitle( + icon: Icons.location_on_outlined, + title: "Location", + ), + const SizedBox(height: 6), + TextField( + controller: controller.locationController, + decoration: InputDecoration( + hintText: "Enter Location", + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), - const SizedBox(width: 8), - const Text("Pre-Approved?"), - ], + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + suffixIcon: controller.isFetchingLocation.value + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.my_location), + tooltip: "Use Current Location", + onPressed: controller.fetchCurrentLocation, + ), + ), + ), + + const SizedBox(height: 16), + // Attachments Section + const _SectionTitle( + icon: Icons.attach_file, title: "Attachments"), + const SizedBox(height: 6), + + Obx(() { + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...controller.attachments.map((file) { + final fileName = file.path.split('/').last; + final extension = + fileName.split('.').last.toLowerCase(); + + final isImage = + ['jpg', 'jpeg', 'png'].contains(extension); + + IconData fileIcon; + Color iconColor = Colors.blueAccent; + switch (extension) { + case 'pdf': + fileIcon = Icons.picture_as_pdf; + iconColor = Colors.redAccent; + break; + case 'doc': + case 'docx': + fileIcon = Icons.description; + iconColor = Colors.blueAccent; + break; + case 'xls': + case 'xlsx': + fileIcon = Icons.table_chart; + iconColor = Colors.green; + break; + case 'txt': + fileIcon = Icons.article; + iconColor = Colors.grey; + break; + default: + fileIcon = Icons.insert_drive_file; + iconColor = Colors.blueGrey; + } + + return Stack( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.grey.shade300), + color: Colors.grey.shade100, + ), + child: isImage + ? ClipRRect( + borderRadius: + BorderRadius.circular(8), + child: Image.file(file, + fit: BoxFit.cover), + ) + : Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon(fileIcon, + color: iconColor, size: 30), + const SizedBox(height: 4), + Text( + extension.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: iconColor, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Positioned( + top: -6, + right: -6, + child: IconButton( + icon: const Icon(Icons.close, + color: Colors.red, size: 18), + onPressed: () => + controller.removeAttachment(file), + ), + ), + ], + ); + }).toList(), + GestureDetector( + onTap: controller.pickAttachments, + 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), + ), + ), + ], + ); + }), + const SizedBox(height: 16), + + // Description + const _SectionTitle( + icon: Icons.description_outlined, + title: "Description", + ), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.descriptionController, + hint: "Enter Description", + maxLines: 3, ), const SizedBox(height: 24), @@ -215,20 +395,15 @@ void showAddExpenseBottomSheet() { label: MyText.bodyMedium("Cancel", fontWeight: 600), style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.grey), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), + minimumSize: + const Size.fromHeight(48), ), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( - onPressed: () { - // Handle Save - Get.back(); - }, + onPressed: controller.submitExpense, icon: const Icon(Icons.check, size: 18), label: MyText.bodyMedium( "Submit", @@ -238,82 +413,216 @@ void showAddExpenseBottomSheet() { style: ElevatedButton.styleFrom( backgroundColor: Colors.indigo, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), ), + padding: + const EdgeInsets.symmetric(vertical: 14), + minimumSize: + const Size.fromHeight(48), ), ), ), ], - ), + ) ], ), - ), + ); + }), + + // Project Selection List + Obx(() { + if (!isProjectExpanded.value) return const SizedBox.shrink(); + return Positioned( + top: 110, + left: 16, + right: 16, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(10), + child: _buildProjectSelectionList(), + ), + ), + ); + }), + ], + ), + ), + ), + ); + } + + Widget _buildProjectSelectionList() { + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: ListView.builder( + shrinkWrap: true, + itemCount: controller.globalProjects.length, + itemBuilder: (context, index) { + final project = controller.globalProjects[index]; + final isSelected = project == controller.selectedProject.value; + + return RadioListTile( + value: project, + groupValue: controller.selectedProject.value, + onChanged: (val) { + controller.selectedProject.value = val!; + isProjectExpanded.value = false; + }, + title: Text( + project, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? Colors.blueAccent : Colors.black87, ), ), - ), - ); - }, - ), - isScrollControlled: true, - ); -} - -/// Section Title -Widget _sectionTitle(IconData icon, String title) { - return Row( - children: [ - Icon(icon, color: Colors.grey[700], size: 18), - const SizedBox(width: 8), - MyText.bodyMedium(title, fontWeight: 600), - ], - ); -} - -/// Custom TextField -Widget _customTextField({ - required TextEditingController controller, - required String hint, - int maxLines = 1, - TextInputType keyboardType = TextInputType.text, -}) { - return TextField( - controller: controller, - maxLines: maxLines, - keyboardType: keyboardType, - decoration: InputDecoration( - hintText: hint, - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - ), - ); -} - -/// Dropdown Tile -Widget _dropdownTile({required String title, required VoidCallback onTap}) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - title, - style: const TextStyle(fontSize: 14, color: Colors.black87), - overflow: TextOverflow.ellipsis, + activeColor: Colors.blueAccent, + tileColor: isSelected + ? Colors.blueAccent.withOpacity(0.1) + : Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), ), - ), - const Icon(Icons.arrow_drop_down), - ], + visualDensity: const VisualDensity(vertical: -4), + ); + }, ), - ), - ); + ); + } + + Future _showOptionList( + BuildContext context, + List options, + String Function(T) getLabel, + ValueChanged onSelected, + ) async { + final RenderBox button = context.findRenderObject() as RenderBox; + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + + final Offset position = + button.localToGlobal(Offset.zero, ancestor: overlay); + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy + button.size.height, + position.dx + button.size.width, + 0, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: options.map((option) { + return PopupMenuItem( + value: option, + child: Text(getLabel(option)), + ); + }).toList(), + ); + + if (selected != null) onSelected(selected); + } + + Widget _buildDragHandle() => Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(2), + ), + ), + ); +} + +class _SectionTitle extends StatelessWidget { + final IconData icon; + final String title; + const _SectionTitle({required this.icon, required this.title}); + + @override + Widget build(BuildContext context) { + final color = Colors.grey[700]; + return Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + MyText.bodyMedium(title, fontWeight: 600), + ], + ); + } +} + +class _CustomTextField extends StatelessWidget { + final TextEditingController controller; + final String hint; + final int maxLines; + final TextInputType keyboardType; + + const _CustomTextField({ + required this.controller, + required this.hint, + this.maxLines = 1, + this.keyboardType = TextInputType.text, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboardType, + decoration: InputDecoration( + hintText: hint, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ); + } +} + +class _DropdownTile extends StatelessWidget { + final String title; + final VoidCallback onTap; + + const _DropdownTile({ + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + title, + style: const TextStyle(fontSize: 14, color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ); + } } diff --git a/lib/model/expense/expense_list_model.dart b/lib/model/expense/expense_list_model.dart new file mode 100644 index 0000000..451ba72 --- /dev/null +++ b/lib/model/expense/expense_list_model.dart @@ -0,0 +1,268 @@ +import 'dart:convert'; + +List expenseModelFromJson(String str) => List.from( + json.decode(str).map((x) => ExpenseModel.fromJson(x))); + +String expenseModelToJson(List data) => + json.encode(List.from(data.map((x) => x.toJson()))); + +class ExpenseModel { + final String id; + final Project project; + final ExpenseType expensesType; + final PaymentMode paymentMode; + final PaidBy paidBy; + final CreatedBy createdBy; + final DateTime transactionDate; + final DateTime createdAt; + final String supplerName; + final double amount; + final Status status; + final List nextStatus; + final bool preApproved; + + ExpenseModel({ + required this.id, + required this.project, + required this.expensesType, + required this.paymentMode, + required this.paidBy, + required this.createdBy, + required this.transactionDate, + required this.createdAt, + required this.supplerName, + required this.amount, + required this.status, + required this.nextStatus, + required this.preApproved, + }); + + factory ExpenseModel.fromJson(Map json) => ExpenseModel( + id: json["id"], + project: Project.fromJson(json["project"]), + expensesType: ExpenseType.fromJson(json["expensesType"]), + paymentMode: PaymentMode.fromJson(json["paymentMode"]), + paidBy: PaidBy.fromJson(json["paidBy"]), + createdBy: CreatedBy.fromJson(json["createdBy"]), + transactionDate: DateTime.parse(json["transactionDate"]), + createdAt: DateTime.parse(json["createdAt"]), + supplerName: json["supplerName"], + amount: (json["amount"] as num).toDouble(), + status: Status.fromJson(json["status"]), + nextStatus: List.from( + json["nextStatus"].map((x) => Status.fromJson(x))), + preApproved: json["preApproved"], + ); + + Map toJson() => { + "id": id, + "project": project.toJson(), + "expensesType": expensesType.toJson(), + "paymentMode": paymentMode.toJson(), + "paidBy": paidBy.toJson(), + "createdBy": createdBy.toJson(), + "transactionDate": transactionDate.toIso8601String(), + "createdAt": createdAt.toIso8601String(), + "supplerName": supplerName, + "amount": amount, + "status": status.toJson(), + "nextStatus": List.from(nextStatus.map((x) => x.toJson())), + "preApproved": preApproved, + }; +} + +class Project { + final String id; + final String name; + final String shortName; + final String projectAddress; + final String contactPerson; + final DateTime startDate; + final DateTime endDate; + final String projectStatusId; + + Project({ + required this.id, + required this.name, + required this.shortName, + required this.projectAddress, + required this.contactPerson, + required this.startDate, + required this.endDate, + required this.projectStatusId, + }); + + factory Project.fromJson(Map json) => Project( + id: json["id"], + name: json["name"], + shortName: json["shortName"], + projectAddress: json["projectAddress"], + contactPerson: json["contactPerson"], + startDate: DateTime.parse(json["startDate"]), + endDate: DateTime.parse(json["endDate"]), + projectStatusId: json["projectStatusId"], + ); + + Map toJson() => { + "id": id, + "name": name, + "shortName": shortName, + "projectAddress": projectAddress, + "contactPerson": contactPerson, + "startDate": startDate.toIso8601String(), + "endDate": endDate.toIso8601String(), + "projectStatusId": projectStatusId, + }; +} + +class ExpenseType { + final String id; + final String name; + final bool noOfPersonsRequired; + final String description; + + ExpenseType({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.description, + }); + + factory ExpenseType.fromJson(Map json) => ExpenseType( + id: json["id"], + name: json["name"], + noOfPersonsRequired: json["noOfPersonsRequired"], + description: json["description"], + ); + + Map toJson() => { + "id": id, + "name": name, + "noOfPersonsRequired": noOfPersonsRequired, + "description": description, + }; +} + +class PaymentMode { + final String id; + final String name; + final String description; + + PaymentMode({ + required this.id, + required this.name, + required this.description, + }); + + factory PaymentMode.fromJson(Map json) => PaymentMode( + id: json["id"], + name: json["name"], + description: json["description"], + ); + + Map toJson() => { + "id": id, + "name": name, + "description": description, + }; +} + +class PaidBy { + final String id; + final String firstName; + final String lastName; + final String photo; + final String jobRoleId; + final String? jobRoleName; + + PaidBy({ + required this.id, + required this.firstName, + required this.lastName, + required this.photo, + required this.jobRoleId, + this.jobRoleName, + }); + + factory PaidBy.fromJson(Map json) => PaidBy( + id: json["id"], + firstName: json["firstName"], + lastName: json["lastName"], + photo: json["photo"], + jobRoleId: json["jobRoleId"], + jobRoleName: json["jobRoleName"], + ); + + Map toJson() => { + "id": id, + "firstName": firstName, + "lastName": lastName, + "photo": photo, + "jobRoleId": jobRoleId, + "jobRoleName": jobRoleName, + }; +} + +class CreatedBy { + final String id; + final String firstName; + final String lastName; + final String photo; + final String jobRoleId; + final String? jobRoleName; + + CreatedBy({ + required this.id, + required this.firstName, + required this.lastName, + required this.photo, + required this.jobRoleId, + this.jobRoleName, + }); + + factory CreatedBy.fromJson(Map json) => CreatedBy( + id: json["id"], + firstName: json["firstName"], + lastName: json["lastName"], + photo: json["photo"], + jobRoleId: json["jobRoleId"], + jobRoleName: json["jobRoleName"], + ); + + Map toJson() => { + "id": id, + "firstName": firstName, + "lastName": lastName, + "photo": photo, + "jobRoleId": jobRoleId, + "jobRoleName": jobRoleName, + }; +} + +class Status { + final String id; + final String name; + final String description; + final bool isSystem; + + Status({ + required this.id, + required this.name, + required this.description, + required this.isSystem, + }); + + factory Status.fromJson(Map json) => Status( + id: json["id"], + name: json["name"], + description: json["description"], + isSystem: json["isSystem"], + ); + + Map toJson() => { + "id": id, + "name": name, + "description": description, + "isSystem": isSystem, + }; +} diff --git a/lib/model/expense/expense_status_model.dart b/lib/model/expense/expense_status_model.dart new file mode 100644 index 0000000..8970c59 --- /dev/null +++ b/lib/model/expense/expense_status_model.dart @@ -0,0 +1,25 @@ +class ExpenseStatusModel { + final String id; + final String name; + final String description; + final bool isSystem; + final bool isActive; + + ExpenseStatusModel({ + required this.id, + required this.name, + required this.description, + required this.isSystem, + required this.isActive, + }); + + factory ExpenseStatusModel.fromJson(Map json) { + return ExpenseStatusModel( + id: json['id'], + name: json['name'], + description: json['description'] ?? '', + isSystem: json['isSystem'] ?? false, + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/model/expense/expense_type_model.dart b/lib/model/expense/expense_type_model.dart new file mode 100644 index 0000000..0cf8be9 --- /dev/null +++ b/lib/model/expense/expense_type_model.dart @@ -0,0 +1,25 @@ +class ExpenseTypeModel { + final String id; + final String name; + final bool noOfPersonsRequired; + final String description; + final bool isActive; + + ExpenseTypeModel({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.description, + required this.isActive, + }); + + factory ExpenseTypeModel.fromJson(Map json) { + return ExpenseTypeModel( + id: json['id'], + name: json['name'], + noOfPersonsRequired: json['noOfPersonsRequired'] ?? false, + description: json['description'] ?? '', + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/model/expense/payment_types_model.dart b/lib/model/expense/payment_types_model.dart new file mode 100644 index 0000000..d3f6024 --- /dev/null +++ b/lib/model/expense/payment_types_model.dart @@ -0,0 +1,22 @@ +class PaymentModeModel { + final String id; + final String name; + final String description; + final bool isActive; + + PaymentModeModel({ + required this.id, + required this.name, + required this.description, + required this.isActive, + }); + + factory PaymentModeModel.fromJson(Map json) { + return PaymentModeModel( + id: json['id'], + name: json['name'], + description: json['description'] ?? '', + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 6ad1194..951e5d6 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -3,13 +3,15 @@ import 'package:get/get.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; // Import DateTimeUtils class ExpenseDetailScreen extends StatelessWidget { const ExpenseDetailScreen({super.key}); - Color _getStatusColor(String status) { + static Color getStatusColor(String? status) { switch (status) { - case 'Request': + case 'Requested': return Colors.blue; case 'Review': return Colors.orange; @@ -26,10 +28,9 @@ class ExpenseDetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final Map expense = - Get.arguments['expense'] as Map; - final Color statusColor = _getStatusColor(expense['status']!); - final ProjectController projectController = Get.find(); + final ExpenseModel expense = Get.arguments['expense'] as ExpenseModel; + final statusColor = getStatusColor(expense.status.name); + final projectController = Get.find(); return Scaffold( backgroundColor: const Color(0xFFF7F7F7), @@ -95,139 +96,89 @@ class ExpenseDetailScreen extends StatelessWidget { ), body: SingleChildScrollView( padding: const EdgeInsets.all(16), - child: Card( - elevation: 6, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header Section - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFFFF4B2B), Color(0xFFFF416C)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - expense['title'] ?? 'N/A', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(height: 6), - Text( - expense['amount'] ?? '₹ 0', - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.flag, size: 16, color: statusColor), - const SizedBox(width: 6), - Text( - expense['status'] ?? 'N/A', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - ), - - // Details Section - Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _detailRow(Icons.calendar_today, "Date & Time", - expense['date'] ?? 'N/A'), - _detailRow(Icons.category_outlined, "Expense Type", - "${expense['category']} (GST: ${expense['gstNo'] ?? 'N/A'})"), - _detailRow(Icons.payment, "Payment Mode", - expense['paymentMode'] ?? 'N/A'), - _detailRow(Icons.person, "Paid By", - expense['paidBy'] ?? 'N/A'), - _detailRow(Icons.access_time, "Transaction Date", - expense['transactionDate'] ?? 'N/A'), - _detailRow(Icons.location_on_outlined, "Location", - expense['location'] ?? 'N/A'), - _detailRow(Icons.store, "Supplier Name", - expense['supplierName'] ?? 'N/A'), - _detailRow(Icons.confirmation_num_outlined, - "Transaction ID", expense['transactionId'] ?? 'N/A'), - _detailRow(Icons.description, "Description", - expense['description'] ?? 'N/A'), - ], - ), - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ExpenseHeader( + title: expense.expensesType.name, + amount: '₹ ${expense.amount.toStringAsFixed(2)}', + status: expense.status.name, + statusColor: statusColor, + ), + const SizedBox(height: 16), + _ExpenseDetailsList(expense: expense), + ], ), ), ); } +} - Widget _detailRow(IconData icon, String title, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( +class _ExpenseHeader extends StatelessWidget { + final String title; + final String amount; + final String status; + final Color statusColor; + + const _ExpenseHeader({ + required this.title, + required this.amount, + required this.status, + required this.statusColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(10), + Text( + title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.black, ), - child: Icon(icon, size: 20, color: Colors.grey[800]), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 6), + Text( + amount, + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.flag, size: 16, color: statusColor), + const SizedBox(width: 6), Text( - title, + status, style: const TextStyle( - fontSize: 13, - color: Colors.grey, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - value, - style: const TextStyle( - fontSize: 15, + color: Colors.black, fontWeight: FontWeight.w600, ), ), @@ -239,3 +190,103 @@ class ExpenseDetailScreen extends StatelessWidget { ); } } + +class _ExpenseDetailsList extends StatelessWidget { + final ExpenseModel expense; + + const _ExpenseDetailsList({required this.expense}); + + @override + Widget build(BuildContext context) { + final transactionDate = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toString(), + format: 'dd-MM-yyyy hh:mm a', + ); + final createdAt = DateTimeUtils.convertUtcToLocal( + expense.createdAt.toString(), + format: 'dd-MM-yyyy hh:mm a', + ); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DetailRow(title: "Project", value: expense.project.name), + _DetailRow(title: "Expense Type", value: expense.expensesType.name), + _DetailRow(title: "Payment Mode", value: expense.paymentMode.name), + _DetailRow( + title: "Paid By", + value: + '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), + _DetailRow( + title: "Created By", + value: + '${expense.createdBy.firstName} ${expense.createdBy.lastName}'), + _DetailRow(title: "Transaction Date", value: transactionDate), + _DetailRow(title: "Created At", value: createdAt), + _DetailRow(title: "Supplier Name", value: expense.supplerName), + _DetailRow(title: "Amount", value: '₹ ${expense.amount}'), + _DetailRow(title: "Status", value: expense.status.name), + _DetailRow( + title: "Next Status", + value: expense.nextStatus.map((e) => e.name).join(", ")), + _DetailRow( + title: "Pre-Approved", + value: expense.preApproved ? "Yes" : "No"), + ], + ), + ); + } +} + +class _DetailRow extends StatelessWidget { + final String title; + final String value; + + const _DetailRow({required this.title, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: Text( + title, + style: const TextStyle( + fontSize: 13, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + flex: 5, + child: Text( + value, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index f2d0217..e0e8f50 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/expense/expense_screen_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'; -import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/view/expense/expense_detail_screen.dart'; +import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -17,388 +20,84 @@ class _ExpenseMainScreenState extends State { final RxBool isHistoryView = false.obs; final TextEditingController searchController = TextEditingController(); final RxString searchQuery = ''.obs; - final ProjectController projectController = Get.find(); + final ExpenseController expenseController = Get.put(ExpenseController()); - final List> expenseList = [ - { - 'title': 'Travel Expense', - 'amount': '₹ 1,500', - 'status': 'Request', - 'date': '12 Jul 2025 • 3:45 PM', - 'category': 'Transport', - 'paymentMode': 'UPI', - 'transactionId': 'TXN123451' - }, - { - 'title': 'Hotel Stay', - 'amount': '₹ 4,500', - 'status': 'Approved', - 'date': '11 Jul 2025 • 9:30 AM', - 'category': 'Accommodation', - 'paymentMode': 'Credit Card', - 'transactionId': 'TXN123452' - }, - { - 'title': 'Food Bill', - 'amount': '₹ 1,200', - 'status': 'Paid', - 'date': '10 Jul 2025 • 7:10 PM', - 'category': 'Food', - 'paymentMode': 'Cash', - 'transactionId': 'TXN123453' - }, - ]; - - Color _getStatusColor(String status) { - switch (status) { - case 'Request': - return Colors.blue; - case 'Review': - return Colors.orange; - case 'Approved': - return Colors.green; - case 'Paid': - return Colors.purple; - case 'Closed': - return Colors.grey; - default: - return Colors.black; - } + @override + void initState() { + super.initState(); + expenseController.fetchExpenses(); // Load expenses from API } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - appBar: _buildAppBar(), + appBar: _ExpenseAppBar(projectController: projectController), body: SafeArea( child: Column( children: [ - _buildSearchAndFilter(), - _buildToggleButtons(), + _SearchAndFilter( + searchController: searchController, + onChanged: (value) => searchQuery.value = value, + onFilterTap: _openFilterBottomSheet, + ), + _ToggleButtons(isHistoryView: isHistoryView), Expanded( - child: Obx( - () => isHistoryView.value - ? _buildHistoryList() - : _buildExpenseList(), - ), + child: Obx(() { + if (expenseController.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (expenseController.errorMessage.isNotEmpty) { + return Center( + child: Text( + expenseController.errorMessage.value, + style: const TextStyle(color: Colors.red), + ), + ); + } + + if (expenseController.expenses.isEmpty) { + return const Center(child: Text("No expenses found.")); + } + + // Apply search filter + final filteredList = expenseController.expenses.where((expense) { + final query = searchQuery.value.toLowerCase(); + return query.isEmpty || + expense.expensesType.name.toLowerCase().contains(query) || + expense.supplerName.toLowerCase().contains(query) || + expense.paymentMode.name.toLowerCase().contains(query); + }).toList(); + + // Split into current month and history + final now = DateTime.now(); + final currentMonthList = filteredList.where((e) => + e.transactionDate.month == now.month && + e.transactionDate.year == now.year).toList(); + + final historyList = filteredList.where((e) => + e.transactionDate.isBefore( + DateTime(now.year, now.month, 1))).toList(); + + final listToShow = + isHistoryView.value ? historyList : currentMonthList; + + return _ExpenseList(expenseList: listToShow); + }), ), ], ), ), floatingActionButton: FloatingActionButton( - onPressed: () { - showAddExpenseBottomSheet(); - }, backgroundColor: Colors.red, + onPressed: showAddExpenseBottomSheet, child: const Icon(Icons.add, color: Colors.white), ), ); } - PreferredSizeWidget _buildAppBar() { - return PreferredSize( - preferredSize: const Size.fromHeight(72), - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard'), - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - MyText.titleLarge( - 'Expenses', - fontWeight: 700, - color: Colors.black, - ), - MySpacing.height(2), - GetBuilder( - builder: (controller) { - final projectName = - controller.selectedProject?.name ?? 'Select Project'; - return InkWell( - onTap: () => Get.toNamed('/project-selector'), - child: Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), - ), - ], - ), - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildSearchAndFilter() { - return Padding( - padding: MySpacing.fromLTRB(12, 10, 12, 0), - child: Row( - children: [ - Expanded( - child: SizedBox( - height: 35, - child: TextField( - controller: searchController, - onChanged: (value) => searchQuery.value = value, - decoration: InputDecoration( - contentPadding: - const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: const Icon(Icons.search, - size: 20, color: Colors.grey), - hintText: 'Search expenses...', - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - ), - ), - ), - ), - MySpacing.width(8), - IconButton( - icon: const Icon(Icons.tune, color: Colors.black), - onPressed: _openFilterBottomSheet, - ), - ], - ), - ); - } - - Widget _buildToggleButtons() { - return Padding( - padding: MySpacing.fromLTRB(8, 12, 8, 5), - child: Obx(() { - return Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - _buildToggleButton( - label: 'Expenses', - icon: Icons.receipt_long, - selected: !isHistoryView.value, - onTap: () => isHistoryView.value = false, - ), - _buildToggleButton( - label: 'History', - icon: Icons.history, - selected: isHistoryView.value, - onTap: () => isHistoryView.value = true, - ), - ], - ), - ); - }), - ); - } - - Widget _buildToggleButton({ - required String label, - required IconData icon, - required bool selected, - required VoidCallback onTap, - }) { - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), - decoration: BoxDecoration( - color: selected ? Colors.red : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - color: selected ? Colors.white : Colors.grey, - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildExpenseList() { - return Obx(() { - final filteredList = expenseList.where((expense) { - return searchQuery.isEmpty || - expense['title']! - .toLowerCase() - .contains(searchQuery.value.toLowerCase()); - }).toList(); - - return _buildExpenseHistoryList(filteredList); - }); - } - - Widget _buildHistoryList() { - final historyList = expenseList - .where((item) => item['status'] == 'Paid' || item['status'] == 'Closed') - .toList(); - - return _buildExpenseHistoryList(historyList); - } - - Widget _buildExpenseHistoryList(List> list) { - return ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: list.length, - itemBuilder: (context, index) { - final item = list[index]; - return GestureDetector( - onTap: () => Get.to( - () => const ExpenseDetailScreen(), - arguments: {'expense': item}, - ), - child: _buildExpenseCard(item), - ); - }, - ); - } - - Widget _buildExpenseCard(Map item) { - final statusColor = _getStatusColor(item['status']!); - - return Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - margin: const EdgeInsets.symmetric(vertical: 8), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title & Amount Row - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - const Icon(Icons.receipt_long, size: 20, color: Colors.red), - const SizedBox(width: 8), - Text( - item['title']!, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - Text( - item['amount']!, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.red, - ), - ), - ], - ), - const SizedBox(height: 8), - _buildInfoRow(Icons.calendar_today, item['date']!), - const SizedBox(height: 6), - _buildInfoRow(Icons.category_outlined, item['category']!), - const SizedBox(height: 6), - _buildInfoRow(Icons.payment, item['paymentMode']!), - _buildInfoRow(Icons.confirmation_num_outlined, item['transactionId']!), - const SizedBox(height: 10), - Align( - alignment: Alignment.centerRight, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - item['status']!, - style: TextStyle( - color: statusColor, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildInfoRow(IconData icon, String text) { - return Row( - children: [ - Icon(icon, size: 14, color: Colors.grey), - const SizedBox(width: 4), - Text( - text, - style: TextStyle(color: Colors.grey[600], fontSize: 12), - ), - ], - ); - } - void _openFilterBottomSheet() { Get.bottomSheet( Container( @@ -435,3 +134,330 @@ class _ExpenseMainScreenState extends State { ); } } + +// AppBar Widget +class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { + final ProjectController projectController; + + const _ExpenseAppBar({required this.projectController}); + + @override + Size get preferredSize => const Size.fromHeight(72); + + @override + Widget build(BuildContext context) { + return PreferredSize( + preferredSize: preferredSize, + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Expenses', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + builder: (_) { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return InkWell( + onTap: () => Get.toNamed('/project-selector'), + child: Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ), + ); + }, + ) + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// Search and Filter Widget +class _SearchAndFilter extends StatelessWidget { + final TextEditingController searchController; + final ValueChanged onChanged; + final VoidCallback onFilterTap; + + const _SearchAndFilter({ + required this.searchController, + required this.onChanged, + required this.onFilterTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.fromLTRB(12, 10, 12, 0), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: searchController, + onChanged: onChanged, + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + hintText: 'Search expenses...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ), + MySpacing.width(8), + IconButton( + icon: const Icon(Icons.tune, color: Colors.black), + onPressed: onFilterTap, + ), + ], + ), + ); + } +} + +// Toggle Buttons Widget +class _ToggleButtons extends StatelessWidget { + final RxBool isHistoryView; + + const _ToggleButtons({required this.isHistoryView}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.fromLTRB(8, 12, 8, 5), + child: Obx(() { + return Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _ToggleButton( + label: 'Expenses', + icon: Icons.receipt_long, + selected: !isHistoryView.value, + onTap: () => isHistoryView.value = false, + ), + _ToggleButton( + label: 'History', + icon: Icons.history, + selected: isHistoryView.value, + onTap: () => isHistoryView.value = true, + ), + ], + ), + ); + }), + ); + } +} + +class _ToggleButton extends StatelessWidget { + final String label; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + const _ToggleButton({ + required this.label, + required this.icon, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), + decoration: BoxDecoration( + color: selected ? Colors.red : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: selected ? Colors.white : Colors.grey, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ); + } +} + +// Expense List Widget (Dynamic) +class _ExpenseList extends StatelessWidget { + final List expenseList; + + const _ExpenseList({required this.expenseList}); + + static Color _getStatusColor(String status) { + switch (status) { + case 'Requested': return Colors.blue; + case 'Review': return Colors.orange; + case 'Approved': return Colors.green; + case 'Paid': return Colors.purple; + case 'Closed': return Colors.grey; + default: return Colors.black; + } + } + + @override + Widget build(BuildContext context) { + if (expenseList.isEmpty) { + return const Center(child: Text('No expenses found.')); + } + + return ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: expenseList.length, + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), + itemBuilder: (context, index) { + final expense = expenseList[index]; + final statusColor = _getStatusColor(expense.status.name); + + // Convert UTC date to local formatted string + final formattedDate = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toIso8601String(), + format: 'dd MMM yyyy, hh:mm a', + ); + + return GestureDetector( + onTap: () => Get.to( + () => const ExpenseDetailScreen(), + arguments: {'expense': expense}, + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Amount row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.receipt_long, + size: 20, color: Colors.red), + const SizedBox(width: 8), + Text( + expense.expensesType.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + Text( + '₹ ${expense.amount.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ], + ), + const SizedBox(height: 6), + + // Date + Status + Row( + children: [ + Text( + formattedDate, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + const Spacer(), + Text( + expense.status.name, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index fd193f6..846bfc0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -78,6 +78,7 @@ dependencies: flutter_quill_delta_from_html: ^1.5.2 quill_delta: ^3.0.0-nullsafety.2 connectivity_plus: ^6.1.4 + geocoding: ^4.0.0 dev_dependencies: flutter_test: sdk: flutter