made changes for employee get method

This commit is contained in:
Vaibhav Surve 2025-08-05 20:35:22 +05:30
parent 0401b41b3c
commit aa76ec60cb
10 changed files with 562 additions and 283 deletions

View File

@ -8,13 +8,13 @@ import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.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/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.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/payment_types_model.dart';
@ -50,6 +50,9 @@ class AddExpenseController extends GetxController {
final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs;
final existingAttachments = <Map<String, dynamic>>[].obs;
final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs;
final employeeSearchResults = <EmployeeModel>[].obs;
// Editing
String? editingExpenseId;
@ -61,7 +64,10 @@ class AddExpenseController extends GetxController {
super.onInit();
fetchMasterData();
fetchGlobalProjects();
fetchAllEmployees();
employeeSearchController.addListener(() {
searchEmployees(employeeSearchController.text);
});
}
@override
@ -77,12 +83,44 @@ class AddExpenseController extends GetxController {
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 ----------
void populateFieldsForEdit(Map<String, dynamic> data) {
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
isEditMode.value = true;
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'] ?? '';
amountController.text = data['amount']?.toString() ?? '';
supplierController.text = data['supplerName'] ?? '';
@ -90,7 +128,7 @@ class AddExpenseController extends GetxController {
transactionIdController.text = data['transactionId'] ?? '';
locationController.text = data['location'] ?? '';
// Transaction Date
// --- Transaction Date ---
if (data['transactionDate'] != null) {
try {
final parsedDate = DateTime.parse(data['transactionDate']);
@ -107,31 +145,29 @@ class AddExpenseController extends GetxController {
transactionDateController.clear();
}
// No of Persons
// --- No of Persons ---
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
// Select Expense Type and Payment Mode by matching IDs
// --- Dropdown selections ---
selectedExpenseType.value =
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
// Select Paid By employee matching id (case insensitive, trimmed)
final paidById = data['paidById']?.toString().trim().toLowerCase() ?? '';
selectedPaidBy.value = allEmployees
.firstWhereOrNull((e) => e.id.trim().toLowerCase() == paidById);
// --- Paid By select ---
// 1. By ID
// --- Paid By select ---
selectedPaidBy.value =
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
if (selectedPaidBy.value == null && paidById.isNotEmpty) {
logSafe('⚠️ Could not match paidById: "$paidById"',
level: LogLevel.warning);
for (var emp in allEmployees) {
logSafe(
'Employee ID: "${emp.id.trim().toLowerCase()}", Name: "${emp.name}"',
level: LogLevel.warning);
}
if (selectedPaidBy.value == null) {
final fullName = '$paidByFirstName $paidByLastName';
await searchEmployees(fullName);
selectedPaidBy.value = employeeSearchResults
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
}
// Populate existing attachments if present
// --- Existing Attachments ---
existingAttachments.clear();
if (data['attachments'] != null && data['attachments'] is List) {
existingAttachments
@ -184,7 +220,6 @@ class AddExpenseController extends GetxController {
await Future.wait([
fetchMasterData(),
fetchGlobalProjects(),
fetchAllEmployees(),
]);
}
@ -451,18 +486,4 @@ class AddExpenseController extends GetxController {
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/employee_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:flutter/material.dart';
class ExpenseController extends GetxController {
final RxList<ExpenseModel> expenses = <ExpenseModel>[].obs;
@ -32,6 +32,10 @@ class ExpenseController extends GetxController {
final RxList<EmployeeModel> selectedCreatedByEmployees =
<EmployeeModel>[].obs;
final RxString selectedDateType = 'Transaction Date'.obs;
final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs;
final employeeSearchResults = <EmployeeModel>[].obs;
final List<String> dateTypes = [
'Transaction Date',
@ -46,6 +50,9 @@ class ExpenseController extends GetxController {
super.onInit();
loadInitialMasterData();
fetchAllEmployees();
employeeSearchController.addListener(() {
searchEmployees(employeeSearchController.text);
});
}
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
Future<void> fetchExpenses({
List<String>? projectIds,
@ -173,32 +207,33 @@ class ExpenseController extends GetxController {
/// Fetch master data: expense types, payment modes, and expense status
Future<void> fetchMasterData() async {
try {
final expenseTypesData = await ApiService.getMasterExpenseTypes();
if (expenseTypesData is List) {
expenseTypes.value =
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
try {
final expenseTypesData = await ApiService.getMasterExpenseTypes();
if (expenseTypesData is List) {
expenseTypes.value =
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
final paymentModesData = await ApiService.getMasterPaymentModes();
if (paymentModesData is List) {
paymentModes.value =
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
}
final paymentModesData = await ApiService.getMasterPaymentModes();
if (paymentModesData is List) {
paymentModes.value =
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
}
final expenseStatusData = await ApiService.getMasterExpenseStatus();
if (expenseStatusData is List) {
expenseStatuses.value =
expenseStatusData.map((e) => ExpenseStatusModel.fromJson(e)).toList();
final expenseStatusData = await ApiService.getMasterExpenseStatus();
if (expenseStatusData is List) {
expenseStatuses.value = expenseStatusData
.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
Future<void> fetchGlobalProjects() async {

View File

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

View File

@ -1151,6 +1151,31 @@ class ApiService {
}
// === 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(
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/model/expense/expense_type_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/utils/base_bottom_sheet.dart';
import 'package:url_launcher/url_launcher_string.dart';
@ -39,34 +40,23 @@ class _AddExpenseBottomSheet extends StatefulWidget {
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
final AddExpenseController controller = Get.put(AddExpenseController());
void _showEmployeeList() {
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (_) => Obx(() {
final employees = controller.allEmployees;
return SizedBox(
height: 300,
child: ListView.builder(
itemCount: employees.length,
itemBuilder: (_, index) {
final emp = employees[index];
final fullName = '${emp.firstName} ${emp.lastName}'.trim();
return ListTile(
title: Text(fullName.isNotEmpty ? fullName : "Unnamed"),
onTap: () {
controller.selectedPaidBy.value = emp;
Navigator.pop(context);
},
);
},
),
);
}),
);
}
void _showEmployeeList() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
backgroundColor: Colors.transparent,
builder: (_) => EmployeeSelectorBottomSheet(),
);
// Optional cleanup
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
}
Future<void> _showOptionList<T>(
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,
'paymentModeId': expense.paymentMode.id,
'paidById': expense.paidBy.id,
// ==== Add these lines below ====
'paidByFirstName': expense.paidBy.firstName,
'paidByLastName': expense.paidBy.lastName,
// =================================
'attachments': expense.documents
.map((doc) => {
'url': doc.preSignedUrl,
@ -146,7 +150,7 @@ class ExpenseDetailScreen extends StatelessWidget {
await addCtrl.loadMasterData();
addCtrl.populateFieldsForEdit(editData);
await showAddExpenseBottomSheet( isEdit: true,);
await showAddExpenseBottomSheet(isEdit: true);
// Refresh expense details after editing
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_style.dart';
import 'package:marco/model/employee_model.dart';
import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart';
class ExpenseFilterBottomSheet extends StatelessWidget {
final ExpenseController expenseController;
@ -18,9 +19,16 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
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
Widget build(BuildContext context) {
// Obx rebuilds the widget when observable values from the controller change.
return Obx(() {
return BaseBottomSheet(
title: 'Filter Expenses',
@ -41,11 +49,11 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => expenseController.clearFilters(),
child: const Text(
child: MyText(
"Reset Filter",
style: TextStyle(
style: MyTextStyle.labelMedium(
color: Colors.red,
fontWeight: FontWeight.w600,
fontWeight: 600,
),
),
),
@ -57,9 +65,9 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
MySpacing.height(16),
_buildDateRangeFilter(context),
MySpacing.height(16),
_buildPaidByFilter(),
_buildPaidByFilter(context),
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) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -79,7 +86,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
);
}
/// Extracted widget builder for the Project filter.
Widget _buildProjectFilter(BuildContext context) {
return _buildField(
"Project",
@ -94,7 +100,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
);
}
/// Extracted widget builder for the Expense Status filter.
Widget _buildStatusFilter(BuildContext context) {
return _buildField(
"Expense Status",
@ -117,123 +122,128 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
);
}
/// Extracted widget builder for the Date Range filter.
Widget _buildDateRangeFilter(BuildContext context) {
return _buildField(
"Date Filter",
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() {
return SegmentedButton<String>(
segments: expenseController.dateTypes
.map(
(type) => ButtonSegment(
value: type,
label: Text(
type,
style: MyTextStyle.bodySmall(
fontWeight: 600,
fontSize: 13,
height: 1.2,
return _buildField(
"Date Filter",
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() {
return SegmentedButton<String>(
segments: expenseController.dateTypes
.map(
(type) => ButtonSegment(
value: type,
label: MyText(
type,
style: MyTextStyle.bodySmall(
fontWeight: 600,
fontSize: 13,
height: 1.2,
),
),
),
),
)
.toList(),
selected: {expenseController.selectedDateType.value},
onSelectionChanged: (newSelection) {
if (newSelection.isNotEmpty) {
expenseController.selectedDateType.value = newSelection.first;
}
},
style: ButtonStyle(
visualDensity: const VisualDensity(horizontal: -2, vertical: -2),
padding: MaterialStateProperty.all(
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),
)
.toList(),
selected: {expenseController.selectedDateType.value},
onSelectionChanged: (newSelection) {
if (newSelection.isNotEmpty) {
expenseController.selectedDateType.value = newSelection.first;
}
},
style: ButtonStyle(
visualDensity:
const VisualDensity(horizontal: -2, vertical: -2),
padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
),
side: MaterialStateProperty.resolveWith(
(states) => BorderSide(
color: states.contains(MaterialState.selected)
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.grey.shade300,
width: 1,
: Colors.black87,
),
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),
Row(
children: [
Expanded(
child: _dateButton(
label: expenseController.startDate.value == null
? 'Start Date'
: DateTimeUtils.formatDate(
expenseController.startDate.value!, 'dd MMM yyyy'),
onTap: () => _selectDate(
context,
expenseController.startDate,
lastDate: expenseController.endDate.value,
);
}),
MySpacing.height(16),
Row(
children: [
Expanded(
child: _dateButton(
label: expenseController.startDate.value == null
? 'Start Date'
: DateTimeUtils.formatDate(
expenseController.startDate.value!, 'dd MMM yyyy'),
onTap: () => _selectDate(
context,
expenseController.startDate,
lastDate: expenseController.endDate.value,
),
),
),
),
MySpacing.width(12),
Expanded(
child: _dateButton(
label: expenseController.endDate.value == null
? 'End Date'
: DateTimeUtils.formatDate(
expenseController.endDate.value!, 'dd MMM yyyy'),
onTap: () => _selectDate(
context,
expenseController.endDate,
firstDate: expenseController.startDate.value,
MySpacing.width(12),
Expanded(
child: _dateButton(
label: expenseController.endDate.value == null
? 'End Date'
: DateTimeUtils.formatDate(
expenseController.endDate.value!, 'dd MMM yyyy'),
onTap: () => _selectDate(
context,
expenseController.endDate,
firstDate: expenseController.startDate.value,
),
),
),
),
],
),
],
),
);
}
],
),
],
),
);
}
/// Extracted widget builder for the "Paid By" employee filter.
Widget _buildPaidByFilter() {
Widget _buildPaidByFilter(BuildContext context) {
return _buildField(
"Paid By",
_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() {
Widget _buildCreatedByFilter(BuildContext context) {
return _buildField(
"Created By",
_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(
BuildContext context,
Rx<DateTime?> dateNotifier, {
@ -251,7 +261,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
}
}
/// Reusable popup selector widget.
Widget _popupSelector(
BuildContext context, {
required String currentValue,
@ -264,7 +273,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
itemBuilder: (context) => items
.map((e) => PopupMenuItem<String>(
value: e,
child: Text(e),
child: MyText(e),
))
.toList(),
child: Container(
@ -278,7 +287,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
child: MyText(
currentValue,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
@ -291,7 +300,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
);
}
/// Reusable date button widget.
Widget _dateButton({required String label, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
@ -307,7 +315,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
MySpacing.width(8),
Expanded(
child: Text(
child: MyText(
label,
style: MyTextStyle.bodyMedium(),
overflow: TextOverflow.ellipsis,
@ -319,9 +327,36 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
);
}
/// Reusable employee selector with Autocomplete.
Widget _employeeSelector({required RxList<EmployeeModel> selectedEmployees}) {
final textController = TextEditingController();
Future<void> _showEmployeeSelectorBottomSheet({
required BuildContext context,
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -331,102 +366,39 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
}
return Wrap(
spacing: 8,
runSpacing: 0,
children: selectedEmployees
.map((emp) => Chip(
label: Text(emp.name),
label: MyText(emp.name),
onDeleted: () => selectedEmployees.remove(emp),
deleteIcon: const Icon(Icons.close, size: 18),
backgroundColor: Colors.grey.shade200,
padding: const EdgeInsets.all(8),
))
.toList(),
);
}),
MySpacing.height(8),
Autocomplete<EmployeeModel>(
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<EmployeeModel>.empty();
}
return expenseController.allEmployees.where((emp) {
final isNotSelected = !selectedEmployees.contains(emp);
final matchesQuery = emp.name
.toLowerCase()
.contains(textEditingValue.text.toLowerCase());
return isNotSelected && matchesQuery;
});
},
displayStringForOption: (EmployeeModel emp) => emp.name,
onSelected: (EmployeeModel emp) {
if (!selectedEmployees.contains(emp)) {
selectedEmployees.add(emp);
}
textController.clear();
},
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),
);
},
),
),
),
);
},
GestureDetector(
onTap: () => _showEmployeeSelectorBottomSheet(
context: context,
selectedEmployees: selectedEmployees,
searchEmployees: searchEmployees,
title: title,
),
child: Container(
padding: MySpacing.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: [
const Icon(Icons.search, color: Colors.grey),
MySpacing.width(8),
Expanded(child: MyText(title)),
],
),
),
),
],
);
}
/// 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),
);
}
}