diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index adaa258..c715dcf 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -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 = [].obs; final allEmployees = [].obs; final existingAttachments = >[].obs; + final employeeSearchController = TextEditingController(); + final isSearchingEmployees = false.obs; + final employeeSearchResults = [].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 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 data) { + Future populateFieldsForEdit(Map 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 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; - } - } } diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 51a475c..a0a012a 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -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 expenses = [].obs; @@ -32,6 +32,10 @@ class ExpenseController extends GetxController { final RxList selectedCreatedByEmployees = [].obs; final RxString selectedDateType = 'Transaction Date'.obs; + + final employeeSearchController = TextEditingController(); + final isSearchingEmployees = false.obs; + final employeeSearchResults = [].obs; final List 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 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 fetchExpenses({ List? projectIds, @@ -173,32 +207,33 @@ class ExpenseController extends GetxController { /// Fetch master data: expense types, payment modes, and expense status Future 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 fetchGlobalProjects() async { diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 1a98492..ac47286 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -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"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 350eb8b..0062b1f 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -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?> searchEmployeesBasic({ + String? searchString, + }) async { + // Remove ArgumentError check because searchString is optional now + + final queryParams = {}; + + // 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?> getAllEmployeesByProject( String projectId) async { diff --git a/lib/model/employees/employee_with_id_name_model.dart b/lib/model/employees/employee_with_id_name_model.dart new file mode 100644 index 0000000..bb9a685 --- /dev/null +++ b/lib/model/employees/employee_with_id_name_model.dart @@ -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 json) { + return EmployeeModelWithIdName( + id: json['id']?.toString() ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + name: '${json['firstName'] ?? ''} ${json['lastName'] ?? ''}'.trim(), + ); + } + + Map toJson() { + return { + 'id': id, + 'firstName': name.split(' ').first, + 'lastName': name.split(' ').length > 1 ? name.split(' ').last : '', + }; + } +} diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 35598f3..ca93abe 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -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 _showOptionList( List options, diff --git a/lib/model/expense/employee_selector_bottom_sheet.dart b/lib/model/expense/employee_selector_bottom_sheet.dart new file mode 100644 index 0000000..fb9fe27 --- /dev/null +++ b/lib/model/expense/employee_selector_bottom_sheet.dart @@ -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(); + + 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(); + }, + ); + }, + ), + ), + ], + ); + }), + ); + } +} diff --git a/lib/model/expense/employee_selector_for_filter_bottom_sheet.dart b/lib/model/expense/employee_selector_for_filter_bottom_sheet.dart new file mode 100644 index 0000000..d85b403 --- /dev/null +++ b/lib/model/expense/employee_selector_for_filter_bottom_sheet.dart @@ -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 selectedEmployees; + final Future> Function(String) searchEmployees; + final String title; + + const EmployeeSelectorBottomSheet({ + super.key, + required this.selectedEmployees, + required this.searchEmployees, + this.title = "Select Employees", + }); + + @override + State createState() => + _EmployeeSelectorBottomSheetState(); +} + +class _EmployeeSelectorBottomSheetState + extends State { + final TextEditingController _searchController = TextEditingController(); + final RxBool isSearching = false.obs; + final RxList searchResults = [].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 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); + } + }); + }, + ); + }), + ), + ], + )); + } +} diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 77d3875..9a8d7d2 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -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(); diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart index ea121c2..1162ba5 100644 --- a/lib/view/expense/expense_filter_bottom_sheet.dart +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -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> 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( - 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( + 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 _selectDate( BuildContext context, Rx 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( 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 selectedEmployees}) { - final textController = TextEditingController(); + Future _showEmployeeSelectorBottomSheet({ + required BuildContext context, + required RxList selectedEmployees, + required Future> Function(String) searchEmployees, + String title = 'Select Employee', + }) async { + final List? result = + await showModalBottomSheet>( + 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 selectedEmployees, + required Future> 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( - optionsBuilder: (TextEditingValue textEditingValue) { - if (textEditingValue.text.isEmpty) { - return const Iterable.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), - ); - } }