Refactor expense reimbursement and filter UI components

- Updated ReimbursementBottomSheet to use BaseBottomSheet for consistent styling and functionality.
- Improved input field decorations and added spacing helpers for better layout.
- Simplified the employee selection process and integrated it into the new design.
- Refactored ExpenseDetailScreen to utilize controller initialization method.
- Enhanced ExpenseFilterBottomSheet with a cleaner structure and improved field handling.
- Removed unnecessary wrapper for ExpenseFilterBottomSheet and integrated it directly into the expense screen.
This commit is contained in:
Vaibhav Surve 2025-07-31 13:13:00 +05:30
parent adf5e1437e
commit 29f759ca9d
8 changed files with 683 additions and 842 deletions

View File

@ -11,35 +11,40 @@ class ExpenseDetailController extends GetxController {
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null); final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs; final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
@override bool _isInitialized = false;
void onInit() { late String _expenseId;
super.onInit();
/// Call this once from the screen (NOT inside build) to initialize
void init(String expenseId) {
if (_isInitialized) return;
_isInitialized = true;
_expenseId = expenseId;
fetchExpenseDetails();
fetchAllEmployees(); fetchAllEmployees();
} }
/// Fetch expense details by ID /// Fetch expense details by stored ID
Future<void> fetchExpenseDetails(String expenseId) async { Future<void> fetchExpenseDetails() async {
isLoading.value = true; isLoading.value = true;
errorMessage.value = ''; errorMessage.value = '';
try { try {
logSafe("Fetching expense details for ID: $expenseId"); logSafe("Fetching expense details for ID: $_expenseId");
final result = final result = await ApiService.getExpenseDetailsApi(expenseId: _expenseId);
await ApiService.getExpenseDetailsApi(expenseId: expenseId);
if (result != null) { if (result != null) {
try { try {
expense.value = ExpenseDetailModel.fromJson(result); expense.value = ExpenseDetailModel.fromJson(result);
logSafe("Expense details loaded successfully: ${expense.value?.id}"); logSafe("Expense details loaded successfully: ${expense.value?.id}");
} catch (e) { } catch (e) {
errorMessage.value = 'Failed to parse expense details: $e'; errorMessage.value = 'Failed to parse expense details: $e';
logSafe("Parse error in fetchExpenseDetails: $e", logSafe("Parse error in fetchExpenseDetails: $e", level: LogLevel.error);
level: LogLevel.error);
} }
} else { } else {
errorMessage.value = 'Failed to fetch expense details from server.'; errorMessage.value = 'Failed to fetch expense details from server.';
logSafe("fetchExpenseDetails failed: null response", logSafe("fetchExpenseDetails failed: null response", level: LogLevel.error);
level: LogLevel.error);
} }
} catch (e, stack) { } catch (e, stack) {
errorMessage.value = 'An unexpected error occurred.'; errorMessage.value = 'An unexpected error occurred.';
@ -59,8 +64,7 @@ class ExpenseDetailController extends GetxController {
final response = await ApiService.getAllEmployees(); final response = await ApiService.getAllEmployees();
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
logSafe("All Employees fetched: ${allEmployees.length}", logSafe("All Employees fetched: ${allEmployees.length}", level: LogLevel.info);
level: LogLevel.info);
} else { } else {
allEmployees.clear(); allEmployees.clear();
logSafe("No employees found.", level: LogLevel.warning); logSafe("No employees found.", level: LogLevel.warning);
@ -76,22 +80,21 @@ class ExpenseDetailController extends GetxController {
/// Update expense with reimbursement info and status /// Update expense with reimbursement info and status
Future<bool> updateExpenseStatusWithReimbursement({ Future<bool> updateExpenseStatusWithReimbursement({
required String expenseId,
required String comment, required String comment,
required String reimburseTransactionId, required String reimburseTransactionId,
required String reimburseDate, required String reimburseDate,
required String reimburseById, required String reimburseById,
required String statusId, // dynamic required String statusId,
}) async { }) async {
isLoading.value = true; isLoading.value = true;
errorMessage.value = ''; errorMessage.value = '';
try { try {
logSafe("Submitting reimbursement for expense: $expenseId"); logSafe("Submitting reimbursement for expense: $_expenseId");
final success = await ApiService.updateExpenseStatusApi( final success = await ApiService.updateExpenseStatusApi(
expenseId: expenseId, expenseId: _expenseId,
statusId: statusId, // now dynamic statusId: statusId,
comment: comment, comment: comment,
reimburseTransactionId: reimburseTransactionId, reimburseTransactionId: reimburseTransactionId,
reimburseDate: reimburseDate, reimburseDate: reimburseDate,
@ -100,7 +103,7 @@ class ExpenseDetailController extends GetxController {
if (success) { if (success) {
logSafe("Reimbursement submitted successfully."); logSafe("Reimbursement submitted successfully.");
await fetchExpenseDetails(expenseId); await fetchExpenseDetails(); // refresh latest
return true; return true;
} else { } else {
errorMessage.value = "Failed to submit reimbursement."; errorMessage.value = "Failed to submit reimbursement.";
@ -108,8 +111,7 @@ class ExpenseDetailController extends GetxController {
} }
} catch (e, stack) { } catch (e, stack) {
errorMessage.value = 'An unexpected error occurred.'; errorMessage.value = 'An unexpected error occurred.';
logSafe("Exception in updateExpenseStatusWithReimbursement: $e", logSafe("Exception in updateExpenseStatusWithReimbursement: $e", level: LogLevel.error);
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug); logSafe("StackTrace: $stack", level: LogLevel.debug);
return false; return false;
} finally { } finally {
@ -118,21 +120,21 @@ class ExpenseDetailController extends GetxController {
} }
/// Update status for this specific expense /// Update status for this specific expense
Future<bool> updateExpenseStatus(String expenseId, String statusId) async { Future<bool> updateExpenseStatus(String statusId) async {
isLoading.value = true; isLoading.value = true;
errorMessage.value = ''; errorMessage.value = '';
try { try {
logSafe("Updating status for expense: $expenseId -> $statusId"); logSafe("Updating status for expense: $_expenseId -> $statusId");
final success = await ApiService.updateExpenseStatusApi( final success = await ApiService.updateExpenseStatusApi(
expenseId: expenseId, expenseId: _expenseId,
statusId: statusId, statusId: statusId,
); );
if (success) { if (success) {
logSafe("Expense status updated successfully."); logSafe("Expense status updated successfully.");
await fetchExpenseDetails(expenseId); await fetchExpenseDetails(); // refresh
return true; return true;
} else { } else {
errorMessage.value = "Failed to update expense status."; errorMessage.value = "Failed to update expense status.";

View File

@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class BaseBottomSheet extends StatelessWidget {
final String title;
final Widget child;
final VoidCallback onCancel;
final VoidCallback onSubmit;
final bool isSubmitting;
final String submitText;
final Color submitColor;
final IconData submitIcon;
const BaseBottomSheet({
super.key,
required this.title,
required this.child,
required this.onCancel,
required this.onSubmit,
this.isSubmitting = false,
this.submitText = 'Submit',
this.submitColor = Colors.indigo,
this.submitIcon = Icons.check_circle_outline,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
return SingleChildScrollView(
padding: mediaQuery.viewInsets,
child: Padding(
padding: const EdgeInsets.only(
top: 60),
child: Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 12,
offset: Offset(0, -2),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MySpacing.height(5),
Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
MySpacing.height(12),
MyText.titleLarge(title, fontWeight: 700),
MySpacing.height(12),
child,
MySpacing.height(24),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium(
"Cancel",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
vertical: 8),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: isSubmitting ? null : onSubmit,
icon: Icon(submitIcon, color: Colors.white),
label: MyText.bodyMedium(
isSubmitting ? "Submitting..." : submitText,
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: submitColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
vertical: 8),
),
),
),
],
),
],
),
),
),
),
);
}
}

View File

@ -3,9 +3,9 @@ class Permissions {
static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614"; static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614";
static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc"; static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc";
static const String manageEmployees = "a97d366a-c2bb-448d-be93-402bd2324566"; static const String manageEmployees = "a97d366a-c2bb-448d-be93-402bd2324566";
static const String manageProjectInfra ="f2aee20a-b754-4537-8166-f9507b44585b"; static const String manageProjectInfra = "f2aee20a-b754-4537-8166-f9507b44585b";
static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8"; static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8";
static const String regularizeAttendance ="57802c4a-00aa-4a1f-a048-fd2f70dd44b6"; static const String regularizeAttendance = "57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3"; static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3";
static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c"; static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c";
static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5"; static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5";
@ -13,4 +13,13 @@ class Permissions {
static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2"; static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2";
static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda"; static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda";
static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5"; static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5";
// Expense Permissions
static const String expenseViewSelf = "385be49f-8fde-440e-bdbc-3dffeb8dd116";
static const String expenseViewAll = "01e06444-9ca7-4df4-b900-8c3fa051b92f";
static const String expenseUpload = "0f57885d-bcb2-4711-ac95-d841ace6d5a7";
static const String expenseReview = "1f4bda08-1873-449a-bb66-3e8222bd871b";
static const String expenseApprove = "eaafdd76-8aac-45f9-a530-315589c6deca";
static const String expenseProcess = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11";
static const String expenseManage = "bdee29a2-b73b-402d-8dd1-c4b1f81ccbc3";
} }

View File

@ -1,11 +1,11 @@
import 'dart:io'; import 'dart:io';
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/controller/expense/add_expense_controller.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_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
void showAddExpenseBottomSheet() { void showAddExpenseBottomSheet() {
Get.bottomSheet(const _AddExpenseBottomSheet(), isScrollControlled: true); Get.bottomSheet(const _AddExpenseBottomSheet(), isScrollControlled: true);
@ -83,309 +83,228 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return Obx(() {
child: Padding( return BaseBottomSheet(
padding: const EdgeInsets.only(top: 60), title: "Add Expense",
child: Material( isSubmitting: controller.isSubmitting.value,
color: Colors.white, onCancel: Get.back,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), onSubmit: () {
child: Obx(() { if (!controller.isSubmitting.value) {
return SingleChildScrollView( controller.submitExpense();
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), }
child: Column( },
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
const _DragHandle(), children: [
Center( _buildDropdown<String>(
child: MyText.titleLarge("Add Expense", fontWeight: 700), icon: Icons.work_outline,
), title: "Project",
const SizedBox(height: 20), requiredField: true,
_buildSectionWithDropdown<String>( value: controller.selectedProject.value.isEmpty
icon: Icons.work_outline, ? "Select Project"
title: "Project", : controller.selectedProject.value,
requiredField: true, onTap: () => _showOptionList<String>(
currentValue: controller.selectedProject.value.isEmpty controller.globalProjects.toList(),
? "Select Project" (p) => p,
: controller.selectedProject.value, (val) => controller.selectedProject.value = val,
onTap: () => _showOptionList<String>(
controller.globalProjects.toList(),
(p) => p,
(val) => controller.selectedProject.value = val),
),
const SizedBox(height: 16),
_buildSectionWithDropdown<ExpenseTypeModel>(
icon: Icons.category_outlined,
title: "Expense Type",
requiredField: true,
currentValue: controller.selectedExpenseType.value?.name ??
"Select Expense Type",
onTap: () => _showOptionList<ExpenseTypeModel>(
controller.expenseTypes.toList(),
(e) => e.name,
(val) => controller.selectedExpenseType.value = val,
),
),
if (controller
.selectedExpenseType.value?.noOfPersonsRequired ==
true)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
icon: Icons.people_outline,
title: "No. of Persons",
requiredField: true,
),
const SizedBox(height: 6),
_CustomTextField(
controller: controller.noOfPersonsController,
hint: "Enter No. of Persons",
keyboardType: TextInputType.number,
),
],
),
),
const SizedBox(height: 16),
_SectionTitle(
icon: Icons.confirmation_number_outlined,
title: "GST No.",
),
const SizedBox(height: 6),
_CustomTextField(
controller: controller.gstController,
hint: "Enter GST No.",
),
const SizedBox(height: 16),
_buildSectionWithDropdown<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
currentValue: controller.selectedPaymentMode.value?.name ??
"Select Payment Mode",
onTap: () => _showOptionList<PaymentModeModel>(
controller.paymentModes.toList(),
(m) => m.name,
(val) => controller.selectedPaymentMode.value = val,
),
),
const SizedBox(height: 16),
_SectionTitle(
icon: Icons.person_outline,
title: "Paid By",
requiredField: true),
const SizedBox(height: 6),
GestureDetector(
onTap: _showEmployeeList,
child: _TileContainer(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.selectedPaidBy.value == null
? "Select Paid By"
: '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}',
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.arrow_drop_down, size: 22),
],
),
),
),
const SizedBox(height: 16),
_SectionTitle(
icon: Icons.currency_rupee,
title: "Amount",
requiredField: true),
const SizedBox(height: 6),
_CustomTextField(
controller: controller.amountController,
hint: "Enter Amount",
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
_SectionTitle(
icon: Icons.store_mall_directory_outlined,
title: "Supplier Name",
requiredField: true),
const SizedBox(height: 6),
_CustomTextField(
controller: controller.supplierController,
hint: "Enter Supplier Name",
),
const SizedBox(height: 16),
_SectionTitle(
icon: Icons.confirmation_number_outlined,
title: "Transaction ID"),
const SizedBox(height: 6),
_CustomTextField(
controller: controller.transactionIdController,
hint: "Enter Transaction ID",
),
const SizedBox(height: 16),
_SectionTitle(
icon: Icons.calendar_today,
title: "Transaction Date",
requiredField: true),
const SizedBox(height: 6),
GestureDetector(
onTap: () => controller.pickTransactionDate(context),
child: AbsorbPointer(
child: _CustomTextField(
controller: controller.transactionDateController,
hint: "Select Transaction Date",
),
),
),
const SizedBox(height: 16),
_SectionTitle(
icon: Icons.location_on_outlined, title: "Location"),
const SizedBox(height: 6),
TextField(
controller: controller.locationController,
decoration: InputDecoration(
hintText: "Enter Location",
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(
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),
_SectionTitle(
icon: Icons.attach_file,
title: "Attachments",
requiredField: true),
const SizedBox(height: 6),
_AttachmentsSection(
attachments: controller.attachments,
onRemove: controller.removeAttachment,
onAdd: controller.pickAttachments,
),
const SizedBox(height: 16),
_SectionTitle(
icon: Icons.description_outlined,
title: "Description",
requiredField: true),
const SizedBox(height: 6),
_CustomTextField(
controller: controller.descriptionController,
hint: "Enter Description",
maxLines: 3,
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: Get.back,
icon: const Icon(Icons.close, size: 18),
label: MyText.bodyMedium("Cancel", fontWeight: 600),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48)),
),
),
const SizedBox(width: 12),
Expanded(
child: Obx(
() {
final isLoading = controller.isSubmitting.value;
return ElevatedButton.icon(
onPressed:
isLoading ? null : controller.submitExpense,
icon: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.check, size: 18),
label: MyText.bodyMedium(
isLoading ? "Submitting..." : "Submit",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
padding:
const EdgeInsets.symmetric(vertical: 14),
minimumSize: const Size.fromHeight(48),
),
);
},
),
),
],
),
],
), ),
); ),
}), MySpacing.height(16),
_buildDropdown<ExpenseTypeModel>(
icon: Icons.category_outlined,
title: "Expense Type",
requiredField: true,
value: controller.selectedExpenseType.value?.name ??
"Select Expense Type",
onTap: () => _showOptionList<ExpenseTypeModel>(
controller.expenseTypes.toList(),
(e) => e.name,
(val) => controller.selectedExpenseType.value = val,
),
),
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
true)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(
icon: Icons.people_outline,
title: "No. of Persons",
requiredField: true,
),
MySpacing.height(6),
_CustomTextField(
controller: controller.noOfPersonsController,
hint: "Enter No. of Persons",
keyboardType: TextInputType.number,
),
],
),
),
MySpacing.height(16),
_SectionTitle(
icon: Icons.confirmation_number_outlined, title: "GST No."),
MySpacing.height(6),
_CustomTextField(
controller: controller.gstController, hint: "Enter GST No."),
MySpacing.height(16),
_buildDropdown<PaymentModeModel>(
icon: Icons.payment,
title: "Payment Mode",
requiredField: true,
value: controller.selectedPaymentMode.value?.name ??
"Select Payment Mode",
onTap: () => _showOptionList<PaymentModeModel>(
controller.paymentModes.toList(),
(p) => p.name,
(val) => controller.selectedPaymentMode.value = val,
),
),
MySpacing.height(16),
_SectionTitle(
icon: Icons.person_outline,
title: "Paid By",
requiredField: true),
MySpacing.height(6),
GestureDetector(
onTap: _showEmployeeList,
child: _TileContainer(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.selectedPaidBy.value == null
? "Select Paid By"
: '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}',
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.arrow_drop_down, size: 22),
],
),
),
),
MySpacing.height(16),
_SectionTitle(
icon: Icons.currency_rupee,
title: "Amount",
requiredField: true),
MySpacing.height(6),
_CustomTextField(
controller: controller.amountController,
hint: "Enter Amount",
keyboardType: TextInputType.number,
),
MySpacing.height(16),
_SectionTitle(
icon: Icons.store_mall_directory_outlined,
title: "Supplier Name",
requiredField: true,
),
MySpacing.height(6),
_CustomTextField(
controller: controller.supplierController,
hint: "Enter Supplier Name"),
MySpacing.height(16),
_SectionTitle(
icon: Icons.confirmation_number_outlined,
title: "Transaction ID"),
MySpacing.height(6),
_CustomTextField(
controller: controller.transactionIdController,
hint: "Enter Transaction ID"),
MySpacing.height(16),
_SectionTitle(
icon: Icons.calendar_today,
title: "Transaction Date",
requiredField: true,
),
MySpacing.height(6),
GestureDetector(
onTap: () => controller.pickTransactionDate(context),
child: AbsorbPointer(
child: _CustomTextField(
controller: controller.transactionDateController,
hint: "Select Transaction Date",
),
),
),
MySpacing.height(16),
_SectionTitle(icon: Icons.location_on_outlined, title: "Location"),
MySpacing.height(6),
TextField(
controller: controller.locationController,
decoration: InputDecoration(
hintText: "Enter Location",
filled: true,
fillColor: Colors.grey.shade100,
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding:
const EdgeInsets.symmetric(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,
),
),
),
MySpacing.height(16),
_SectionTitle(
icon: Icons.attach_file,
title: "Attachments",
requiredField: true),
MySpacing.height(6),
_AttachmentsSection(
attachments: controller.attachments,
onRemove: controller.removeAttachment,
onAdd: controller.pickAttachments,
),
MySpacing.height(16),
_SectionTitle(
icon: Icons.description_outlined,
title: "Description",
requiredField: true),
MySpacing.height(6),
_CustomTextField(
controller: controller.descriptionController,
hint: "Enter Description",
maxLines: 3,
),
],
), ),
), );
); });
} }
Widget _buildSectionWithDropdown<T>({ Widget _buildDropdown<T>({
required IconData icon, required IconData icon,
required String title, required String title,
required bool requiredField, required bool requiredField,
required String currentValue, required String value,
required VoidCallback onTap, required VoidCallback onTap,
Widget? extraWidget,
}) { }) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_SectionTitle(icon: icon, title: title, requiredField: requiredField), _SectionTitle(icon: icon, title: title, requiredField: requiredField),
const SizedBox(height: 6), MySpacing.height(6),
_DropdownTile(title: currentValue, onTap: onTap), _DropdownTile(title: value, onTap: onTap),
if (extraWidget != null) extraWidget,
], ],
); );
} }
} }
class _DragHandle extends StatelessWidget {
const _DragHandle();
@override
Widget build(BuildContext context) {
return 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 { class _SectionTitle extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
@ -447,11 +366,23 @@ class _CustomTextField extends StatelessWidget {
keyboardType: keyboardType, keyboardType: keyboardType,
decoration: InputDecoration( decoration: InputDecoration(
hintText: hint, hintText: hint,
hintStyle: TextStyle(fontSize: 14, color: Colors.grey[600]),
filled: true, filled: true,
fillColor: Colors.grey.shade100, fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10), const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
),
), ),
); );
} }

View File

@ -1,8 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/controller/expense/expense_detail_controller.dart'; import 'package:marco/controller/expense/expense_detail_controller.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/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class ReimbursementBottomSheet extends StatefulWidget { class ReimbursementBottomSheet extends StatefulWidget {
final String expenseId; final String expenseId;
@ -30,8 +34,7 @@ class ReimbursementBottomSheet extends StatefulWidget {
} }
class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> { class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
final ExpenseDetailController controller = final ExpenseDetailController controller = Get.find<ExpenseDetailController>();
Get.find<ExpenseDetailController>();
final TextEditingController commentCtrl = TextEditingController(); final TextEditingController commentCtrl = TextEditingController();
final TextEditingController txnCtrl = TextEditingController(); final TextEditingController txnCtrl = TextEditingController();
@ -49,14 +52,16 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
context: context, context: context,
backgroundColor: Colors.white, backgroundColor: Colors.white,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) { builder: (_) {
return SizedBox( return SizedBox(
height: 300, height: 300,
child: Obx(() { child: Obx(() {
final employees = controller.allEmployees; final employees = controller.allEmployees;
if (employees.isEmpty) if (employees.isEmpty) {
return const Center(child: Text("No employees found")); return const Center(child: Text("No employees found"));
}
return ListView.builder( return ListView.builder(
itemCount: employees.length, itemCount: employees.length,
itemBuilder: (_, index) { itemBuilder: (_, index) {
@ -77,221 +82,128 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
); );
} }
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: MySpacing.all(16),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return Obx(() {
child: Container( return BaseBottomSheet(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), title: "Reimbursement Info",
decoration: const BoxDecoration( isSubmitting: controller.isLoading.value,
color: Colors.white, onCancel: () {
borderRadius: BorderRadius.vertical(top: Radius.circular(24)), widget.onClose();
), Navigator.pop(context);
},
onSubmit: () async {
if (commentCtrl.text.trim().isEmpty ||
txnCtrl.text.trim().isEmpty ||
dateStr.value.isEmpty ||
controller.selectedReimbursedBy.value == null) {
Get.snackbar("Incomplete", "Please fill all fields");
return;
}
final success = await widget.onSubmit(
comment: commentCtrl.text.trim(),
reimburseTransactionId: txnCtrl.text.trim(),
reimburseDate: dateStr.value,
reimburseById: controller.selectedReimbursedBy.value!.id,
statusId: widget.statusId,
);
if (success) {
Get.back();
Get.snackbar('Success', 'Reimbursement submitted');
} else {
Get.snackbar('Error', controller.errorMessage.value);
}
},
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Drag handle MyText.labelMedium("Comment"),
Container( MySpacing.height(8),
width: 50, TextField(
height: 4, controller: commentCtrl,
margin: const EdgeInsets.only(bottom: 16), decoration: _inputDecoration("Enter comment"),
decoration: BoxDecoration( ),
color: Colors.grey.shade300, MySpacing.height(16),
borderRadius: BorderRadius.circular(20),
MyText.labelMedium("Transaction ID"),
MySpacing.height(8),
TextField(
controller: txnCtrl,
decoration: _inputDecoration("Enter transaction ID"),
),
MySpacing.height(16),
MyText.labelMedium("Reimbursement Date"),
MySpacing.height(8),
GestureDetector(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: dateStr.value.isEmpty
? DateTime.now()
: DateFormat('yyyy-MM-dd').parse(dateStr.value),
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) {
dateStr.value = DateFormat('yyyy-MM-dd').format(picked);
}
},
child: AbsorbPointer(
child: TextField(
controller: TextEditingController(text: dateStr.value),
decoration: _inputDecoration("Select Date").copyWith(
suffixIcon: const Icon(Icons.calendar_today),
),
),
), ),
), ),
MySpacing.height(16),
// Title MyText.labelMedium("Reimbursed By"),
Row( MySpacing.height(8),
mainAxisAlignment: MainAxisAlignment.spaceBetween, GestureDetector(
children: [ onTap: _showEmployeeList,
MyText.titleLarge('Reimbursement Info', fontWeight: 700), child: AbsorbPointer(
const SizedBox(), child: TextField(
], controller: TextEditingController(
), text: controller.selectedReimbursedBy.value == null
const SizedBox(height: 20), ? ""
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
Flexible( ),
child: SingleChildScrollView( decoration: _inputDecoration("Select Employee").copyWith(
child: Column( suffixIcon: const Icon(Icons.expand_more),
children: [ ),
_buildInputField(label: 'Comment', controller: commentCtrl),
const SizedBox(height: 16),
_buildInputField(
label: 'Transaction ID', controller: txnCtrl),
const SizedBox(height: 16),
_buildDatePickerField(),
const SizedBox(height: 16),
_buildEmployeePickerField(),
const SizedBox(height: 24),
_buildActionButtons(),
],
), ),
), ),
), ),
], ],
), ),
),
);
}
Widget _buildInputField(
{required String label, required TextEditingController controller}) {
return TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
);
}
Widget _buildDatePickerField() {
return Obx(() {
return InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: dateStr.value.isEmpty
? DateTime.now()
: DateFormat('yyyy-MM-dd').parse(dateStr.value),
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) {
dateStr.value = DateFormat('yyyy-MM-dd').format(picked);
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Reimbursement Date',
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
child: Row(
children: [
Icon(Icons.date_range, color: Colors.grey.shade600),
const SizedBox(width: 10),
Text(
dateStr.value.isEmpty ? "Select Date" : dateStr.value,
style: TextStyle(
fontSize: 14,
color: dateStr.value.isEmpty ? Colors.grey : Colors.black,
),
),
],
),
),
); );
}); });
} }
Widget _buildEmployeePickerField() {
return Obx(() {
return GestureDetector(
onTap: _showEmployeeList,
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Reimbursed By',
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
child: Text(
controller.selectedReimbursedBy.value == null
? "Select Reimbursed By"
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
style: TextStyle(
fontSize: 14,
color: controller.selectedReimbursedBy.value == null
? Colors.grey
: Colors.black,
),
),
),
);
});
}
Widget _buildActionButtons() {
return Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () {
widget.onClose();
Get.back();
},
icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium("Cancel",
color: Colors.white, fontWeight: 600),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 7),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Obx(() {
return ElevatedButton.icon(
onPressed: controller.isLoading.value
? null
: () async {
if (commentCtrl.text.trim().isEmpty ||
txnCtrl.text.trim().isEmpty ||
dateStr.value.isEmpty ||
controller.selectedReimbursedBy.value == null) {
Get.snackbar("Incomplete", "Please fill all fields");
return;
}
final success = await widget.onSubmit(
comment: commentCtrl.text.trim(),
reimburseTransactionId: txnCtrl.text.trim(),
reimburseDate: dateStr.value,
reimburseById:
controller.selectedReimbursedBy.value!.id,
statusId: widget.statusId,
);
if (success) {
Get.back();
Get.snackbar('Success', 'Reimbursement submitted');
} else {
Get.snackbar('Error', controller.errorMessage.value);
}
},
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
label: MyText.bodyMedium(
controller.isLoading.value ? "Submitting..." : "Submit",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 7),
),
);
}),
),
],
);
}
} }

View File

@ -39,7 +39,7 @@ class ExpenseDetailScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final controller = Get.put(ExpenseDetailController()); final controller = Get.put(ExpenseDetailController());
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
controller.fetchExpenseDetails(expenseId); controller.init(expenseId);
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF7F7F7), backgroundColor: const Color(0xFFF7F7F7),
@ -90,27 +90,15 @@ class ExpenseDetailScreen extends StatelessWidget {
), ),
body: SafeArea( body: SafeArea(
child: Obx(() { child: Obx(() {
// Show error snackbar only once after frame render
if (controller.errorMessage.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Get.snackbar(
"Error",
controller.errorMessage.value,
backgroundColor: Colors.red.withOpacity(0.9),
colorText: Colors.white,
);
controller.errorMessage.value = '';
});
}
if (controller.isLoading.value) { if (controller.isLoading.value) {
return _buildLoadingSkeleton(); return _buildLoadingSkeleton();
} }
final expense = controller.expense.value; final expense = controller.expense.value;
if (expense == null) {
if (controller.errorMessage.isNotEmpty || expense == null) {
return Center( return Center(
child: MyText.bodyMedium("No expense details found."), child: MyText.bodyMedium("No data to display."),
); );
} }
@ -221,7 +209,6 @@ class ExpenseDetailScreen extends StatelessWidget {
}) async { }) async {
final success = await controller final success = await controller
.updateExpenseStatusWithReimbursement( .updateExpenseStatusWithReimbursement(
expenseId: expense.id,
comment: comment, comment: comment,
reimburseTransactionId: reimburseTransactionId, reimburseTransactionId: reimburseTransactionId,
reimburseDate: reimburseDate, reimburseDate: reimburseDate,
@ -236,7 +223,8 @@ class ExpenseDetailScreen extends StatelessWidget {
backgroundColor: Colors.green.withOpacity(0.8), backgroundColor: Colors.green.withOpacity(0.8),
colorText: Colors.white, colorText: Colors.white,
); );
await controller.fetchExpenseDetails(expenseId); await controller.fetchExpenseDetails();
return true; return true;
} else { } else {
Get.snackbar( Get.snackbar(
@ -251,8 +239,9 @@ class ExpenseDetailScreen extends StatelessWidget {
), ),
); );
} else { } else {
final success = await controller.updateExpenseStatus( final success =
expense.id, next.id); await controller.updateExpenseStatus(next.id);
if (success) { if (success) {
Get.snackbar( Get.snackbar(
'Success', 'Success',
@ -260,7 +249,7 @@ class ExpenseDetailScreen extends StatelessWidget {
backgroundColor: Colors.green.withOpacity(0.8), backgroundColor: Colors.green.withOpacity(0.8),
colorText: Colors.white, colorText: Colors.white,
); );
await controller.fetchExpenseDetails(expenseId); await controller.fetchExpenseDetails();
} else { } else {
Get.snackbar( Get.snackbar(
'Error', 'Error',

View File

@ -1,47 +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/expense/expense_screen_controller.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/model/employee_model.dart'; import 'package:marco/model/employee_model.dart';
/// Wrapper to open Expense Filter Bottom Sheet
void openExpenseFilterBottomSheet(
BuildContext context, ExpenseController expenseController) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return ExpenseFilterBottomSheetWrapper(
expenseController: expenseController);
},
);
}
class ExpenseFilterBottomSheetWrapper extends StatelessWidget {
final ExpenseController expenseController;
const ExpenseFilterBottomSheetWrapper(
{super.key, required this.expenseController});
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return ExpenseFilterBottomSheet(
expenseController: expenseController,
scrollController: scrollController,
);
},
);
}
}
class ExpenseFilterBottomSheet extends StatelessWidget { class ExpenseFilterBottomSheet extends StatelessWidget {
final ExpenseController expenseController; final ExpenseController expenseController;
final ScrollController scrollController; final ScrollController scrollController;
@ -52,26 +18,143 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
required this.scrollController, required this.scrollController,
}); });
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: MySpacing.all(12),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
return SafeArea( return BaseBottomSheet(
child: Container( title: 'Filter Expenses',
decoration: const BoxDecoration( onCancel: () => Get.back(),
color: Colors.white, onSubmit: () {
borderRadius: BorderRadius.vertical(top: Radius.circular(24)), expenseController.fetchExpenses();
), Get.back();
},
submitText: 'Submit',
submitColor: Colors.indigo,
submitIcon: Icons.check_circle_outline,
child: SingleChildScrollView(
controller: scrollController,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Align(
child: SingleChildScrollView( alignment: Alignment.centerRight,
controller: scrollController, child: TextButton(
padding: onPressed: () => expenseController.clearFilters(),
const EdgeInsets.symmetric(horizontal: 16, vertical: 20), child: const Text(
child: _buildContent(context), "Reset Filter",
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.w600,
),
),
), ),
), ),
_buildBottomButtons(), MySpacing.height(8),
_buildField("Project", _popupSelector(
context,
currentValue: expenseController.selectedProject.value.isEmpty
? 'Select Project'
: expenseController.selectedProject.value,
items: expenseController.globalProjects,
onSelected: (value) =>
expenseController.selectedProject.value = value,
)),
MySpacing.height(16),
_buildField("Expense Status", _popupSelector(
context,
currentValue: expenseController.selectedStatus.value.isEmpty
? 'Select Expense Status'
: expenseController.expenseStatuses
.firstWhereOrNull((e) =>
e.id == expenseController.selectedStatus.value)
?.name ??
'Select Expense Status',
items: expenseController.expenseStatuses
.map((e) => e.name)
.toList(),
onSelected: (name) {
final status = expenseController.expenseStatuses
.firstWhere((e) => e.name == name);
expenseController.selectedStatus.value = status.id;
},
)),
MySpacing.height(16),
_buildField("Date Range", Row(
children: [
Expanded(child: _dateButton(
label: expenseController.startDate.value == null
? 'Start Date'
: DateTimeUtils.formatDate(
expenseController.startDate.value!, 'dd MMM yyyy'),
onTap: () async {
DateTime? picked = await showDatePicker(
context: context,
initialDate:
expenseController.startDate.value ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) {
expenseController.startDate.value = picked;
}
},
)),
MySpacing.width(8),
Expanded(child: _dateButton(
label: expenseController.endDate.value == null
? 'End Date'
: DateTimeUtils.formatDate(
expenseController.endDate.value!, 'dd MMM yyyy'),
onTap: () async {
DateTime? picked = await showDatePicker(
context: context,
initialDate:
expenseController.endDate.value ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) {
expenseController.endDate.value = picked;
}
},
)),
],
)),
MySpacing.height(16),
_buildField("Paid By", _employeeSelector(
selectedEmployees: expenseController.selectedPaidByEmployees,
)),
MySpacing.height(16),
_buildField("Created By", _employeeSelector(
selectedEmployees: expenseController.selectedCreatedByEmployees,
)),
], ],
), ),
), ),
@ -79,208 +162,17 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
}); });
} }
/// Builds the filter content Widget _buildField(String label, Widget child) {
Widget _buildContent(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Center( MyText.labelMedium(label),
child: Container( MySpacing.height(8),
width: 50, child,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(20),
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.titleLarge('Filter Expenses', fontWeight: 700),
TextButton(
onPressed: () => expenseController.clearFilters(),
child: const Text(
"Reset Filter",
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 16),
/// Project Filter
_buildCardSection(
title: "Project",
child: _popupSelector(
context,
currentValue: expenseController.selectedProject.value.isEmpty
? 'Select Project'
: expenseController.selectedProject.value,
items: expenseController.globalProjects,
onSelected: (value) =>
expenseController.selectedProject.value = value,
),
),
const SizedBox(height: 16),
/// Expense Status Filter
_buildCardSection(
title: "Expense Status",
child: _popupSelector(
context,
currentValue: expenseController.selectedStatus.value.isEmpty
? 'Select Expense Status'
: expenseController.expenseStatuses
.firstWhereOrNull((e) =>
e.id == expenseController.selectedStatus.value)
?.name ??
'Select Expense Status',
items:
expenseController.expenseStatuses.map((e) => e.name).toList(),
onSelected: (name) {
final status = expenseController.expenseStatuses
.firstWhere((e) => e.name == name);
expenseController.selectedStatus.value = status.id;
},
),
),
const SizedBox(height: 16),
/// Date Range Filter
_buildCardSection(
title: "Date Range",
child: Row(
children: [
Expanded(
child: _dateButton(
label: expenseController.startDate.value == null
? 'Start Date'
: DateTimeUtils.formatDate(
expenseController.startDate.value!, 'dd MMM yyyy'),
onTap: () async {
DateTime? picked = await showDatePicker(
context: context,
initialDate:
expenseController.startDate.value ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null)
expenseController.startDate.value = picked;
},
),
),
const SizedBox(width: 8),
Expanded(
child: _dateButton(
label: expenseController.endDate.value == null
? 'End Date'
: DateTimeUtils.formatDate(
expenseController.endDate.value!, 'dd MMM yyyy'),
onTap: () async {
DateTime? picked = await showDatePicker(
context: context,
initialDate:
expenseController.endDate.value ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null)
expenseController.endDate.value = picked;
},
),
),
],
),
),
const SizedBox(height: 16),
/// Paid By Filter
_buildCardSection(
title: "Paid By",
child: _employeeFilterSection(
selectedEmployees: expenseController.selectedPaidByEmployees,
),
),
const SizedBox(height: 16),
/// Created By Filter
_buildCardSection(
title: "Created By",
child: _employeeFilterSection(
selectedEmployees: expenseController.selectedCreatedByEmployees,
),
),
const SizedBox(height: 24),
], ],
); );
} }
/// Bottom Action Buttons
Widget _buildBottomButtons() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
// Cancel Button
Expanded(
child: ElevatedButton.icon(
onPressed: () {
Get.back();
},
icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium(
"Cancel",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
),
),
),
const SizedBox(width: 12),
// Submit Button
Expanded(
child: ElevatedButton.icon(
onPressed: () {
expenseController.fetchExpenses();
Get.back();
},
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
label: MyText.bodyMedium(
"Submit",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
),
),
),
],
),
);
}
/// Popup Selector
Widget _popupSelector( Widget _popupSelector(
BuildContext context, { BuildContext context, {
required String currentValue, required String currentValue,
@ -288,21 +180,20 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
required ValueChanged<String> onSelected, required ValueChanged<String> onSelected,
}) { }) {
return PopupMenuButton<String>( return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: onSelected, onSelected: onSelected,
itemBuilder: (context) { itemBuilder: (context) => items
return items .map((e) => PopupMenuItem<String>(
.map((e) => PopupMenuItem<String>( value: e,
value: e, child: Text(e),
child: Text(e), ))
)) .toList(),
.toList();
},
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: MySpacing.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -321,55 +212,52 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
); );
} }
/// Card Section Wrapper
Widget _buildCardSection({required String title, required Widget child}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(title, fontWeight: 600),
const SizedBox(height: 6),
child,
],
);
}
/// Date Button
Widget _dateButton({required String label, required VoidCallback onTap}) { Widget _dateButton({required String label, required VoidCallback onTap}) {
return ElevatedButton.icon( return GestureDetector(
onPressed: onTap, onTap: onTap,
icon: const Icon(Icons.calendar_today, size: 16), child: Container(
style: ElevatedButton.styleFrom( padding: MySpacing.xy(16, 12),
backgroundColor: Colors.grey.shade100, decoration: BoxDecoration(
foregroundColor: Colors.black, color: Colors.grey.shade100,
elevation: 0, borderRadius: BorderRadius.circular(12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), border: Border.all(color: Colors.grey.shade300),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), ),
child: Row(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
MySpacing.width(8),
Expanded(
child: Text(label,
style: MyTextStyle.bodyMedium(),
overflow: TextOverflow.ellipsis),
),
],
),
), ),
label: Text(label, overflow: TextOverflow.ellipsis),
); );
} }
/// Employee Filter Section Widget _employeeSelector({
Widget _employeeFilterSection( required RxList<EmployeeModel> selectedEmployees,
{required RxList<EmployeeModel> selectedEmployees}) { }) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Obx(() { Obx(() {
return Wrap( return Wrap(
spacing: 6, spacing: 8,
runSpacing: -8, runSpacing: -8,
children: selectedEmployees.map((emp) { children: selectedEmployees.map((emp) {
return Chip( return Chip(
label: Text(emp.name), label: Text(emp.name),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () => selectedEmployees.remove(emp), onDeleted: () => selectedEmployees.remove(emp),
deleteIcon: const Icon(Icons.close, size: 18),
backgroundColor: Colors.grey.shade200, backgroundColor: Colors.grey.shade200,
); );
}).toList(), }).toList(),
); );
}), }),
const SizedBox(height: 6), MySpacing.height(8),
Autocomplete<EmployeeModel>( Autocomplete<EmployeeModel>(
optionsBuilder: (TextEditingValue textEditingValue) { optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) { if (textEditingValue.text.isEmpty) {
@ -387,19 +275,11 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
selectedEmployees.add(emp); selectedEmployees.add(emp);
} }
}, },
fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { fieldViewBuilder: (context, controller, focusNode, _) {
return TextField( return TextField(
controller: controller, controller: controller,
focusNode: focusNode, focusNode: focusNode,
decoration: InputDecoration( decoration: _inputDecoration("Search Employee"),
hintText: 'Search Employee',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
); );
}, },
optionsViewBuilder: (context, onSelected, options) { optionsViewBuilder: (context, onSelected, options) {
@ -411,7 +291,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
child: SizedBox( child: SizedBox(
height: 200, height: 200,
child: ListView.builder( child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: options.length, itemCount: options.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final emp = options.elementAt(index); final emp = options.elementAt(index);

View File

@ -42,8 +42,9 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (context) { builder: (context) {
return ExpenseFilterBottomSheetWrapper( return ExpenseFilterBottomSheet(
expenseController: expenseController, expenseController: expenseController,
scrollController: ScrollController(),
); );
}, },
); );