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