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:
parent
9124b815ef
commit
e5b3616245
70
lib/controller/expense/expense_detail_controller.dart
Normal file
70
lib/controller/expense/expense_detail_controller.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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() => {
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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)}',
|
||||
|
Loading…
x
Reference in New Issue
Block a user