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.
This commit is contained in:
Vaibhav Surve 2025-07-28 12:09:13 +05:30
parent 9124b815ef
commit e5b3616245
8 changed files with 1108 additions and 575 deletions

View File

@ -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<ExpenseModel?> expense = Rx<ExpenseModel?>(null);
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
/// Fetch expense details by ID
Future<void> 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<bool> 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;
}
}
}

View File

@ -12,6 +12,8 @@ class ExpenseController extends GetxController {
final RxList<ExpenseModel> expenses = <ExpenseModel>[].obs;
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
// Master data
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
final RxList<ExpenseStatusModel> expenseStatuses = <ExpenseStatusModel>[].obs;
@ -19,6 +21,15 @@ class ExpenseController extends GetxController {
final RxMap<String, String> projectsMap = <String, String>{}.obs;
RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
// Persistent Filter States
final RxString selectedProject = ''.obs;
final RxString selectedStatus = ''.obs;
final Rx<DateTime?> startDate = Rx<DateTime?>(null);
final Rx<DateTime?> endDate = Rx<DateTime?>(null);
final RxList<EmployeeModel> selectedPaidByEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> selectedCreatedByEmployees =
<EmployeeModel>[].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<void> loadInitialMasterData() async {
await fetchGlobalProjects();
await fetchMasterData();
}
/// Fetch expenses with filters and pagination (called explicitly when needed)
/// Fetch expenses using filters
Future<void> fetchExpenses({
List<String>? projectIds,
List<String>? statusIds,
@ -53,12 +73,18 @@ class ExpenseController extends GetxController {
_pageNumber = pageNumber;
final Map<String, dynamic> 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<void> fetchMasterData() async {
try {
@ -121,7 +157,7 @@ class ExpenseController extends GetxController {
}
}
/// Fetch list of all global projects
/// Fetch global projects
Future<void> 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<void> 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<bool> 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();

View File

@ -241,6 +241,52 @@ class ApiService {
// === Expense APIs === //
/// Get Expense Details API
static Future<Map<String, dynamic>?> 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<String, dynamic>) {
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<bool> updateExpenseStatusApi({
required String expenseId,

View File

@ -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),
],
),
);
}
}

View File

@ -24,15 +24,19 @@ class ExpenseResponse {
required this.timestamp,
});
factory ExpenseResponse.fromJson(Map<String, dynamic> 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<String, dynamic> json) {
final dataField = json["data"];
return ExpenseResponse(
success: json["success"] ?? false,
message: json["message"] ?? '',
data: (dataField is Map<String, dynamic>)
? ExpenseData.fromJson(dataField)
: ExpenseData.empty(),
errors: json["errors"],
statusCode: json["statusCode"] ?? 0,
timestamp: DateTime.tryParse(json["timestamp"] ?? '') ?? DateTime.now(),
);
}
Map<String, dynamic> 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<ExpenseModel> data;
ExpenseData({
required this.filter,
required this.currentPage,
required this.totalPages,
required this.totalEntites,
@ -58,14 +64,25 @@ class ExpenseData {
});
factory ExpenseData.fromJson(Map<String, dynamic> json) => ExpenseData(
currentPage: json["currentPage"],
totalPages: json["totalPages"],
totalEntites: json["totalEntites"],
data: List<ExpenseModel>.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<dynamic>? ?? [])
.map((x) => ExpenseModel.fromJson(x))
.toList(),
);
factory ExpenseData.empty() => ExpenseData(
filter: null,
currentPage: 0,
totalPages: 0,
totalEntites: 0,
data: [],
);
Map<String, dynamic> toJson() => {
"filter": filter?.toJson(),
"currentPage": currentPage,
"totalPages": totalPages,
"totalEntites": totalEntites,
@ -73,6 +90,47 @@ class ExpenseData {
};
}
class Filter {
final List<String> projectIds;
final List<String> statusIds;
final List<String> createdByIds;
final List<String> 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<String, dynamic> json) => Filter(
projectIds: List<String>.from(json["projectIds"] ?? []),
statusIds: List<String>.from(json["statusIds"] ?? []),
createdByIds: List<String>.from(json["createdByIds"] ?? []),
paidById: List<String>.from(json["paidById"] ?? []),
startDate:
json["startDate"] != null ? DateTime.tryParse(json["startDate"]) : null,
endDate:
json["endDate"] != null ? DateTime.tryParse(json["endDate"]) : null,
);
Map<String, dynamic> 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<String, dynamic> 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<Status>.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<dynamic>? ?? [])
.map((x) => Status.fromJson(x))
.toList(),
preApproved: json["preApproved"] ?? false,
);
@ -163,14 +221,15 @@ class Project {
});
factory Project.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
@ -199,10 +258,10 @@ class ExpenseType {
});
factory ExpenseType.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
@ -225,9 +284,9 @@ class PaymentMode {
});
factory PaymentMode.fromJson(Map<String, dynamic> json) => PaymentMode(
id: json["id"],
name: json["name"],
description: json["description"],
id: json["id"] ?? '',
name: json["name"] ?? '',
description: json["description"] ?? '',
);
Map<String, dynamic> toJson() => {
@ -255,11 +314,11 @@ class PaidBy {
});
factory PaidBy.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() => {

View File

@ -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<ProjectController>();
final expenseController = Get.find<ExpenseController>();
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,
),
),
],

View File

@ -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<EmployeeModel> selectedPaidByEmployees;
final RxList<EmployeeModel> 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<DateTime?> startDate = Rx<DateTime?>(null);
final Rx<DateTime?> endDate = Rx<DateTime?>(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<String>(
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<EmployeeModel> 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<String> items,
required ValueChanged<String> onSelected,
}) {
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onSelected: onSelected,
itemBuilder: (context) {
return items
.map((e) => PopupMenuItem<String>(
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<EmployeeModel> 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),
],
),
),
);
}
}

View File

@ -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<ExpenseMainScreen> {
final RxBool isHistoryView = false.obs;
final TextEditingController searchController = TextEditingController();
final RxString searchQuery = ''.obs;
final ProjectController projectController = Get.find<ProjectController>();
final ExpenseController expenseController = Get.put(ExpenseController());
final RxList<EmployeeModel> selectedPaidByEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> selectedCreatedByEmployees =
<EmployeeModel>[].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<ExpenseMainScreen> {
_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<ExpenseMainScreen> {
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<ExpenseMainScreen> {
),
);
}
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<String> onChanged;
@ -217,6 +216,8 @@ class _SearchAndFilter extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ExpenseController expenseController = Get.find<ExpenseController>();
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<ExpenseModel> expenseList;
@ -371,7 +397,7 @@ class _ExpenseList extends StatelessWidget {
if (expenseList.isEmpty) {
return Center(child: MyText.bodyMedium('No expenses found.'));
}
final expenseController = Get.find<ExpenseController>();
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)}',