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:
Vaibhav Surve 2025-07-19 20:15:54 +05:30
parent b40d371d43
commit af83d66390
13 changed files with 1918 additions and 720 deletions

View File

@ -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"/>

View 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();
}
}

View 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;
}
}
}

View File

@ -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";
} }

View File

@ -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(

View File

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

View 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,
};
}

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

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

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

View File

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

View File

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

View File

@ -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