Vaibhav_Feature-#768 #59

Closed
vaibhav.surve wants to merge 74 commits from Vaibhav_Feature-#768 into Feature_Expense
10 changed files with 562 additions and 283 deletions
Showing only changes of commit aa76ec60cb - Show all commits

View File

@ -8,13 +8,13 @@ import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:marco/model/employee_model.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employee_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/model/expense/payment_types_model.dart';
@ -50,6 +50,9 @@ class AddExpenseController extends GetxController {
final paymentModes = <PaymentModeModel>[].obs; final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs; final allEmployees = <EmployeeModel>[].obs;
final existingAttachments = <Map<String, dynamic>>[].obs; final existingAttachments = <Map<String, dynamic>>[].obs;
final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs;
final employeeSearchResults = <EmployeeModel>[].obs;
// Editing // Editing
String? editingExpenseId; String? editingExpenseId;
@ -61,7 +64,10 @@ class AddExpenseController extends GetxController {
super.onInit(); super.onInit();
fetchMasterData(); fetchMasterData();
fetchGlobalProjects(); fetchGlobalProjects();
fetchAllEmployees();
employeeSearchController.addListener(() {
searchEmployees(employeeSearchController.text);
});
} }
@override @override
@ -77,12 +83,44 @@ class AddExpenseController extends GetxController {
super.onClose(); super.onClose();
} }
Future<void> searchEmployees(String searchQuery) async {
if (searchQuery.trim().isEmpty) {
employeeSearchResults.clear();
return;
}
isSearchingEmployees.value = true;
try {
final results = await ApiService.searchEmployeesBasic(
searchString: searchQuery.trim(),
);
if (results != null) {
employeeSearchResults.assignAll(
results.map((e) => EmployeeModel.fromJson(e)),
);
} else {
employeeSearchResults.clear();
}
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
// ---------- Form Population for Edit ---------- // ---------- Form Population for Edit ----------
void populateFieldsForEdit(Map<String, dynamic> data) { Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
isEditMode.value = true; isEditMode.value = true;
editingExpenseId = data['id']; editingExpenseId = data['id'];
// Basic fields // --- Fetch all Paid By variables up front ---
final paidById = (data['paidById'] ?? '').toString();
final paidByFirstName = (data['paidByFirstName'] ?? '').toString().trim();
final paidByLastName = (data['paidByLastName'] ?? '').toString().trim();
// --- Standard Fields ---
selectedProject.value = data['projectName'] ?? ''; selectedProject.value = data['projectName'] ?? '';
amountController.text = data['amount']?.toString() ?? ''; amountController.text = data['amount']?.toString() ?? '';
supplierController.text = data['supplerName'] ?? ''; supplierController.text = data['supplerName'] ?? '';
@ -90,7 +128,7 @@ class AddExpenseController extends GetxController {
transactionIdController.text = data['transactionId'] ?? ''; transactionIdController.text = data['transactionId'] ?? '';
locationController.text = data['location'] ?? ''; locationController.text = data['location'] ?? '';
// Transaction Date // --- Transaction Date ---
if (data['transactionDate'] != null) { if (data['transactionDate'] != null) {
try { try {
final parsedDate = DateTime.parse(data['transactionDate']); final parsedDate = DateTime.parse(data['transactionDate']);
@ -107,31 +145,29 @@ class AddExpenseController extends GetxController {
transactionDateController.clear(); transactionDateController.clear();
} }
// No of Persons // --- No of Persons ---
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString(); noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
// Select Expense Type and Payment Mode by matching IDs // --- Dropdown selections ---
selectedExpenseType.value = selectedExpenseType.value =
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']); expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value = selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']); paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
// Select Paid By employee matching id (case insensitive, trimmed) // --- Paid By select ---
final paidById = data['paidById']?.toString().trim().toLowerCase() ?? ''; // 1. By ID
selectedPaidBy.value = allEmployees // --- Paid By select ---
.firstWhereOrNull((e) => e.id.trim().toLowerCase() == paidById); selectedPaidBy.value =
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
if (selectedPaidBy.value == null && paidById.isNotEmpty) { if (selectedPaidBy.value == null) {
logSafe('⚠️ Could not match paidById: "$paidById"', final fullName = '$paidByFirstName $paidByLastName';
level: LogLevel.warning); await searchEmployees(fullName);
for (var emp in allEmployees) { selectedPaidBy.value = employeeSearchResults
logSafe( .firstWhereOrNull((e) => e.id.trim() == paidById.trim());
'Employee ID: "${emp.id.trim().toLowerCase()}", Name: "${emp.name}"',
level: LogLevel.warning);
}
} }
// Populate existing attachments if present // --- Existing Attachments ---
existingAttachments.clear(); existingAttachments.clear();
if (data['attachments'] != null && data['attachments'] is List) { if (data['attachments'] != null && data['attachments'] is List) {
existingAttachments existingAttachments
@ -184,7 +220,6 @@ class AddExpenseController extends GetxController {
await Future.wait([ await Future.wait([
fetchMasterData(), fetchMasterData(),
fetchGlobalProjects(), fetchGlobalProjects(),
fetchAllEmployees(),
]); ]);
} }
@ -451,18 +486,4 @@ class AddExpenseController extends GetxController {
logSafe("Error fetching projects: $e", level: LogLevel.error); logSafe("Error fetching projects: $e", level: LogLevel.error);
} }
} }
Future<void> fetchAllEmployees() async {
isLoading.value = true;
try {
final response = await ApiService.getAllEmployees();
if (response != null) {
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
}
} catch (e) {
logSafe("Error fetching employees: $e", level: LogLevel.error);
} finally {
isLoading.value = false;
}
}
} }

View File

@ -8,7 +8,7 @@ import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/expense_status_model.dart'; import 'package:marco/model/expense/expense_status_model.dart';
import 'package:marco/model/employee_model.dart'; import 'package:marco/model/employee_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:flutter/material.dart';
class ExpenseController extends GetxController { class ExpenseController extends GetxController {
final RxList<ExpenseModel> expenses = <ExpenseModel>[].obs; final RxList<ExpenseModel> expenses = <ExpenseModel>[].obs;
@ -32,6 +32,10 @@ class ExpenseController extends GetxController {
final RxList<EmployeeModel> selectedCreatedByEmployees = final RxList<EmployeeModel> selectedCreatedByEmployees =
<EmployeeModel>[].obs; <EmployeeModel>[].obs;
final RxString selectedDateType = 'Transaction Date'.obs; final RxString selectedDateType = 'Transaction Date'.obs;
final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs;
final employeeSearchResults = <EmployeeModel>[].obs;
final List<String> dateTypes = [ final List<String> dateTypes = [
'Transaction Date', 'Transaction Date',
@ -46,6 +50,9 @@ class ExpenseController extends GetxController {
super.onInit(); super.onInit();
loadInitialMasterData(); loadInitialMasterData();
fetchAllEmployees(); fetchAllEmployees();
employeeSearchController.addListener(() {
searchEmployees(employeeSearchController.text);
});
} }
bool get isFilterApplied { bool get isFilterApplied {
@ -94,6 +101,33 @@ class ExpenseController extends GetxController {
} }
} }
Future<void> searchEmployees(String searchQuery) async {
if (searchQuery.trim().isEmpty) {
employeeSearchResults.clear();
return;
}
isSearchingEmployees.value = true;
try {
final results = await ApiService.searchEmployeesBasic(
searchString: searchQuery.trim(),
);
if (results != null) {
employeeSearchResults.assignAll(
results.map((e) => EmployeeModel.fromJson(e)),
);
} else {
employeeSearchResults.clear();
}
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
/// Fetch expenses using filters /// Fetch expenses using filters
Future<void> fetchExpenses({ Future<void> fetchExpenses({
List<String>? projectIds, List<String>? projectIds,
@ -173,32 +207,33 @@ class ExpenseController extends GetxController {
/// Fetch master data: expense types, payment modes, and expense status /// Fetch master data: expense types, payment modes, and expense status
Future<void> fetchMasterData() async { Future<void> fetchMasterData() async {
try { try {
final expenseTypesData = await ApiService.getMasterExpenseTypes(); final expenseTypesData = await ApiService.getMasterExpenseTypes();
if (expenseTypesData is List) { if (expenseTypesData is List) {
expenseTypes.value = expenseTypes.value =
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
} }
final paymentModesData = await ApiService.getMasterPaymentModes(); final paymentModesData = await ApiService.getMasterPaymentModes();
if (paymentModesData is List) { if (paymentModesData is List) {
paymentModes.value = paymentModes.value =
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
} }
final expenseStatusData = await ApiService.getMasterExpenseStatus(); final expenseStatusData = await ApiService.getMasterExpenseStatus();
if (expenseStatusData is List) { if (expenseStatusData is List) {
expenseStatuses.value = expenseStatuses.value = expenseStatusData
expenseStatusData.map((e) => ExpenseStatusModel.fromJson(e)).toList(); .map((e) => ExpenseStatusModel.fromJson(e))
.toList();
}
} catch (e) {
showAppSnackbar(
title: "Error",
message: "Failed to fetch master data: $e",
type: SnackbarType.error,
);
} }
} catch (e) {
showAppSnackbar(
title: "Error",
message: "Failed to fetch master data: $e",
type: SnackbarType.error,
);
} }
}
/// Fetch global projects /// Fetch global projects
Future<void> fetchGlobalProjects() async { Future<void> fetchGlobalProjects() async {

View File

@ -17,6 +17,7 @@ class ApiEndpoints {
// Employee Screen API Endpoints // Employee Screen API Endpoints
static const String getAllEmployeesByProject = "/employee/list"; static const String getAllEmployeesByProject = "/employee/list";
static const String getAllEmployees = "/employee/list"; static const String getAllEmployees = "/employee/list";
static const String getEmployeesWithoutPermission = "/employee/basic";
static const String getRoles = "/roles/jobrole"; static const String getRoles = "/roles/jobrole";
static const String createEmployee = "/employee/manage-mobile"; static const String createEmployee = "/employee/manage-mobile";
static const String getEmployeeInfo = "/employee/profile/get"; static const String getEmployeeInfo = "/employee/profile/get";

View File

@ -1151,6 +1151,31 @@ class ApiService {
} }
// === Employee APIs === // === Employee APIs ===
/// Search employees by first name and last name only (not middle name)
/// Returns a list of up to 10 employee records matching the search string.
static Future<List<dynamic>?> searchEmployeesBasic({
String? searchString,
}) async {
// Remove ArgumentError check because searchString is optional now
final queryParams = <String, String>{};
// Add searchString to query parameters only if it's not null or empty
if (searchString != null && searchString.isNotEmpty) {
queryParams['searchString'] = searchString;
}
final response = await _getRequest(
ApiEndpoints.getEmployeesWithoutPermission,
queryParams: queryParams,
);
if (response != null) {
return _parseResponse(response, label: 'Search Employees Basic');
}
return null;
}
static Future<List<dynamic>?> getAllEmployeesByProject( static Future<List<dynamic>?> getAllEmployeesByProject(
String projectId) async { String projectId) async {

View File

@ -0,0 +1,30 @@
class EmployeeModelWithIdName {
final String id;
final String firstName;
final String lastName;
final String name;
EmployeeModelWithIdName({
required this.id,
required this.firstName,
required this.lastName,
required this.name,
});
factory EmployeeModelWithIdName.fromJson(Map<String, dynamic> json) {
return EmployeeModelWithIdName(
id: json['id']?.toString() ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
name: '${json['firstName'] ?? ''} ${json['lastName'] ?? ''}'.trim(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'firstName': name.split(' ').first,
'lastName': name.split(' ').length > 1 ? name.split(' ').last : '',
};
}
}

View File

@ -4,6 +4,7 @@ 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/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/model/expense/payment_types_model.dart';
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -39,34 +40,23 @@ class _AddExpenseBottomSheet extends StatefulWidget {
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
final AddExpenseController controller = Get.put(AddExpenseController()); final AddExpenseController controller = Get.put(AddExpenseController());
void _showEmployeeList() { void _showEmployeeList() async {
showModalBottomSheet( await showModalBottomSheet(
context: context, context: context,
backgroundColor: Colors.white, isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))), shape: const RoundedRectangleBorder(
builder: (_) => Obx(() { borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
final employees = controller.allEmployees; ),
return SizedBox( backgroundColor: Colors.transparent,
height: 300, builder: (_) => EmployeeSelectorBottomSheet(),
child: ListView.builder( );
itemCount: employees.length,
itemBuilder: (_, index) { // Optional cleanup
final emp = employees[index]; controller.employeeSearchController.clear();
final fullName = '${emp.firstName} ${emp.lastName}'.trim(); controller.employeeSearchResults.clear();
return ListTile( }
title: Text(fullName.isNotEmpty ? fullName : "Unnamed"),
onTap: () {
controller.selectedPaidBy.value = emp;
Navigator.pop(context);
},
);
},
),
);
}),
);
}
Future<void> _showOptionList<T>( Future<void> _showOptionList<T>(
List<T> options, List<T> options,

View File

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/expense/add_expense_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
class EmployeeSelectorBottomSheet extends StatelessWidget {
final AddExpenseController controller = Get.find<AddExpenseController>();
EmployeeSelectorBottomSheet({super.key});
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: "Search Employee",
onCancel: () => Get.back(),
onSubmit: () {},
showButtons: false,
child: Obx(() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller.employeeSearchController,
decoration: InputDecoration(
hintText: "Search by name, email...",
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
onChanged: (value) => controller.searchEmployees(value),
),
MySpacing.height(12),
SizedBox(
height: 400, // Adjust this if needed
child: controller.isSearchingEmployees.value
? const Center(child: CircularProgressIndicator())
: controller.employeeSearchResults.isEmpty
? Center(
child: MyText.bodyMedium(
"No employees found.",
fontWeight: 500,
),
)
: ListView.builder(
itemCount: controller.employeeSearchResults.length,
itemBuilder: (_, index) {
final emp = controller.employeeSearchResults[index];
final fullName =
'${emp.firstName} ${emp.lastName}'.trim();
return ListTile(
title: MyText.bodyLarge(
fullName.isNotEmpty ? fullName : "Unnamed",
fontWeight: 600,
),
onTap: () {
controller.selectedPaidBy.value = emp;
Get.back();
},
);
},
),
),
],
);
}),
);
}
}

View File

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/widgets/my_spacing.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/helpers/utils/base_bottom_sheet.dart';
class EmployeeSelectorBottomSheet extends StatefulWidget {
final RxList<EmployeeModel> selectedEmployees;
final Future<List<EmployeeModel>> Function(String) searchEmployees;
final String title;
const EmployeeSelectorBottomSheet({
super.key,
required this.selectedEmployees,
required this.searchEmployees,
this.title = "Select Employees",
});
@override
State<EmployeeSelectorBottomSheet> createState() =>
_EmployeeSelectorBottomSheetState();
}
class _EmployeeSelectorBottomSheetState
extends State<EmployeeSelectorBottomSheet> {
final TextEditingController _searchController = TextEditingController();
final RxBool isSearching = false.obs;
final RxList<EmployeeModel> searchResults = <EmployeeModel>[].obs;
@override
void initState() {
super.initState();
// Initial fetch (empty text gets all/none as you wish)
_searchEmployees('');
}
void _searchEmployees(String query) async {
isSearching.value = true;
List<EmployeeModel> results = await widget.searchEmployees(query);
searchResults.assignAll(results);
isSearching.value = false;
}
void _submitSelection() =>
Get.back(result: widget.selectedEmployees.toList());
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: widget.title,
onCancel: () => Get.back(),
onSubmit: _submitSelection,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Chips
Obx(() => widget.selectedEmployees.isEmpty
? const SizedBox.shrink()
: Wrap(
spacing: 8,
children: widget.selectedEmployees
.map(
(emp) => Chip(
label: MyText(emp.name),
onDeleted: () =>
widget.selectedEmployees.remove(emp),
),
)
.toList(),
)),
MySpacing.height(8),
// Search box
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: "Search Employees...",
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
prefixIcon: Icon(Icons.search),
),
onChanged: _searchEmployees,
),
MySpacing.height(12),
SizedBox(
height: 320, // CHANGE AS PER DESIGN!
child: Obx(() {
if (isSearching.value) {
return Center(child: CircularProgressIndicator());
}
if (searchResults.isEmpty) {
return Padding(
padding: EdgeInsets.all(20),
child:
MyText('No results', style: MyTextStyle.bodyMedium()),
);
}
return ListView.separated(
itemCount: searchResults.length,
separatorBuilder: (_, __) => Divider(height: 1),
itemBuilder: (context, index) {
final emp = searchResults[index];
final isSelected = widget.selectedEmployees.contains(emp);
return ListTile(
title: MyText(emp.name),
trailing: isSelected
? Icon(Icons.check_circle, color: Colors.indigo)
: Icon(Icons.radio_button_unchecked,
color: Colors.grey),
onTap: () {
if (isSelected) {
widget.selectedEmployees.remove(emp);
} else {
widget.selectedEmployees.add(emp);
}
});
},
);
}),
),
],
));
}
}

View File

@ -130,6 +130,10 @@ class ExpenseDetailScreen extends StatelessWidget {
'expensesTypeId': expense.expensesType.id, 'expensesTypeId': expense.expensesType.id,
'paymentModeId': expense.paymentMode.id, 'paymentModeId': expense.paymentMode.id,
'paidById': expense.paidBy.id, 'paidById': expense.paidBy.id,
// ==== Add these lines below ====
'paidByFirstName': expense.paidBy.firstName,
'paidByLastName': expense.paidBy.lastName,
// =================================
'attachments': expense.documents 'attachments': expense.documents
.map((doc) => { .map((doc) => {
'url': doc.preSignedUrl, 'url': doc.preSignedUrl,
@ -146,7 +150,7 @@ class ExpenseDetailScreen extends StatelessWidget {
await addCtrl.loadMasterData(); await addCtrl.loadMasterData();
addCtrl.populateFieldsForEdit(editData); addCtrl.populateFieldsForEdit(editData);
await showAddExpenseBottomSheet( isEdit: true,); await showAddExpenseBottomSheet(isEdit: true);
// Refresh expense details after editing // Refresh expense details after editing
await controller.fetchExpenseDetails(); await controller.fetchExpenseDetails();

View File

@ -7,6 +7,7 @@ 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/widgets/my_text_style.dart';
import 'package:marco/model/employee_model.dart'; import 'package:marco/model/employee_model.dart';
import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart';
class ExpenseFilterBottomSheet extends StatelessWidget { class ExpenseFilterBottomSheet extends StatelessWidget {
final ExpenseController expenseController; final ExpenseController expenseController;
@ -18,9 +19,16 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
required this.scrollController, required this.scrollController,
}); });
// FIX: create search adapter
Future<List<EmployeeModel>> searchEmployeesForBottomSheet(
String query) async {
await expenseController
.searchEmployees(query); // async method, returns void
return expenseController.employeeSearchResults.toList();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Obx rebuilds the widget when observable values from the controller change.
return Obx(() { return Obx(() {
return BaseBottomSheet( return BaseBottomSheet(
title: 'Filter Expenses', title: 'Filter Expenses',
@ -41,11 +49,11 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton( child: TextButton(
onPressed: () => expenseController.clearFilters(), onPressed: () => expenseController.clearFilters(),
child: const Text( child: MyText(
"Reset Filter", "Reset Filter",
style: TextStyle( style: MyTextStyle.labelMedium(
color: Colors.red, color: Colors.red,
fontWeight: FontWeight.w600, fontWeight: 600,
), ),
), ),
), ),
@ -57,9 +65,9 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
MySpacing.height(16), MySpacing.height(16),
_buildDateRangeFilter(context), _buildDateRangeFilter(context),
MySpacing.height(16), MySpacing.height(16),
_buildPaidByFilter(), _buildPaidByFilter(context),
MySpacing.height(16), MySpacing.height(16),
_buildCreatedByFilter(), _buildCreatedByFilter(context),
], ],
), ),
), ),
@ -67,7 +75,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
}); });
} }
/// Builds a generic field layout with a label and a child widget.
Widget _buildField(String label, Widget child) { Widget _buildField(String label, Widget child) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -79,7 +86,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
); );
} }
/// Extracted widget builder for the Project filter.
Widget _buildProjectFilter(BuildContext context) { Widget _buildProjectFilter(BuildContext context) {
return _buildField( return _buildField(
"Project", "Project",
@ -94,7 +100,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
); );
} }
/// Extracted widget builder for the Expense Status filter.
Widget _buildStatusFilter(BuildContext context) { Widget _buildStatusFilter(BuildContext context) {
return _buildField( return _buildField(
"Expense Status", "Expense Status",
@ -117,123 +122,128 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
); );
} }
/// Extracted widget builder for the Date Range filter.
Widget _buildDateRangeFilter(BuildContext context) { Widget _buildDateRangeFilter(BuildContext context) {
return _buildField( return _buildField(
"Date Filter", "Date Filter",
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Obx(() { Obx(() {
return SegmentedButton<String>( return SegmentedButton<String>(
segments: expenseController.dateTypes segments: expenseController.dateTypes
.map( .map(
(type) => ButtonSegment( (type) => ButtonSegment(
value: type, value: type,
label: Text( label: MyText(
type, type,
style: MyTextStyle.bodySmall( style: MyTextStyle.bodySmall(
fontWeight: 600, fontWeight: 600,
fontSize: 13, fontSize: 13,
height: 1.2, height: 1.2,
),
), ),
), ),
), )
) .toList(),
.toList(), selected: {expenseController.selectedDateType.value},
selected: {expenseController.selectedDateType.value}, onSelectionChanged: (newSelection) {
onSelectionChanged: (newSelection) { if (newSelection.isNotEmpty) {
if (newSelection.isNotEmpty) { expenseController.selectedDateType.value = newSelection.first;
expenseController.selectedDateType.value = newSelection.first; }
} },
}, style: ButtonStyle(
style: ButtonStyle( visualDensity:
visualDensity: const VisualDensity(horizontal: -2, vertical: -2), const VisualDensity(horizontal: -2, vertical: -2),
padding: MaterialStateProperty.all( padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(horizontal: 8, vertical: 6)), const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
backgroundColor: MaterialStateProperty.resolveWith(
(states) => states.contains(MaterialState.selected)
? Colors.indigo.shade100
: Colors.grey.shade100,
),
foregroundColor: MaterialStateProperty.resolveWith(
(states) => states.contains(MaterialState.selected)
? Colors.indigo
: Colors.black87,
),
shape: MaterialStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
), ),
), backgroundColor: MaterialStateProperty.resolveWith(
side: MaterialStateProperty.resolveWith( (states) => states.contains(MaterialState.selected)
(states) => BorderSide( ? Colors.indigo.shade100
color: states.contains(MaterialState.selected) : Colors.grey.shade100,
),
foregroundColor: MaterialStateProperty.resolveWith(
(states) => states.contains(MaterialState.selected)
? Colors.indigo ? Colors.indigo
: Colors.grey.shade300, : Colors.black87,
width: 1, ),
shape: MaterialStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
side: MaterialStateProperty.resolveWith(
(states) => BorderSide(
color: states.contains(MaterialState.selected)
? Colors.indigo
: Colors.grey.shade300,
width: 1,
),
), ),
), ),
), );
); }),
}), MySpacing.height(16),
MySpacing.height(16), Row(
Row( children: [
children: [ Expanded(
Expanded( child: _dateButton(
child: _dateButton( label: expenseController.startDate.value == null
label: expenseController.startDate.value == null ? 'Start Date'
? 'Start Date' : DateTimeUtils.formatDate(
: DateTimeUtils.formatDate( expenseController.startDate.value!, 'dd MMM yyyy'),
expenseController.startDate.value!, 'dd MMM yyyy'), onTap: () => _selectDate(
onTap: () => _selectDate( context,
context, expenseController.startDate,
expenseController.startDate, lastDate: expenseController.endDate.value,
lastDate: expenseController.endDate.value, ),
), ),
), ),
), MySpacing.width(12),
MySpacing.width(12), Expanded(
Expanded( child: _dateButton(
child: _dateButton( label: expenseController.endDate.value == null
label: expenseController.endDate.value == null ? 'End Date'
? 'End Date' : DateTimeUtils.formatDate(
: DateTimeUtils.formatDate( expenseController.endDate.value!, 'dd MMM yyyy'),
expenseController.endDate.value!, 'dd MMM yyyy'), onTap: () => _selectDate(
onTap: () => _selectDate( context,
context, expenseController.endDate,
expenseController.endDate, firstDate: expenseController.startDate.value,
firstDate: expenseController.startDate.value, ),
), ),
), ),
), ],
], ),
), ],
], ),
), );
); }
}
Widget _buildPaidByFilter(BuildContext context) {
/// Extracted widget builder for the "Paid By" employee filter.
Widget _buildPaidByFilter() {
return _buildField( return _buildField(
"Paid By", "Paid By",
_employeeSelector( _employeeSelector(
selectedEmployees: expenseController.selectedPaidByEmployees), context: context,
selectedEmployees: expenseController.selectedPaidByEmployees,
searchEmployees: searchEmployeesForBottomSheet, // FIXED
title: 'Search Paid By',
),
); );
} }
/// Extracted widget builder for the "Created By" employee filter. Widget _buildCreatedByFilter(BuildContext context) {
Widget _buildCreatedByFilter() {
return _buildField( return _buildField(
"Created By", "Created By",
_employeeSelector( _employeeSelector(
selectedEmployees: expenseController.selectedCreatedByEmployees), context: context,
selectedEmployees: expenseController.selectedCreatedByEmployees,
searchEmployees: searchEmployeesForBottomSheet, // FIXED
title: 'Search Created By',
),
); );
} }
/// Helper method to show a date picker and update the state.
Future<void> _selectDate( Future<void> _selectDate(
BuildContext context, BuildContext context,
Rx<DateTime?> dateNotifier, { Rx<DateTime?> dateNotifier, {
@ -251,7 +261,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
} }
} }
/// Reusable popup selector widget.
Widget _popupSelector( Widget _popupSelector(
BuildContext context, { BuildContext context, {
required String currentValue, required String currentValue,
@ -264,7 +273,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
itemBuilder: (context) => items itemBuilder: (context) => items
.map((e) => PopupMenuItem<String>( .map((e) => PopupMenuItem<String>(
value: e, value: e,
child: Text(e), child: MyText(e),
)) ))
.toList(), .toList(),
child: Container( child: Container(
@ -278,7 +287,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Expanded( Expanded(
child: Text( child: MyText(
currentValue, currentValue,
style: const TextStyle(color: Colors.black87), style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -291,7 +300,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
); );
} }
/// Reusable date button widget.
Widget _dateButton({required String label, required VoidCallback onTap}) { Widget _dateButton({required String label, required VoidCallback onTap}) {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
@ -307,7 +315,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
const Icon(Icons.calendar_today, size: 16, color: Colors.grey), const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
MySpacing.width(8), MySpacing.width(8),
Expanded( Expanded(
child: Text( child: MyText(
label, label,
style: MyTextStyle.bodyMedium(), style: MyTextStyle.bodyMedium(),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -319,9 +327,36 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
); );
} }
/// Reusable employee selector with Autocomplete. Future<void> _showEmployeeSelectorBottomSheet({
Widget _employeeSelector({required RxList<EmployeeModel> selectedEmployees}) { required BuildContext context,
final textController = TextEditingController(); required RxList<EmployeeModel> selectedEmployees,
required Future<List<EmployeeModel>> Function(String) searchEmployees,
String title = 'Select Employee',
}) async {
final List<EmployeeModel>? result =
await showModalBottomSheet<List<EmployeeModel>>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => EmployeeSelectorBottomSheet(
selectedEmployees: selectedEmployees,
searchEmployees: searchEmployees,
title: title,
),
);
if (result != null) {
selectedEmployees.assignAll(result);
}
}
Widget _employeeSelector({
required BuildContext context,
required RxList<EmployeeModel> selectedEmployees,
required Future<List<EmployeeModel>> Function(String) searchEmployees,
String title = 'Search Employee',
}) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -331,102 +366,39 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
} }
return Wrap( return Wrap(
spacing: 8, spacing: 8,
runSpacing: 0,
children: selectedEmployees children: selectedEmployees
.map((emp) => Chip( .map((emp) => Chip(
label: Text(emp.name), label: MyText(emp.name),
onDeleted: () => selectedEmployees.remove(emp), onDeleted: () => selectedEmployees.remove(emp),
deleteIcon: const Icon(Icons.close, size: 18),
backgroundColor: Colors.grey.shade200,
padding: const EdgeInsets.all(8),
)) ))
.toList(), .toList(),
); );
}), }),
MySpacing.height(8), MySpacing.height(8),
Autocomplete<EmployeeModel>( GestureDetector(
optionsBuilder: (TextEditingValue textEditingValue) { onTap: () => _showEmployeeSelectorBottomSheet(
if (textEditingValue.text.isEmpty) { context: context,
return const Iterable<EmployeeModel>.empty(); selectedEmployees: selectedEmployees,
} searchEmployees: searchEmployees,
return expenseController.allEmployees.where((emp) { title: title,
final isNotSelected = !selectedEmployees.contains(emp); ),
final matchesQuery = emp.name child: Container(
.toLowerCase() padding: MySpacing.all(12),
.contains(textEditingValue.text.toLowerCase()); decoration: BoxDecoration(
return isNotSelected && matchesQuery; color: Colors.grey.shade100,
}); borderRadius: BorderRadius.circular(12),
}, border: Border.all(color: Colors.grey.shade300),
displayStringForOption: (EmployeeModel emp) => emp.name, ),
onSelected: (EmployeeModel emp) { child: Row(
if (!selectedEmployees.contains(emp)) { children: [
selectedEmployees.add(emp); const Icon(Icons.search, color: Colors.grey),
} MySpacing.width(8),
textController.clear(); Expanded(child: MyText(title)),
}, ],
fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { ),
// Assign the local controller to the one from the builder ),
// to allow clearing it on selection.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (textController != controller) {
// This is a workaround to sync controllers
}
});
return TextField(
controller: controller,
focusNode: focusNode,
decoration: _inputDecoration("Search Employee"),
onSubmitted: (_) => onFieldSubmitted(),
);
},
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
color: Colors.white,
elevation: 4.0,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: options.length,
itemBuilder: (context, index) {
final emp = options.elementAt(index);
return ListTile(
title: Text(emp.name),
onTap: () => onSelected(emp),
);
},
),
),
),
);
},
), ),
], ],
); );
} }
/// Centralized decoration for text fields.
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),
);
}
} }