From a3b95b4d07ab97a97189fed81a0f3e2a16266997 Mon Sep 17 00:00:00 2001 From: Manish Date: Mon, 24 Nov 2025 15:08:03 +0530 Subject: [PATCH] reenhacement of employee selector --- .../multiple_select_bottomsheet.dart | 156 ++++++++----- .../add_payment_request_bottom_sheet.dart | 213 ++++++++---------- .../payment_request_filter_bottom_sheet.dart | 8 +- 3 files changed, 206 insertions(+), 171 deletions(-) diff --git a/lib/model/employees/multiple_select_bottomsheet.dart b/lib/model/employees/multiple_select_bottomsheet.dart index 829dc4f..f3be00c 100644 --- a/lib/model/employees/multiple_select_bottomsheet.dart +++ b/lib/model/employees/multiple_select_bottomsheet.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:on_field_work/helpers/utils/base_bottom_sheet.dart'; @@ -24,33 +25,72 @@ class EmployeeSelectionBottomSheet extends StatefulWidget { class _EmployeeSelectionBottomSheetState extends State { final TextEditingController _searchController = TextEditingController(); + final RxBool _isSearching = false.obs; - final RxList _searchResults = [].obs; + final RxList _allResults = [].obs; + late RxList _selectedEmployees; + Timer? _debounce; + @override void initState() { super.initState(); _selectedEmployees = RxList.from(widget.initiallySelected); - _searchEmployees(''); + + _performSearch(''); } @override void dispose() { + _debounce?.cancel(); _searchController.dispose(); super.dispose(); } - Future _searchEmployees(String query) async { + // ------------------------------------------------------ + // 🔥 Optimized debounce-based search + // ------------------------------------------------------ + void _onSearchChanged(String query) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), () { + _performSearch(query.trim()); + }); + } + + Future _performSearch(String query) async { _isSearching.value = true; + final data = await ApiService.searchEmployeesBasic(searchString: query); + final results = (data as List) .map((e) => EmployeeModel.fromJson(e as Map)) .toList(); - _searchResults.assignAll(results); + + // ------------------------------------------------------ + // Auto-move selected employees to top + // ------------------------------------------------------ + results.sort((a, b) { + if (widget.multipleSelection) { + // Only move selected employees to top in multi-select + final aSel = _selectedEmployees.contains(a) ? 0 : 1; + final bSel = _selectedEmployees.contains(b) ? 0 : 1; + + if (aSel != bSel) return aSel.compareTo(bSel); + } + + // Otherwise, keep original order (or alphabetically if needed) + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + + _allResults.assignAll(results); + _isSearching.value = false; } + // ------------------------------------------------------ + // Handle tap & checkbox + // ------------------------------------------------------ void _toggleEmployee(EmployeeModel emp) { if (widget.multipleSelection) { if (_selectedEmployees.contains(emp)) { @@ -61,9 +101,14 @@ class _EmployeeSelectionBottomSheetState } else { _selectedEmployees.assignAll([emp]); } - _selectedEmployees.refresh(); // important for Obx rebuild + + // Re-sort list after each toggle + _performSearch(_searchController.text.trim()); } + // ------------------------------------------------------ + // Submit selection + // ------------------------------------------------------ void _handleSubmit() { if (widget.multipleSelection) { Navigator.of(context).pop(_selectedEmployees.toList()); @@ -73,11 +118,14 @@ class _EmployeeSelectionBottomSheetState } } + // ------------------------------------------------------ + // Search bar widget + // ------------------------------------------------------ Widget _searchBar() => Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: TextField( controller: _searchController, - onChanged: _searchEmployees, + onChanged: _onSearchChanged, decoration: InputDecoration( hintText: 'Search employees...', filled: true, @@ -88,7 +136,7 @@ class _EmployeeSelectionBottomSheetState icon: const Icon(Icons.close, color: Colors.grey), onPressed: () { _searchController.clear(); - _searchEmployees(''); + _performSearch(''); }, ) : null, @@ -102,60 +150,64 @@ class _EmployeeSelectionBottomSheetState ), ); + // ------------------------------------------------------ + // Employee list (optimized) + // ------------------------------------------------------ Widget _employeeList() => Expanded( child: Obx(() { - if (_isSearching.value) { - return const Center(child: CircularProgressIndicator()); - } - - if (_searchResults.isEmpty) { - return const Center(child: Text("No employees found")); - } + final results = _allResults; return ListView.builder( padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: _searchResults.length, + itemCount: results.length, itemBuilder: (context, index) { - final emp = _searchResults[index]; + final emp = results[index]; + final isSelected = _selectedEmployees.contains(emp); - return Obx(() { - final isSelected = _selectedEmployees.contains(emp); - return ListTile( - leading: CircleAvatar( - backgroundColor: Colors.blueAccent, - child: Text( - (emp.firstName.isNotEmpty ? emp.firstName[0] : 'U') - .toUpperCase(), - style: const TextStyle(color: Colors.white), - ), + Widget trailingWidget; + + if (widget.multipleSelection) { + // Multiple selection → normal checkbox + trailingWidget = Checkbox( + value: isSelected, + onChanged: (_) => _toggleEmployee(emp), + fillColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) + ? Colors.blueAccent + : Colors.white, ), - title: Text('${emp.firstName} ${emp.lastName}'), - subtitle: Text(emp.email), - trailing: Checkbox( - value: isSelected, - onChanged: (_) { - FocusScope.of(context).unfocus(); // hide keyboard - _toggleEmployee(emp); - }, - fillColor: MaterialStateProperty.resolveWith( - (states) => states.contains(MaterialState.selected) - ? Colors.blueAccent - : Colors.white, - ), - ), - onTap: () { - FocusScope.of(context).unfocus(); - _toggleEmployee(emp); - }, - contentPadding: - const EdgeInsets.symmetric(horizontal: 0, vertical: 4), ); - }); + } else { + // Single selection → check circle + trailingWidget = isSelected + ? const Icon(Icons.check_circle, color: Colors.blueAccent) + : const Icon(Icons.circle_outlined, color: Colors.grey); + } + + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.blueAccent, + child: Text( + (emp.firstName.isNotEmpty ? emp.firstName[0] : 'U') + .toUpperCase(), + style: const TextStyle(color: Colors.white), + ), + ), + title: Text('${emp.firstName} ${emp.lastName}'), + subtitle: Text(emp.email), + trailing: trailingWidget, + onTap: () => _toggleEmployee(emp), + contentPadding: + const EdgeInsets.symmetric(horizontal: 0, vertical: 4), + ); }, ); }), ); + // ------------------------------------------------------ + // Build bottom sheet + // ------------------------------------------------------ @override Widget build(BuildContext context) { return BaseBottomSheet( @@ -164,10 +216,12 @@ class _EmployeeSelectionBottomSheetState onSubmit: _handleSubmit, child: SizedBox( height: MediaQuery.of(context).size.height * 0.7, - child: Column(children: [ - _searchBar(), - _employeeList(), - ]), + child: Column( + children: [ + _searchBar(), + _employeeList(), + ], + ), ), ); } diff --git a/lib/model/finance/add_payment_request_bottom_sheet.dart b/lib/model/finance/add_payment_request_bottom_sheet.dart index b27dde4..4950b73 100644 --- a/lib/model/finance/add_payment_request_bottom_sheet.dart +++ b/lib/model/finance/add_payment_request_bottom_sheet.dart @@ -9,6 +9,8 @@ import 'package:on_field_work/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/expense/expense_form_widgets.dart'; import 'package:on_field_work/helpers/widgets/my_confirmation_dialog.dart'; +import 'package:on_field_work/model/employees/multiple_select_bottomsheet.dart'; +import 'package:on_field_work/model/employees/employee_model.dart'; Future showPaymentRequestBottomSheet({ bool isEdit = false, @@ -58,12 +60,10 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> if (widget.isEdit && widget.existingData != null) { final data = widget.existingData!; - // Prefill text fields controller.titleController.text = data["title"] ?? ""; controller.amountController.text = data["amount"]?.toString() ?? ""; controller.descriptionController.text = data["description"] ?? ""; - // Prefill due date if (data["dueDate"] != null && data["dueDate"].toString().isNotEmpty) { DateTime? dueDate = DateTime.tryParse(data["dueDate"].toString()); if (dueDate != null) { @@ -73,15 +73,15 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> } } - // Prefill dropdowns & toggles controller.selectedProject.value = { 'id': data["projectId"], 'name': data["projectName"], }; + controller.selectedPayee.value = data["payee"] ?? ""; controller.isAdvancePayment.value = data["isAdvancePayment"] ?? false; - // Categories & currencies + // When categories and currencies load, set selected ones everAll([controller.categories, controller.currencies], (_) { controller.selectedCategory.value = controller.categories .firstWhereOrNull((c) => c.id == data["expenseCategoryId"]); @@ -89,7 +89,6 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> .firstWhereOrNull((c) => c.id == data["currencyId"]); }); - // Attachments final attachmentsData = data["attachments"]; if (attachmentsData != null && attachmentsData is List && @@ -116,21 +115,21 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> @override Widget build(BuildContext context) { - return Obx(() => Form( + return Obx( + () => SafeArea( + child: Form( key: _formKey, child: BaseBottomSheet( - title: widget.isEdit - ? "Edit Payment Request" - : "Create Payment Request", + title: widget.isEdit ? "Edit Payment Request" : "Create Payment Request", isSubmitting: controller.isSubmitting.value, onCancel: Get.back, submitText: "Save as Draft", onSubmit: () async { if (_formKey.currentState!.validate() && _validateSelections()) { bool success = false; + if (widget.isEdit && widget.existingData != null) { - final requestId = - widget.existingData!['id']?.toString() ?? ''; + final requestId = widget.existingData!['id']?.toString() ?? ''; if (requestId.isNotEmpty) { success = await controller.submitEditedPaymentRequest( requestId: requestId); @@ -144,7 +143,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> if (success) { Get.back(); - if (widget.onUpdated != null) widget.onUpdated!(); + widget.onUpdated?.call(); showAppSnackbar( title: "Success", @@ -157,31 +156,33 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> } }, child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ _buildDropdown( - "Select Project", - Icons.work_outline, - controller.selectedProject.value?['name'] ?? - "Select Project", - controller.globalProjects, - (p) => p['name'], - controller.selectProject, - key: _projectDropdownKey), + "Select Project", + Icons.work_outline, + controller.selectedProject.value?['name'] ?? "Select Project", + controller.globalProjects, + (p) => p['name'], + controller.selectProject, + key: _projectDropdownKey, + ), _gap(), _buildDropdown( - "Expense Category", - Icons.category_outlined, - controller.selectedCategory.value?.name ?? - "Select Category", - controller.categories, - (c) => c.name, - controller.selectCategory, - key: _categoryDropdownKey), + "Expense Category", + Icons.category_outlined, + controller.selectedCategory.value?.name ?? "Select Category", + controller.categories, + (c) => c.name, + controller.selectCategory, + key: _categoryDropdownKey, + ), _gap(), - _buildTextField( - "Title", Icons.title_outlined, controller.titleController, + _buildTextField("Title", Icons.title_outlined, + controller.titleController, hint: "Enter title", validator: Validators.requiredField), _gap(), _buildRadio("Is Advance Payment", Icons.attach_money_outlined, @@ -199,17 +200,17 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> ? null : "Enter valid amount"), _gap(), - _buildPayeeAutocompleteField(), + _buildPayeeField(), _gap(), _buildDropdown( - "Currency", - Icons.monetization_on_outlined, - controller.selectedCurrency.value?.currencyName ?? - "Select Currency", - controller.currencies, - (c) => c.currencyName, - controller.selectCurrency, - key: _currencyDropdownKey), + "Currency", + Icons.monetization_on_outlined, + controller.selectedCurrency.value?.currencyName ?? "Select Currency", + controller.currencies, + (c) => c.currencyName, + controller.selectCurrency, + key: _currencyDropdownKey, + ), _gap(), _buildTextField("Description", Icons.description_outlined, controller.descriptionController, @@ -218,11 +219,14 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> validator: Validators.requiredField), _gap(), _buildAttachmentsSection(), + MySpacing.height(30), ], ), ), ), - )); + ), + ), + ); } Widget _buildDropdown(String title, IconData icon, String value, @@ -234,9 +238,10 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> SectionTitle(icon: icon, title: title, requiredField: true), MySpacing.height(6), DropdownTile( - key: key, - title: value, - onTap: () => _showOptionList(options, getLabel, onSelected, key)), + key: key, + title: value, + onTap: () => _showOptionList(options, getLabel, onSelected, key), + ), ], ); } @@ -265,7 +270,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> } Widget _buildRadio( - String title, IconData icon, RxBool controller, List labels) { + String title, IconData icon, RxBool controllerBool, List labels) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -284,15 +289,16 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> final i = entry.key; final label = entry.value; final value = i == 0; + return Expanded( child: RadioListTile( contentPadding: EdgeInsets.zero, title: Text(label), value: value, - groupValue: controller.value, + groupValue: controllerBool.value, activeColor: contentTheme.primary, onChanged: (val) => - val != null ? controller.value = val : null, + val != null ? controllerBool.value = val : null, ), ); }).toList(), @@ -306,9 +312,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> crossAxisAlignment: CrossAxisAlignment.start, children: [ const SectionTitle( - icon: Icons.calendar_today, - title: "Due To Date", - requiredField: true), + icon: Icons.calendar_today, title: "Due To Date", requiredField: true), MySpacing.height(6), GestureDetector( onTap: () => controller.pickDueDate(context), @@ -336,75 +340,35 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> ); } - Widget _buildPayeeAutocompleteField() { + Widget _buildPayeeField() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SectionTitle( - icon: Icons.person_outline, title: "Payee", requiredField: true), - const SizedBox(height: 6), - Autocomplete( - optionsBuilder: (textEditingValue) { - final query = textEditingValue.text.toLowerCase(); - return query.isEmpty - ? const Iterable.empty() - : controller.payees - .where((p) => p.toLowerCase().contains(query)); - }, - displayStringForOption: (option) => option, - fieldViewBuilder: - (context, fieldController, focusNode, onFieldSubmitted) { - // Avoid updating during build - WidgetsBinding.instance.addPostFrameCallback((_) { - if (fieldController.text != controller.selectedPayee.value) { - fieldController.text = controller.selectedPayee.value; - fieldController.selection = TextSelection.fromPosition( - TextPosition(offset: fieldController.text.length)); - } - }); - - return TextFormField( - controller: fieldController, - focusNode: focusNode, - decoration: InputDecoration( - hintText: "Type or select payee", - filled: true, - fillColor: Colors.grey.shade100, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), + const SectionTitle( + icon: Icons.person_outline, + title: "Payee", + requiredField: true, + ), + MySpacing.height(6), + GestureDetector( + onTap: _showPayeeSelector, + child: TileContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Obx(() => Text( + controller.selectedPayee.value?.name ?? "Select Payee", + style: const TextStyle(fontSize: 15), + overflow: TextOverflow.ellipsis, + )), ), - ), - validator: (v) => - v == null || v.trim().isEmpty ? "Please enter payee" : null, - onChanged: (val) => controller.selectedPayee.value = val, - ); - }, - onSelected: (selection) => controller.selectedPayee.value = selection, - optionsViewBuilder: (context, onSelected, options) => Material( - color: Colors.white, - elevation: 4, - borderRadius: BorderRadius.circular(8), - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200, minWidth: 300), - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: options.length, - itemBuilder: (_, index) => InkWell( - onTap: () => onSelected(options.elementAt(index)), - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 10, horizontal: 12), - child: Text(options.elementAt(index), - style: const TextStyle(fontSize: 14)), - ), - ), - ), + const Icon(Icons.arrow_drop_down, size: 22), + ], ), ), ), + const SizedBox(height: 6), ], ); } @@ -492,8 +456,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> return; } - final RenderBox button = - key.currentContext!.findRenderObject() as RenderBox; + final RenderBox button = key.currentContext!.findRenderObject() as RenderBox; final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final position = button.localToGlobal(Offset.zero, ancestor: overlay); @@ -507,8 +470,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> 0), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), items: options - .map( - (opt) => PopupMenuItem(value: opt, child: Text(getLabel(opt)))) + .map((opt) => PopupMenuItem(value: opt, child: Text(getLabel(opt)))) .toList(), ); @@ -523,7 +485,7 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> if (controller.selectedCategory.value == null) { return _showError("Please select a category"); } - if (controller.selectedPayee.value.isEmpty) { + if (controller.selectedPayee.value == null) { return _showError("Please select a payee"); } if (controller.selectedCurrency.value == null) { @@ -532,6 +494,25 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> return true; } + Future _showPayeeSelector() async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => EmployeeSelectionBottomSheet( + title: "Select Payee", + multipleSelection: false, + initiallySelected: controller.selectedPayee.value != null + ? [controller.selectedPayee.value!] + : [], + ), + ); + + if (result is EmployeeModel) { + controller.selectedPayee.value = result; + } + } + bool _showError(String msg) { showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error); return false; diff --git a/lib/model/finance/payment_request_filter_bottom_sheet.dart b/lib/model/finance/payment_request_filter_bottom_sheet.dart index d468da1..0845a38 100644 --- a/lib/model/finance/payment_request_filter_bottom_sheet.dart +++ b/lib/model/finance/payment_request_filter_bottom_sheet.dart @@ -8,7 +8,7 @@ import 'package:on_field_work/helpers/widgets/my_text_style.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/widgets/date_range_picker.dart'; import 'package:on_field_work/model/employees/employee_model.dart'; -import 'package:on_field_work/model/expense/employee_selector_for_filter_bottom_sheet.dart'; +import 'package:on_field_work/model/employees/multiple_select_bottomsheet.dart'; class PaymentRequestFilterBottomSheet extends StatefulWidget { final PaymentRequestController controller; @@ -441,9 +441,9 @@ class _PaymentRequestFilterBottomSheetState shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (context) => EmployeeSelectorBottomSheet( - selectedEmployees: selectedEmployees, - searchEmployees: (query) => searchEmployees(query, items), + builder: (context) => EmployeeSelectionBottomSheet( + initiallySelected: selectedEmployees.toList(), + multipleSelection: true, title: title, ), );