From e5b3616245fbcc05df2a5f3762fc4731ed5bb88c Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 28 Jul 2025 12:09:13 +0530 Subject: [PATCH] Refactor expense models and detail screen for improved error handling and data validation - Enhanced `ExpenseResponse` and `ExpenseData` models to handle null values and provide default values. - Introduced a new `Filter` class to encapsulate filtering logic for expenses. - Updated `ExpenseDetailScreen` to utilize a controller for fetching expense details and managing loading states. - Improved UI responsiveness with loading skeletons and error messages. - Refactored filter bottom sheet to streamline filter selection and reset functionality. - Added visual indicators for filter application in the main expense screen. - Enhanced expense detail display with better formatting and status color handling. --- .../expense/expense_detail_controller.dart | 70 +++ .../expense/expense_screen_controller.dart | 62 ++- lib/helpers/services/api_service.dart | 46 ++ lib/helpers/widgets/my_custom_skeleton.dart | 406 ++++++++------ lib/model/expense/expense_list_model.dart | 181 +++--- lib/view/expense/expense_detail_screen.dart | 291 ++++++---- .../expense/expense_filter_bottom_sheet.dart | 518 +++++++++++------- lib/view/expense/expense_screen.dart | 109 ++-- 8 files changed, 1108 insertions(+), 575 deletions(-) create mode 100644 lib/controller/expense/expense_detail_controller.dart diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart new file mode 100644 index 0000000..4ff0bec --- /dev/null +++ b/lib/controller/expense/expense_detail_controller.dart @@ -0,0 +1,70 @@ +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 ExpenseDetailController extends GetxController { + final Rx expense = Rx(null); + final RxBool isLoading = false.obs; + final RxString errorMessage = ''.obs; + + /// Fetch expense details by ID + Future fetchExpenseDetails(String expenseId) async { + isLoading.value = true; + errorMessage.value = ''; + + try { + logSafe("Fetching expense details for ID: $expenseId"); + + final result = await ApiService.getExpenseDetailsApi(expenseId: expenseId); + if (result != null) { + try { + expense.value = ExpenseModel.fromJson(result); + logSafe("Expense details loaded successfully: ${expense.value?.id}"); + } catch (e) { + errorMessage.value = 'Failed to parse expense details: $e'; + logSafe("Parse error in fetchExpenseDetails: $e", + level: LogLevel.error); + } + } else { + errorMessage.value = 'Failed to fetch expense details from server.'; + logSafe("fetchExpenseDetails failed: null response", + level: LogLevel.error); + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in fetchExpenseDetails: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } finally { + isLoading.value = false; + } + } + + /// Update status for this specific expense + Future updateExpenseStatus(String expenseId, String statusId) async { + isLoading.value = true; + errorMessage.value = ''; + try { + logSafe("Updating status for expense: $expenseId -> $statusId"); + final success = await ApiService.updateExpenseStatusApi( + expenseId: expenseId, + statusId: statusId, + ); + if (success) { + logSafe("Expense status updated successfully."); + await fetchExpenseDetails(expenseId); // Refresh details + return true; + } else { + errorMessage.value = "Failed to update expense status."; + return false; + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in updateExpenseStatus: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 1acdec9..0e9ca46 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -12,6 +12,8 @@ class ExpenseController extends GetxController { final RxList expenses = [].obs; final RxBool isLoading = false.obs; final RxString errorMessage = ''.obs; + + // Master data final RxList expenseTypes = [].obs; final RxList paymentModes = [].obs; final RxList expenseStatuses = [].obs; @@ -19,6 +21,15 @@ class ExpenseController extends GetxController { final RxMap projectsMap = {}.obs; RxList allEmployees = [].obs; + // Persistent Filter States + final RxString selectedProject = ''.obs; + final RxString selectedStatus = ''.obs; + final Rx startDate = Rx(null); + final Rx endDate = Rx(null); + final RxList selectedPaidByEmployees = [].obs; + final RxList selectedCreatedByEmployees = + [].obs; + int _pageSize = 20; int _pageNumber = 1; @@ -29,13 +40,22 @@ class ExpenseController extends GetxController { fetchAllEmployees(); } - /// Load projects, expense types, statuses, and payment modes on controller init + bool get isFilterApplied { + return selectedProject.value.isNotEmpty || + selectedStatus.value.isNotEmpty || + startDate.value != null || + endDate.value != null || + selectedPaidByEmployees.isNotEmpty || + selectedCreatedByEmployees.isNotEmpty; + } + + /// Load master data Future loadInitialMasterData() async { await fetchGlobalProjects(); await fetchMasterData(); } - /// Fetch expenses with filters and pagination (called explicitly when needed) + /// Fetch expenses using filters Future fetchExpenses({ List? projectIds, List? statusIds, @@ -53,12 +73,18 @@ class ExpenseController extends GetxController { _pageNumber = pageNumber; final Map filterMap = { - "projectIds": projectIds ?? [], - "statusIds": statusIds ?? [], - "createdByIds": createdByIds ?? [], - "paidByIds": paidByIds ?? [], - "startDate": startDate?.toIso8601String(), - "endDate": endDate?.toIso8601String(), + "projectIds": projectIds ?? + (selectedProject.value.isEmpty + ? [] + : [projectsMap[selectedProject.value] ?? '']), + "statusIds": statusIds ?? + (selectedStatus.value.isEmpty ? [] : [selectedStatus.value]), + "createdByIds": + createdByIds ?? selectedCreatedByEmployees.map((e) => e.id).toList(), + "paidByIds": + paidByIds ?? selectedPaidByEmployees.map((e) => e.id).toList(), + "startDate": (startDate ?? this.startDate.value)?.toIso8601String(), + "endDate": (endDate ?? this.endDate.value)?.toIso8601String(), }; try { @@ -95,6 +121,16 @@ class ExpenseController extends GetxController { } } + /// Clear all filters + void clearFilters() { + selectedProject.value = ''; + selectedStatus.value = ''; + startDate.value = null; + endDate.value = null; + selectedPaidByEmployees.clear(); + selectedCreatedByEmployees.clear(); + } + /// Fetch master data: expense types, payment modes, and expense status Future fetchMasterData() async { try { @@ -121,7 +157,7 @@ class ExpenseController extends GetxController { } } - /// Fetch list of all global projects + /// Fetch global projects Future fetchGlobalProjects() async { try { final response = await ApiService.getGlobalProjects(); @@ -143,10 +179,9 @@ class ExpenseController extends GetxController { } } - /// Fetch all employees for Manage Bucket usage + /// Fetch all employees Future fetchAllEmployees() async { isLoading.value = true; - try { final response = await ApiService.getAllEmployees(); if (response != null && response.isNotEmpty) { @@ -166,23 +201,20 @@ class ExpenseController extends GetxController { logSafe("Error fetching employees in Manage Bucket", level: LogLevel.error, error: e); } - isLoading.value = false; update(); } - /// Update expense status and refresh the list + /// Update expense status Future updateExpenseStatus(String expenseId, String statusId) async { isLoading.value = true; errorMessage.value = ''; - try { logSafe("Updating status for expense: $expenseId -> $statusId"); final success = await ApiService.updateExpenseStatusApi( expenseId: expenseId, statusId: statusId, ); - if (success) { logSafe("Expense status updated successfully."); await fetchExpenses(); diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 4520a68..01476e7 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -241,6 +241,52 @@ class ApiService { // === Expense APIs === // + /// Get Expense Details API + static Future?> getExpenseDetailsApi({ + required String expenseId, + }) async { + final endpoint = "${ApiEndpoints.getExpenseDetails}/$expenseId"; + logSafe("Fetching expense details for ID: $expenseId"); + + try { + final response = await _getRequest(endpoint); + if (response == null) { + logSafe("Expense details request failed: null response", + level: LogLevel.error); + return null; + } + + final body = response.body.trim(); + if (body.isEmpty) { + logSafe("Expense details response body is empty", + level: LogLevel.error); + return null; + } + + final jsonResponse = jsonDecode(body); + if (jsonResponse is Map) { + if (jsonResponse['success'] == true) { + logSafe("Expense details fetched successfully"); + return jsonResponse['data']; // Return the expense details object + } else { + logSafe( + "Failed to fetch expense details: ${jsonResponse['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } else { + logSafe("Unexpected response structure: $jsonResponse", + level: LogLevel.error); + } + } catch (e, stack) { + logSafe("Exception during getExpenseDetailsApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + /// Update Expense Status API static Future updateExpenseStatusApi({ required String expenseId, diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 0370bff..3dade46 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -4,36 +4,34 @@ import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/utils/my_shadow.dart'; class SkeletonLoaders { - -static Widget buildLoadingSkeleton() { - return SizedBox( - height: 360, - child: Column( - children: List.generate(5, (index) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: List.generate(6, (i) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 4), - width: 48, - height: 16, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(6), - ), - ); - }), + static Widget buildLoadingSkeleton() { + return SizedBox( + height: 360, + child: Column( + children: List.generate(5, (index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(6, (i) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 48, + height: 16, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ); + }), + ), ), - ), - ); - }), - ), - ); -} - + ); + }), + ), + ); + } // Employee List - Card Style static Widget employeeListSkeletonLoader() { @@ -63,25 +61,37 @@ static Widget buildLoadingSkeleton() { children: [ Row( children: [ - Container(height: 14, width: 100, color: Colors.grey.shade300), + Container( + height: 14, + width: 100, + color: Colors.grey.shade300), MySpacing.width(8), - Container(height: 12, width: 60, color: Colors.grey.shade300), + Container( + height: 12, width: 60, color: Colors.grey.shade300), ], ), MySpacing.height(8), Row( children: [ - Icon(Icons.email, size: 16, color: Colors.grey.shade300), + Icon(Icons.email, + size: 16, color: Colors.grey.shade300), MySpacing.width(4), - Container(height: 10, width: 140, color: Colors.grey.shade300), + Container( + height: 10, + width: 140, + color: Colors.grey.shade300), ], ), MySpacing.height(8), Row( children: [ - Icon(Icons.phone, size: 16, color: Colors.grey.shade300), + Icon(Icons.phone, + size: 16, color: Colors.grey.shade300), MySpacing.width(4), - Container(height: 10, width: 100, color: Colors.grey.shade300), + Container( + height: 10, + width: 100, + color: Colors.grey.shade300), ], ), ], @@ -122,16 +132,28 @@ static Widget buildLoadingSkeleton() { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container(height: 12, width: 100, color: Colors.grey.shade300), + Container( + height: 12, + width: 100, + color: Colors.grey.shade300), MySpacing.height(8), - Container(height: 10, width: 80, color: Colors.grey.shade300), + Container( + height: 10, + width: 80, + color: Colors.grey.shade300), MySpacing.height(12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - Container(height: 28, width: 60, color: Colors.grey.shade300), + Container( + height: 28, + width: 60, + color: Colors.grey.shade300), MySpacing.width(8), - Container(height: 28, width: 60, color: Colors.grey.shade300), + Container( + height: 28, + width: 60, + color: Colors.grey.shade300), ], ), ], @@ -167,7 +189,8 @@ static Widget buildLoadingSkeleton() { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Container(height: 14, width: 120, color: Colors.grey.shade300), + Container( + height: 14, width: 120, color: Colors.grey.shade300), Icon(Icons.add_circle, color: Colors.grey.shade300), ], ), @@ -226,133 +249,198 @@ static Widget buildLoadingSkeleton() { }), ); } - static Widget employeeSkeletonCard() { - return MyCard.bordered( - margin: MySpacing.only(bottom: 12), - paddingAll: 12, - borderRadiusAll: 12, - shadow: MyShadow( - elevation: 1.5, - position: MyShadowPosition.bottom, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Avatar - Container( - height: 35, - width: 35, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - ), - ), - MySpacing.width(12), - // Name, org, email, phone - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container(height: 12, width: 120, color: Colors.grey.shade300), - MySpacing.height(6), - Container(height: 10, width: 80, color: Colors.grey.shade300), - MySpacing.height(8), - - // Email placeholder - Row( - children: [ - Icon(Icons.email_outlined, size: 14, color: Colors.grey.shade300), - MySpacing.width(4), - Container(height: 10, width: 140, color: Colors.grey.shade300), - ], - ), - MySpacing.height(8), - - // Phone placeholder - Row( - children: [ - Icon(Icons.phone_outlined, size: 14, color: Colors.grey.shade300), - MySpacing.width(4), - Container(height: 10, width: 100, color: Colors.grey.shade300), - MySpacing.width(8), - Container( - height: 16, - width: 16, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - ), - ), - ], - ), - MySpacing.height(8), - - // Tags placeholder - Container(height: 8, width: 80, color: Colors.grey.shade300), - ], - ), - ), - - // Arrow - Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300), - ], - ), - ); -} - - static Widget contactSkeletonCard() { - return MyCard.bordered( - margin: MySpacing.only(bottom: 12), - paddingAll: 16, - borderRadiusAll: 16, - shadow: MyShadow( - elevation: 1.5, - position: MyShadowPosition.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + static Widget expenseListSkeletonLoader() { + return ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + itemCount: 6, // Show 6 skeleton items + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), + itemBuilder: (context, index) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - height: 40, - width: 40, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - ), + // Title and Amount + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + height: 14, + width: 120, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ), + Container( + height: 14, + width: 80, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ), + ], ), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: 12, - width: 100, + const SizedBox(height: 6), + // Date and Status + Row( + children: [ + Container( + height: 12, + width: 100, + decoration: BoxDecoration( color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), ), - MySpacing.height(6), - Container( - height: 10, - width: 60, + ), + const Spacer(), + Container( + height: 12, + width: 50, + decoration: BoxDecoration( color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), ), - ], - ), + ), + ], ), ], - ), - MySpacing.height(16), - Container(height: 10, width: 150, color: Colors.grey.shade300), - MySpacing.height(8), - Container(height: 10, width: 100, color: Colors.grey.shade300), - MySpacing.height(8), - Container(height: 10, width: 120, color: Colors.grey.shade300), - ], - ), - ); -} + ); + }, + ); + } + static Widget employeeSkeletonCard() { + return MyCard.bordered( + margin: MySpacing.only(bottom: 12), + paddingAll: 12, + borderRadiusAll: 12, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + MySpacing.width(12), + + // Name, org, email, phone + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 12, width: 120, color: Colors.grey.shade300), + MySpacing.height(6), + Container(height: 10, width: 80, color: Colors.grey.shade300), + MySpacing.height(8), + + // Email placeholder + Row( + children: [ + Icon(Icons.email_outlined, + size: 14, color: Colors.grey.shade300), + MySpacing.width(4), + Container( + height: 10, width: 140, color: Colors.grey.shade300), + ], + ), + MySpacing.height(8), + + // Phone placeholder + Row( + children: [ + Icon(Icons.phone_outlined, + size: 14, color: Colors.grey.shade300), + MySpacing.width(4), + Container( + height: 10, width: 100, color: Colors.grey.shade300), + MySpacing.width(8), + Container( + height: 16, + width: 16, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + ], + ), + MySpacing.height(8), + + // Tags placeholder + Container(height: 8, width: 80, color: Colors.grey.shade300), + ], + ), + ), + + // Arrow + Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300), + ], + ), + ); + } + + static Widget contactSkeletonCard() { + return MyCard.bordered( + margin: MySpacing.only(bottom: 12), + paddingAll: 16, + borderRadiusAll: 16, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + height: 40, + width: 40, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: 100, + color: Colors.grey.shade300, + ), + MySpacing.height(6), + Container( + height: 10, + width: 60, + color: Colors.grey.shade300, + ), + ], + ), + ), + ], + ), + MySpacing.height(16), + Container(height: 10, width: 150, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 100, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 120, color: Colors.grey.shade300), + ], + ), + ); + } } diff --git a/lib/model/expense/expense_list_model.dart b/lib/model/expense/expense_list_model.dart index 410c0be..bdebfb4 100644 --- a/lib/model/expense/expense_list_model.dart +++ b/lib/model/expense/expense_list_model.dart @@ -24,15 +24,19 @@ class ExpenseResponse { required this.timestamp, }); - factory ExpenseResponse.fromJson(Map json) => - ExpenseResponse( - success: json["success"], - message: json["message"], - data: ExpenseData.fromJson(json["data"]), - errors: json["errors"], - statusCode: json["statusCode"], - timestamp: DateTime.parse(json["timestamp"]), - ); + factory ExpenseResponse.fromJson(Map json) { + final dataField = json["data"]; + return ExpenseResponse( + success: json["success"] ?? false, + message: json["message"] ?? '', + data: (dataField is Map) + ? ExpenseData.fromJson(dataField) + : ExpenseData.empty(), + errors: json["errors"], + statusCode: json["statusCode"] ?? 0, + timestamp: DateTime.tryParse(json["timestamp"] ?? '') ?? DateTime.now(), + ); + } Map toJson() => { "success": success, @@ -45,12 +49,14 @@ class ExpenseResponse { } class ExpenseData { + final Filter? filter; final int currentPage; final int totalPages; final int totalEntites; final List data; ExpenseData({ + required this.filter, required this.currentPage, required this.totalPages, required this.totalEntites, @@ -58,14 +64,25 @@ class ExpenseData { }); factory ExpenseData.fromJson(Map json) => ExpenseData( - currentPage: json["currentPage"], - totalPages: json["totalPages"], - totalEntites: json["totalEntites"], - data: List.from( - json["data"].map((x) => ExpenseModel.fromJson(x))), + filter: json["filter"] != null ? Filter.fromJson(json["filter"]) : null, + currentPage: json["currentPage"] ?? 0, + totalPages: json["totalPages"] ?? 0, + totalEntites: json["totalEntites"] ?? 0, + data: (json["data"] as List? ?? []) + .map((x) => ExpenseModel.fromJson(x)) + .toList(), + ); + + factory ExpenseData.empty() => ExpenseData( + filter: null, + currentPage: 0, + totalPages: 0, + totalEntites: 0, + data: [], ); Map toJson() => { + "filter": filter?.toJson(), "currentPage": currentPage, "totalPages": totalPages, "totalEntites": totalEntites, @@ -73,6 +90,47 @@ class ExpenseData { }; } +class Filter { + final List projectIds; + final List statusIds; + final List createdByIds; + final List paidById; + final DateTime? startDate; + final DateTime? endDate; + + Filter({ + required this.projectIds, + required this.statusIds, + required this.createdByIds, + required this.paidById, + required this.startDate, + required this.endDate, + }); + + factory Filter.fromJson(Map json) => Filter( + projectIds: List.from(json["projectIds"] ?? []), + statusIds: List.from(json["statusIds"] ?? []), + createdByIds: List.from(json["createdByIds"] ?? []), + paidById: List.from(json["paidById"] ?? []), + startDate: + json["startDate"] != null ? DateTime.tryParse(json["startDate"]) : null, + endDate: + json["endDate"] != null ? DateTime.tryParse(json["endDate"]) : null, + ); + + Map toJson() => { + "projectIds": projectIds, + "statusIds": statusIds, + "createdByIds": createdByIds, + "paidById": paidById, + "startDate": startDate?.toIso8601String(), + "endDate": endDate?.toIso8601String(), + }; +} + +// --- ExpenseModel and other classes remain same as you wrote --- +// I will include them here for completeness. + class ExpenseModel { final String id; final Project project; @@ -105,22 +163,22 @@ class ExpenseModel { }); 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: json["nextStatus"] != null - ? List.from( - json["nextStatus"].map((x) => Status.fromJson(x)), - ) - : [], + 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.tryParse(json["transactionDate"] ?? '') ?? DateTime.now(), + createdAt: + DateTime.tryParse(json["createdAt"] ?? '') ?? DateTime.now(), + supplerName: json["supplerName"] ?? '', + amount: (json["amount"] ?? 0).toDouble(), + status: Status.fromJson(json["status"] ?? {}), + nextStatus: (json["nextStatus"] as List? ?? []) + .map((x) => Status.fromJson(x)) + .toList(), preApproved: json["preApproved"] ?? false, ); @@ -163,14 +221,15 @@ class Project { }); 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"], + id: json["id"] ?? '', + name: json["name"] ?? '', + shortName: json["shortName"] ?? '', + projectAddress: json["projectAddress"] ?? '', + contactPerson: json["contactPerson"] ?? '', + startDate: + DateTime.tryParse(json["startDate"] ?? '') ?? DateTime.now(), + endDate: DateTime.tryParse(json["endDate"] ?? '') ?? DateTime.now(), + projectStatusId: json["projectStatusId"] ?? '', ); Map toJson() => { @@ -199,10 +258,10 @@ class ExpenseType { }); factory ExpenseType.fromJson(Map json) => ExpenseType( - id: json["id"], - name: json["name"], - noOfPersonsRequired: json["noOfPersonsRequired"], - description: json["description"], + id: json["id"] ?? '', + name: json["name"] ?? '', + noOfPersonsRequired: json["noOfPersonsRequired"] ?? false, + description: json["description"] ?? '', ); Map toJson() => { @@ -225,9 +284,9 @@ class PaymentMode { }); factory PaymentMode.fromJson(Map json) => PaymentMode( - id: json["id"], - name: json["name"], - description: json["description"], + id: json["id"] ?? '', + name: json["name"] ?? '', + description: json["description"] ?? '', ); Map toJson() => { @@ -255,11 +314,11 @@ class PaidBy { }); factory PaidBy.fromJson(Map json) => PaidBy( - id: json["id"], - firstName: json["firstName"], - lastName: json["lastName"], - photo: json["photo"], - jobRoleId: json["jobRoleId"], + id: json["id"] ?? '', + firstName: json["firstName"] ?? '', + lastName: json["lastName"] ?? '', + photo: json["photo"] ?? '', + jobRoleId: json["jobRoleId"] ?? '', jobRoleName: json["jobRoleName"], ); @@ -291,11 +350,11 @@ class CreatedBy { }); factory CreatedBy.fromJson(Map json) => CreatedBy( - id: json["id"], - firstName: json["firstName"], - lastName: json["lastName"], - photo: json["photo"], - jobRoleId: json["jobRoleId"], + id: json["id"] ?? '', + firstName: json["firstName"] ?? '', + lastName: json["lastName"] ?? '', + photo: json["photo"] ?? '', + jobRoleId: json["jobRoleId"] ?? '', jobRoleName: json["jobRoleName"], ); @@ -327,12 +386,12 @@ class Status { }); factory Status.fromJson(Map json) => Status( - id: json["id"], - name: json["name"], - displayName: json["displayName"], - description: json["description"], - color: json["color"], - isSystem: json["isSystem"], + id: json["id"] ?? '', + name: json["name"] ?? '', + displayName: json["displayName"] ?? '', + description: json["description"] ?? '', + color: (json["color"] ?? '').replaceAll("'", ''), + isSystem: json["isSystem"] ?? false, ); Map toJson() => { diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index d5450ab..ad1991d 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -1,27 +1,34 @@ 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:intl/intl.dart'; +import 'package:marco/controller/expense/expense_detail_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/expense_list_model.dart'; -import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/controller/project_controller.dart'; class ExpenseDetailScreen extends StatelessWidget { - const ExpenseDetailScreen({super.key}); + final String expenseId; - static Color getStatusColor(String? status) { + const ExpenseDetailScreen({super.key, required this.expenseId}); + + // Status color logic + static Color getStatusColor(String? status, {String? colorCode}) { + if (colorCode != null && colorCode.isNotEmpty) { + try { + return Color(int.parse(colorCode.replaceFirst('#', '0xff'))); + } catch (_) {} + } switch (status) { - case 'Requested': - return Colors.blue; - case 'Review': + case 'Approval Pending': return Colors.orange; - case 'Approved': - return Colors.green; + case 'Process Pending': + return Colors.blue; + case 'Rejected': + return Colors.red; case 'Paid': - return Colors.purple; - case 'Closed': - return Colors.grey; + return Colors.green; default: return Colors.black; } @@ -29,12 +36,9 @@ class ExpenseDetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final ExpenseModel expense = Get.arguments['expense'] as ExpenseModel; - final statusColor = getStatusColor(expense.status.name); + final controller = Get.put(ExpenseDetailController()); final projectController = Get.find(); - final expenseController = Get.find(); - print( - "Next Status List: ${expense.nextStatus.map((e) => e.toJson()).toList()}"); + controller.fetchExpenseDetails(expenseId); return Scaffold( backgroundColor: const Color(0xFFF7F7F7), @@ -48,12 +52,12 @@ class ExpenseDetailScreen extends StatelessWidget { title: Padding( padding: MySpacing.xy(16, 0), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), - onPressed: () => Get.back(), + onPressed: () => + Get.offAllNamed('/dashboard/expense-main-page'), ), MySpacing.width(8), Expanded( @@ -98,84 +102,146 @@ class ExpenseDetailScreen extends StatelessWidget { ), ), ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - 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), - ], - ), - ), - bottomNavigationBar: expense.nextStatus.isNotEmpty - ? SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: expense.nextStatus.map((next) { - return ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(100, 40), - padding: const EdgeInsets.symmetric( - vertical: 8, horizontal: 12), - backgroundColor: Colors.red, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), - ), - ), - onPressed: () async { - final success = - await expenseController.updateExpenseStatus( - expense.id, - next.id, - ); - - if (success) { - Get.snackbar( - 'Success', - 'Expense moved to ${next.name}', - backgroundColor: Colors.green.withOpacity(0.8), - colorText: Colors.white, - ); - Get.back(result: true); - } else { - Get.snackbar( - 'Error', - 'Failed to update status.', - backgroundColor: Colors.red.withOpacity(0.8), - colorText: Colors.white, - ); - } - }, - child: Text( - next.name, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, - ), - ); - }).toList(), - ), + body: SafeArea( + child: Obx(() { + if (controller.isLoading.value) { + return _buildLoadingSkeleton(); + } + if (controller.errorMessage.isNotEmpty) { + return Center( + child: Text( + controller.errorMessage.value, + style: const TextStyle(color: Colors.red, fontSize: 16), ), - ) - : null, + ); + } + + final expense = controller.expense.value; + if (expense == null) { + return const Center(child: Text("No expense details found.")); + } + + final statusColor = getStatusColor( + expense.status.name, + colorCode: expense.status.color, + ); + + final formattedAmount = NumberFormat.currency( + locale: 'en_IN', + symbol: '₹ ', + decimalDigits: 2, + ).format(expense.amount); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ExpenseHeader( + title: expense.expensesType.name, + amount: formattedAmount, + status: expense.status.name, + statusColor: statusColor, + ), + const SizedBox(height: 16), + _ExpenseDetailsList(expense: expense), + const SizedBox(height: 100), + ], + ), + ); + }), + ), + bottomNavigationBar: Obx(() { + final expense = controller.expense.value; + if (expense == null || expense.nextStatus.isEmpty) { + return const SizedBox(); + } + + return SafeArea( + child: Container( + color: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: expense.nextStatus.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 { + final success = await controller.updateExpenseStatus( + expense.id, next.id); + if (success) { + Get.snackbar( + 'Success', + 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', + backgroundColor: Colors.green.withOpacity(0.8), + colorText: Colors.white, + ); + await controller.fetchExpenseDetails(expenseId); + } else { + Get.snackbar( + 'Error', + 'Failed to update status.', + backgroundColor: Colors.red.withOpacity(0.8), + colorText: Colors.white, + ); + } + }, + child: Text( + next.displayName.isNotEmpty ? next.displayName : next.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + ), + ), + ); + }), + ); + } + + // Loading skeleton placeholder + 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), + ), + ); + }), ); } } +// Expense header card class _ExpenseHeader extends StatelessWidget { final String title; final String amount; @@ -229,7 +295,7 @@ class _ExpenseHeader extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.15), + color: statusColor.withOpacity(0.15), borderRadius: BorderRadius.circular(20), ), child: Row( @@ -239,8 +305,8 @@ class _ExpenseHeader extends StatelessWidget { const SizedBox(width: 6), Text( status, - style: const TextStyle( - color: Colors.black, + style: TextStyle( + color: statusColor, fontWeight: FontWeight.w600, ), ), @@ -253,6 +319,7 @@ class _ExpenseHeader extends StatelessWidget { } } +// Expense details list class _ExpenseDetailsList extends StatelessWidget { final ExpenseModel expense; @@ -289,28 +356,41 @@ class _ExpenseDetailsList extends StatelessWidget { _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}'), + title: "Paid By", + value: '${expense.paidBy.firstName} ${expense.paidBy.lastName}', + ), _DetailRow( - title: "Created By", - value: - '${expense.createdBy.firstName} ${expense.createdBy.lastName}'), + 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: "Amount", + value: NumberFormat.currency( + locale: 'en_IN', + symbol: '₹ ', + decimalDigits: 2, + ).format(expense.amount), + ), _DetailRow(title: "Status", value: expense.status.name), _DetailRow( - title: "Next Status", - value: expense.nextStatus.map((e) => e.name).join(", ")), + title: "Next Status", + value: expense.nextStatus.map((e) => e.name).join(", "), + ), _DetailRow( - title: "Pre-Approved", value: expense.preApproved ? "Yes" : "No"), + title: "Pre-Approved", + value: expense.preApproved ? "Yes" : "No", + ), ], ), ); } } +// A single row for expense details class _DetailRow extends StatelessWidget { final String title; final String value; @@ -343,6 +423,7 @@ class _DetailRow extends StatelessWidget { fontSize: 15, fontWeight: FontWeight.w600, ), + softWrap: true, ), ), ], diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart index 0165940..b5b6bf5 100644 --- a/lib/view/expense/expense_filter_bottom_sheet.dart +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -5,178 +5,73 @@ import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/employee_model.dart'; +/// Wrapper to open Expense Filter Bottom Sheet +void openExpenseFilterBottomSheet( + BuildContext context, ExpenseController expenseController) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return ExpenseFilterBottomSheetWrapper( + expenseController: expenseController); + }, + ); +} + +class ExpenseFilterBottomSheetWrapper extends StatelessWidget { + final ExpenseController expenseController; + + const ExpenseFilterBottomSheetWrapper( + {super.key, required this.expenseController}); + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return ExpenseFilterBottomSheet( + expenseController: expenseController, + scrollController: scrollController, + ); + }, + ); + } +} + class ExpenseFilterBottomSheet extends StatelessWidget { final ExpenseController expenseController; - final RxList selectedPaidByEmployees; - final RxList selectedCreatedByEmployees; + final ScrollController scrollController; - ExpenseFilterBottomSheet({ + const ExpenseFilterBottomSheet({ super.key, required this.expenseController, - required this.selectedPaidByEmployees, - required this.selectedCreatedByEmployees, + required this.scrollController, }); - final RxString selectedProject = ''.obs; - final Rx startDate = Rx(null); - final Rx endDate = Rx(null); - final RxString selectedEmployee = ''.obs; - @override Widget build(BuildContext context) { return Obx(() { - return Container( - padding: const EdgeInsets.all(16), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: SingleChildScrollView( + return SafeArea( + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, children: [ - MyText.titleLarge('Filter Expenses', fontWeight: 700), - const SizedBox(height: 16), - - /// Project Filter - MyText.bodyMedium('Project', fontWeight: 600), - const SizedBox(height: 6), - DropdownButtonFormField( - value: selectedProject.value.isEmpty - ? null - : selectedProject.value, - items: expenseController.globalProjects - .map((proj) => DropdownMenuItem( - value: proj, - child: Text(proj), - )) - .toList(), - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + child: _buildContent(context), ), - hint: const Text('Select Project'), - onChanged: (value) { - selectedProject.value = value ?? ''; - }, - ), - - const SizedBox(height: 16), - - /// Date Range Filter - MyText.bodyMedium('Date Range', fontWeight: 600), - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: _DatePickerField( - label: startDate.value == null - ? 'Start Date' - : DateTimeUtils.formatDate( - startDate.value!, 'dd MMM yyyy'), - onTap: () async { - DateTime? picked = await showDatePicker( - context: context, - initialDate: startDate.value ?? DateTime.now(), - firstDate: DateTime(2020), - lastDate: - DateTime.now().add(const Duration(days: 365)), - ); - if (picked != null) startDate.value = picked; - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _DatePickerField( - label: endDate.value == null - ? 'End Date' - : DateTimeUtils.formatDate( - endDate.value!, 'dd MMM yyyy'), - onTap: () async { - DateTime? picked = await showDatePicker( - context: context, - initialDate: endDate.value ?? DateTime.now(), - firstDate: DateTime(2020), - lastDate: - DateTime.now().add(const Duration(days: 365)), - ); - if (picked != null) endDate.value = picked; - }, - ), - ), - ], - ), - - const SizedBox(height: 16), - - /// Paid By Filter - _employeeFilterSection( - title: 'Paid By', - selectedEmployees: selectedPaidByEmployees, - expenseController: expenseController, - ), - const SizedBox(height: 24), - - /// Created By Filter - _employeeFilterSection( - title: 'Created By', - selectedEmployees: selectedCreatedByEmployees, - expenseController: expenseController, - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey.shade300, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onPressed: () => Get.back(), - child: const Text('Cancel'), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onPressed: () { - expenseController.fetchExpenses( - projectIds: selectedProject.value.isEmpty - ? null - : [ - expenseController - .projectsMap[selectedProject.value]! - ], - paidByIds: selectedPaidByEmployees.isEmpty - ? null - : selectedPaidByEmployees.map((e) => e.id).toList(), - createdByIds: selectedCreatedByEmployees.isEmpty - ? null - : selectedCreatedByEmployees - .map((e) => e.id) - .toList(), - startDate: startDate.value, - endDate: endDate.value, - ); - Get.back(); - }, - child: const Text('Apply'), - ), - ], ), + _buildBottomButtons(), ], ), ), @@ -184,17 +79,282 @@ class ExpenseFilterBottomSheet extends StatelessWidget { }); } - /// Employee Filter Section - Widget _employeeFilterSection({ - required String title, - required RxList selectedEmployees, - required ExpenseController expenseController, + /// Builds the filter content + Widget _buildContent(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 50, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.titleLarge('Filter Expenses', fontWeight: 700), + TextButton( + onPressed: () => expenseController.clearFilters(), + child: const Text( + "Reset Filter", + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + /// Project Filter + _buildCardSection( + title: "Project", + child: _popupSelector( + context, + currentValue: expenseController.selectedProject.value.isEmpty + ? 'Select Project' + : expenseController.selectedProject.value, + items: expenseController.globalProjects, + onSelected: (value) => + expenseController.selectedProject.value = value, + ), + ), + const SizedBox(height: 16), + + /// Expense Status Filter + _buildCardSection( + title: "Expense Status", + child: _popupSelector( + context, + currentValue: expenseController.selectedStatus.value.isEmpty + ? 'Select Expense Status' + : expenseController.expenseStatuses + .firstWhereOrNull((e) => + e.id == expenseController.selectedStatus.value) + ?.name ?? + 'Select Expense Status', + items: + expenseController.expenseStatuses.map((e) => e.name).toList(), + onSelected: (name) { + final status = expenseController.expenseStatuses + .firstWhere((e) => e.name == name); + expenseController.selectedStatus.value = status.id; + }, + ), + ), + const SizedBox(height: 16), + + /// Date Range Filter + _buildCardSection( + title: "Date Range", + child: Row( + children: [ + Expanded( + child: _dateButton( + label: expenseController.startDate.value == null + ? 'Start Date' + : DateTimeUtils.formatDate( + expenseController.startDate.value!, 'dd MMM yyyy'), + onTap: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: + expenseController.startDate.value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) + expenseController.startDate.value = picked; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _dateButton( + label: expenseController.endDate.value == null + ? 'End Date' + : DateTimeUtils.formatDate( + expenseController.endDate.value!, 'dd MMM yyyy'), + onTap: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: + expenseController.endDate.value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) + expenseController.endDate.value = picked; + }, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + /// Paid By Filter + _buildCardSection( + title: "Paid By", + child: _employeeFilterSection( + selectedEmployees: expenseController.selectedPaidByEmployees, + ), + ), + const SizedBox(height: 16), + + /// Created By Filter + _buildCardSection( + title: "Created By", + child: _employeeFilterSection( + selectedEmployees: expenseController.selectedCreatedByEmployees, + ), + ), + const SizedBox(height: 24), + ], + ); + } + + /// Bottom Action Buttons + Widget _buildBottomButtons() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + // Cancel Button + Expanded( + child: ElevatedButton.icon( + onPressed: () { + Get.back(); + }, + icon: const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium( + "Cancel", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + ), + ), + ), + const SizedBox(width: 12), + + // Submit Button + Expanded( + child: ElevatedButton.icon( + onPressed: () { + expenseController.fetchExpenses(); + Get.back(); + }, + icon: const Icon(Icons.check_circle_outline, color: Colors.white), + label: MyText.bodyMedium( + "Submit", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + ), + ), + ), + ], + ), + ); + } + + /// Popup Selector + Widget _popupSelector( + BuildContext context, { + required String currentValue, + required List items, + required ValueChanged onSelected, }) { + return PopupMenuButton( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + onSelected: onSelected, + itemBuilder: (context) { + return items + .map((e) => PopupMenuItem( + value: e, + child: Text(e), + )) + .toList(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + currentValue, + style: const TextStyle(color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], + ), + ), + ); + } + + /// Card Section Wrapper + Widget _buildCardSection({required String title, required Widget child}) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodyMedium(title, fontWeight: 600), const SizedBox(height: 6), + child, + ], + ); + } + + /// Date Button + Widget _dateButton({required String label, required VoidCallback onTap}) { + return ElevatedButton.icon( + onPressed: onTap, + icon: const Icon(Icons.calendar_today, size: 16), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey.shade100, + foregroundColor: Colors.black, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + ), + label: Text(label, overflow: TextOverflow.ellipsis), + ); + } + + /// Employee Filter Section + Widget _employeeFilterSection( + {required RxList selectedEmployees}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Obx(() { return Wrap( spacing: 6, @@ -270,35 +430,3 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } } - -/// Date Picker Field -class _DatePickerField extends StatelessWidget { - final String label; - final VoidCallback onTap; - - const _DatePickerField({ - required this.label, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: const TextStyle(color: Colors.black87)), - const Icon(Icons.calendar_today, size: 16, color: Colors.grey), - ], - ), - ), - ); - } -} diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 874480a..ac95483 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -8,8 +8,8 @@ import 'package:marco/helpers/widgets/my_text.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'; -import 'package:marco/model/employee_model.dart'; import 'package:marco/view/expense/expense_filter_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -22,22 +22,33 @@ 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 RxList selectedPaidByEmployees = [].obs; - final RxList selectedCreatedByEmployees = - [].obs; @override void initState() { super.initState(); - expenseController.fetchExpenses(); + expenseController.fetchExpenses(); // Initial data load } void _refreshExpenses() { expenseController.fetchExpenses(); } + void _openFilterBottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return ExpenseFilterBottomSheetWrapper( + expenseController: expenseController, + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -49,14 +60,14 @@ class _ExpenseMainScreenState extends State { _SearchAndFilter( searchController: searchController, onChanged: (value) => searchQuery.value = value, - onFilterTap: _openFilterBottomSheet, + onFilterTap: () => _openFilterBottomSheet(context), onRefreshTap: _refreshExpenses, ), _ToggleButtons(isHistoryView: isHistoryView), Expanded( child: Obx(() { if (expenseController.isLoading.value) { - return const Center(child: CircularProgressIndicator()); + return SkeletonLoaders.expenseListSkeletonLoader(); } if (expenseController.errorMessage.isNotEmpty) { @@ -81,7 +92,7 @@ class _ExpenseMainScreenState extends State { expense.paymentMode.name.toLowerCase().contains(query); }).toList(); - // Sort by latest transaction date first + // Sort by latest transaction date filteredList.sort( (a, b) => b.transactionDate.compareTo(a.transactionDate)); @@ -113,19 +124,9 @@ class _ExpenseMainScreenState extends State { ), ); } - - void _openFilterBottomSheet() { - Get.bottomSheet( - ExpenseFilterBottomSheet( - expenseController: expenseController, - selectedPaidByEmployees: selectedPaidByEmployees, - selectedCreatedByEmployees: selectedCreatedByEmployees, - ), - ); - } } -// AppBar Widget +///---------------------- APP BAR ----------------------/// class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { final ProjectController projectController; @@ -170,7 +171,6 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { projectController.selectedProject?.name ?? 'Select Project'; return InkWell( - onTap: () => Get.toNamed('/project-selector'), child: Row( children: [ const Icon(Icons.work_outline, @@ -200,8 +200,7 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { } } -// Search and Filter Widget - +///---------------------- SEARCH AND FILTER ----------------------/// class _SearchAndFilter extends StatelessWidget { final TextEditingController searchController; final ValueChanged onChanged; @@ -217,6 +216,8 @@ class _SearchAndFilter extends StatelessWidget { @override Widget build(BuildContext context) { + final ExpenseController expenseController = Get.find(); + return Padding( padding: MySpacing.fromLTRB(12, 10, 12, 0), child: Row( @@ -259,17 +260,42 @@ class _SearchAndFilter extends StatelessWidget { ), ), MySpacing.width(8), - IconButton( - icon: const Icon(Icons.tune, color: Colors.black), - onPressed: onFilterTap, - ), + Obx(() { + final bool showRedDot = expenseController.isFilterApplied; + return IconButton( + onPressed: onFilterTap, + icon: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.tune, color: Colors.black, size: 24), + if (showRedDot) + Positioned( + top: -1, + right: -1, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 1.5, + ), + ), + ), + ), + ], + ), + ); + }), ], ), ); } } -// Toggle Buttons Widget +///---------------------- TOGGLE BUTTONS ----------------------/// class _ToggleButtons extends StatelessWidget { final RxBool isHistoryView; @@ -360,7 +386,7 @@ class _ToggleButton extends StatelessWidget { } } -// Expense List Widget (Dynamic) +///---------------------- EXPENSE LIST ----------------------/// class _ExpenseList extends StatelessWidget { final List expenseList; @@ -371,7 +397,7 @@ class _ExpenseList extends StatelessWidget { if (expenseList.isEmpty) { return Center(child: MyText.bodyMedium('No expenses found.')); } - + final expenseController = Get.find(); return ListView.separated( padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), itemCount: expenseList.length, @@ -386,10 +412,17 @@ class _ExpenseList extends StatelessWidget { ); return GestureDetector( - onTap: () => Get.to( - () => const ExpenseDetailScreen(), - arguments: {'expense': expense}, - ), + onTap: () async { + final result = await Get.to( + () => ExpenseDetailScreen(expenseId: expense.id), + arguments: {'expense': expense}, + ); + + // If status was updated, refresh expenses + if (result == true) { + expenseController.fetchExpenses(); + } + }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( @@ -398,13 +431,9 @@ class _ExpenseList extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - MyText.bodyMedium( - expense.expensesType.name, - fontWeight: 700, - ), - ], + MyText.bodyMedium( + expense.expensesType.name, + fontWeight: 700, ), MyText.bodyMedium( '₹ ${expense.amount.toStringAsFixed(2)}',