From 49ae815c40816318493889eb863aa7eb3fb55149 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 21 Sep 2026 22:47:44 +0330 Subject: [PATCH 1/2] fix product search suggestion behavior --- docs/product-search-field-behavior.md | 32 ++ .../lib/pages/business/quick_sales_page.dart | 188 ++++---- .../utils/product_search_overlay_policy.dart | 20 + .../invoice/product_combobox_widget.dart | 411 +++++++++++++----- .../product_search_overlay_policy_test.dart | 54 +++ 5 files changed, 512 insertions(+), 193 deletions(-) create mode 100644 docs/product-search-field-behavior.md create mode 100644 hesabixUI/hesabix_ui/lib/utils/product_search_overlay_policy.dart create mode 100644 hesabixUI/hesabix_ui/test/product_search_overlay_policy_test.dart diff --git a/docs/product-search-field-behavior.md b/docs/product-search-field-behavior.md new file mode 100644 index 00000000..a8f35db1 --- /dev/null +++ b/docs/product-search-field-behavior.md @@ -0,0 +1,32 @@ +# رفتار فیلد جست‌وجوی کالا + +این سند رفتار فیلد انتخاب کالا در «فروش سریع»، فاکتور و سایر فرم‌هایی را که از انتخاب‌گر مشترک کالا استفاده می‌کنند تعریف می‌کند. + +## نمایش پیشنهادها + +- فوکوس یا کلیک روی فیلد خالی نباید منوی شناور پیشنهادها را باز کند. +- منوی پیشنهادها پس از وارد شدن متن غیرخالی نمایش داده می‌شود. +- پاک کردن متن، منو و انتخاب هایلایت‌شده را می‌بندد. +- پاسخ جست‌وجوی قدیمی نباید جای نتایج متن جدید را بگیرد. + +کالاهای اخیر در بخش مستقل «محصولات اخیر» فروش سریع و در انتخاب‌گر پیشرفته کالا همچنان قابل دسترسی‌اند؛ فیلد خالی از آن‌ها برای ساخت منوی شناور استفاده نمی‌کند. + +## صفحه‌کلید در فروش سریع + +- کلیدهای بالا و پایین، ردیف هایلایت‌شده را در نتایج همان متن تغییر می‌دهند. +- اگر کاربر با بالا یا پایین داخل نتایج حرکت کرده باشد، Enter همان ردیف را به سبد اضافه می‌کند. +- اگر کاربر داخل نتایج حرکت نکرده باشد، Enter متن خود فیلد را جست‌وجو می‌کند. این قاعده برای بارکدخوان‌هایی که بارکد و Enter را سریع ارسال می‌کنند ضروری است. +- اگر نتایج نمایش‌داده‌شده متعلق به متن قبلی باشند، Enter آن‌ها را انتخاب نمی‌کند و متن فعلی مرجع جست‌وجو می‌ماند. +- کلیک روی یک پیشنهاد، همان پیشنهاد را مستقیماً انتخاب می‌کند. + +## دامنهٔ اعمال + +فروش سریع انتخاب‌گر اختصاصی خود را دارد. فاکتور جدید و ویرایش فاکتور از `ProductComboboxWidget` در جدول اقلام استفاده می‌کنند. این کامپوننت مشترک در فرم‌های انبار، گزارش‌ها، تولید، توزیع، CRM و گردش‌کار نیز استفاده می‌شود؛ بنابراین قاعدهٔ «عدم نمایش منو برای فیلد خالی» در همهٔ آن‌ها یکسان است. + +## سناریوهای پذیرش + +1. با باز شدن فروش سریع یا کلیک روی فیلد خالی، منویی نمایش داده نمی‌شود. +2. با تایپ بخشی از نام، فقط نتایج همان عبارت در منو نمایش داده می‌شوند. +3. با حرکت توسط بالا/پایین و زدن Enter، ردیف هایلایت‌شده مستقیماً به سبد اضافه می‌شود و پنجرهٔ «چند نتیجه پیدا شد» باز نمی‌شود. +4. با اسکن سریع بارکد و Enter، بارکد داخل فیلد مرجع انتخاب است و پیشنهاد هایلایت‌شده به‌صورت ناخواسته ثبت نمی‌شود. +5. در فاکتور و سایر مصرف‌کنندگان `ProductComboboxWidget`، کلیک روی فیلد خالی منوی ابتدای فهرست کالاها را باز نمی‌کند. diff --git a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart index 7a101924..a9ec0f66 100755 --- a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart @@ -21,6 +21,7 @@ import '../../utils/error_extractor.dart'; import '../../utils/snackbar_helper.dart'; import '../../utils/number_normalizer.dart' as number_utils; import '../../utils/number_formatters.dart'; +import '../../utils/product_search_overlay_policy.dart'; import '../../utils/invoice_global_discount_calculator.dart'; import '../../l10n/app_localizations.dart'; import '../../services/business_api_service.dart'; @@ -207,6 +208,8 @@ class _QuickSalesPageState extends State int _barcodeSuggestionsSkip = 0; String _barcodeSuggestionsQuery = ''; int _barcodeHighlightedIndex = -1; + bool _barcodeSuggestionNavigatedByKeyboard = false; + int _barcodeSuggestionRequestGeneration = 0; /// جلوگیری از دوبار اجرا شدن انتخاب با دابل‌کلیک روی همان ردیف اورلی پیشنهادها. DateTime? _lastOverlaySuggestionTapAt; @@ -2662,8 +2665,11 @@ class _QuickSalesPageState extends State void _onBarcodeFocusChanged() { if (!mounted || !_isDesktopLike) return; if (_barcodeFocus.hasFocus) { - _showBarcodeOverlay(); - _scheduleBarcodeSuggestionSearch(_barcodeController.text); + final query = _barcodeController.text; + if (shouldShowProductSearchSuggestions(query)) { + _showBarcodeOverlay(); + _scheduleBarcodeSuggestionSearch(query); + } } else { Future.delayed(const Duration(milliseconds: 160), () { if (!mounted || _barcodeFocus.hasFocus) return; @@ -2689,6 +2695,25 @@ class _QuickSalesPageState extends State _barcodeOverlayEntry?.remove(); _barcodeOverlayEntry = null; _barcodeHighlightedIndex = -1; + _barcodeSuggestionNavigatedByKeyboard = false; + } + + void _clearBarcodeSuggestions({bool removeOverlay = true}) { + _searchDebounce?.cancel(); + _barcodeSuggestionRequestGeneration++; + if (mounted) { + setState(() { + _barcodeSuggestions = const >[]; + _barcodeSuggestionsLoading = false; + _barcodeSuggestionsLoadingMore = false; + _barcodeSuggestionsHasMore = false; + _barcodeSuggestionsSkip = 0; + _barcodeSuggestionsQuery = ''; + _barcodeHighlightedIndex = -1; + _barcodeSuggestionNavigatedByKeyboard = false; + }); + } + if (removeOverlay) _removeBarcodeOverlay(); } double _barcodeOverlayHeight() { @@ -2828,82 +2853,41 @@ class _QuickSalesPageState extends State void _scheduleBarcodeSuggestionSearch(String rawQuery) { if (!_isDesktopLike) return; _searchDebounce?.cancel(); + final query = rawQuery.trim(); + _barcodeSuggestionNavigatedByKeyboard = false; + if (!shouldShowProductSearchSuggestions(query)) { + _clearBarcodeSuggestions(); + return; + } + _barcodeSuggestionRequestGeneration++; + if (mounted) { + setState(() { + _barcodeSuggestions = const >[]; + _barcodeSuggestionsLoading = true; + _barcodeSuggestionsLoadingMore = false; + _barcodeSuggestionsHasMore = false; + _barcodeSuggestionsSkip = 0; + _barcodeSuggestionsQuery = ''; + _barcodeHighlightedIndex = -1; + }); + _barcodeOverlayEntry?.markNeedsBuild(); + } _searchDebounce = Timer(const Duration(milliseconds: 220), () { - _loadBarcodeSuggestions(rawQuery.trim()); + _loadBarcodeSuggestions(query); }); } Future _loadBarcodeSuggestions(String query) async { if (!mounted || !_isDesktopLike) return; if (query.isEmpty) { - setState(() { - _barcodeSuggestionsLoading = true; - _barcodeSuggestionsLoadingMore = false; - _barcodeSuggestionsHasMore = false; - _barcodeSuggestionsSkip = 0; - _barcodeSuggestionsQuery = ''; - }); - _barcodeOverlayEntry?.markNeedsBuild(); - try { - final categoryIds = _getCategoryIdsForFilter(_selectedCategoryId); - var items = await _productService.fetchRecentFromSalesInvoices( - businessId: widget.businessId, - take: 10, - categoryIds: categoryIds.isNotEmpty ? categoryIds : null, - ); - if (items.length < 5) { - final more = await _productService.searchProducts( - businessId: widget.businessId, - searchQuery: null, - limit: 15, - skip: 0, - searchFields: const ['code', 'name', 'barcode'], - categoryIds: categoryIds.isNotEmpty ? categoryIds : null, - ); - final merged = List>.from(items); - final seen = {}; - for (final m in merged) { - final id = (m['id'] as num?)?.toInt(); - if (id != null) seen.add(id); - } - for (final m in more) { - final id = (m['id'] as num?)?.toInt(); - if (id == null || seen.contains(id)) continue; - seen.add(id); - merged.add(m); - if (merged.length >= 10) break; - } - items = merged; - } - await _hydrateProductMapsBatched(items); - if (!mounted) return; - setState(() { - _barcodeSuggestions = items; - _barcodeSuggestionsLoadingMore = false; - _barcodeSuggestionsHasMore = false; - _barcodeSuggestionsSkip = items.length; - _barcodeSuggestionsQuery = ''; - _barcodeHighlightedIndex = items.isEmpty ? -1 : 0; - }); - _ensureHighlightedSuggestionVisible(); - } catch (_) { - if (!mounted) return; - setState(() { - _barcodeSuggestions = const >[]; - _barcodeSuggestionsLoadingMore = false; - _barcodeSuggestionsHasMore = false; - _barcodeSuggestionsSkip = 0; - _barcodeHighlightedIndex = -1; - }); - } finally { - if (mounted) { - setState(() => _barcodeSuggestionsLoading = false); - _barcodeOverlayEntry?.markNeedsBuild(); - } - } + _clearBarcodeSuggestions(); return; } - setState(() => _barcodeSuggestionsLoading = true); + final requestGeneration = ++_barcodeSuggestionRequestGeneration; + setState(() { + _barcodeSuggestionsLoading = true; + _barcodeSuggestionNavigatedByKeyboard = false; + }); _barcodeOverlayEntry?.markNeedsBuild(); try { final categoryIds = _getCategoryIdsForFilter(_selectedCategoryId); @@ -2916,7 +2900,11 @@ class _QuickSalesPageState extends State categoryIds: categoryIds.isNotEmpty ? categoryIds : null, ); await _hydrateProductMapsBatched(products); - if (!mounted) return; + if (!mounted || + requestGeneration != _barcodeSuggestionRequestGeneration || + _barcodeController.text.trim() != query) { + return; + } setState(() { _barcodeSuggestions = products; _barcodeSuggestionsLoadingMore = false; @@ -2924,10 +2912,14 @@ class _QuickSalesPageState extends State _barcodeSuggestionsSkip = products.length; _barcodeSuggestionsQuery = query; _barcodeHighlightedIndex = products.isEmpty ? -1 : 0; + _barcodeSuggestionNavigatedByKeyboard = false; }); _ensureHighlightedSuggestionVisible(); } catch (_) { - if (!mounted) return; + if (!mounted || + requestGeneration != _barcodeSuggestionRequestGeneration) { + return; + } setState(() { _barcodeSuggestions = const >[]; _barcodeSuggestionsLoadingMore = false; @@ -2936,7 +2928,7 @@ class _QuickSalesPageState extends State _barcodeHighlightedIndex = -1; }); } finally { - if (mounted) { + if (mounted && requestGeneration == _barcodeSuggestionRequestGeneration) { setState(() => _barcodeSuggestionsLoading = false); _barcodeOverlayEntry?.markNeedsBuild(); } @@ -2950,20 +2942,26 @@ class _QuickSalesPageState extends State _barcodeSuggestionsQuery.isEmpty) { return; } + final requestGeneration = _barcodeSuggestionRequestGeneration; + final query = _barcodeSuggestionsQuery; setState(() => _barcodeSuggestionsLoadingMore = true); _barcodeOverlayEntry?.markNeedsBuild(); try { final categoryIds = _getCategoryIdsForFilter(_selectedCategoryId); final products = await _productService.searchProducts( businessId: widget.businessId, - searchQuery: _barcodeSuggestionsQuery, + searchQuery: query, limit: 20, skip: _barcodeSuggestionsSkip, searchFields: const ['code', 'barcode', 'name', 'general_barcodes'], categoryIds: categoryIds.isNotEmpty ? categoryIds : null, ); await _hydrateProductMapsBatched(products); - if (!mounted) return; + if (!mounted || + requestGeneration != _barcodeSuggestionRequestGeneration || + _barcodeController.text.trim() != query) { + return; + } setState(() { _barcodeSuggestions = [..._barcodeSuggestions, ...products]; _barcodeSuggestionsHasMore = products.length >= 20; @@ -2971,15 +2969,21 @@ class _QuickSalesPageState extends State _barcodeSuggestionsLoadingMore = false; }); } catch (_) { - if (!mounted) return; + if (!mounted || + requestGeneration != _barcodeSuggestionRequestGeneration) { + return; + } setState(() => _barcodeSuggestionsLoadingMore = false); } finally { - _barcodeOverlayEntry?.markNeedsBuild(); + if (requestGeneration == _barcodeSuggestionRequestGeneration) { + _barcodeOverlayEntry?.markNeedsBuild(); + } } } void _moveSuggestionHighlight(int delta) { if (_barcodeSuggestions.isEmpty) return; + _barcodeSuggestionNavigatedByKeyboard = true; var idx = _barcodeHighlightedIndex; if (idx < 0 || idx >= _barcodeSuggestions.length) { idx = delta > 0 ? 0 : _barcodeSuggestions.length - 1; @@ -3045,10 +3049,8 @@ class _QuickSalesPageState extends State await _saveRecentProduct(product); if (!mounted) return; _barcodeController.clear(); - _barcodeSuggestions = const >[]; - _barcodeHighlightedIndex = -1; + _clearBarcodeSuggestions(); _barcodeFocus.requestFocus(); - _barcodeOverlayEntry?.markNeedsBuild(); setState(() { _lastFailedSearchQuery = null; }); @@ -4568,7 +4570,10 @@ class _QuickSalesPageState extends State _lastFailedSearchQuery != null && _lastFailedSearchQuery!.isNotEmpty) IconButton( - icon: Icon(Icons.add_circle, color: SemanticColorResolver.positive(context)), + icon: Icon( + Icons.add_circle, + color: SemanticColorResolver.positive(context), + ), tooltip: 'افزودن کالای جدید: $_lastFailedSearchQuery', onPressed: () => _openAddProductDialog(presetName: _lastFailedSearchQuery), @@ -4587,7 +4592,18 @@ class _QuickSalesPageState extends State ], ), ), - onSubmitted: (value) => _searchByBarcode(value), + onSubmitted: (value) { + if (shouldCommitHighlightedProductSuggestion( + input: value, + loadedQuery: _barcodeSuggestionsQuery, + hasSuggestions: _barcodeSuggestions.isNotEmpty, + navigatedByKeyboard: _barcodeSuggestionNavigatedByKeyboard, + )) { + unawaited(_selectHighlightedBarcodeSuggestion()); + } else { + _searchByBarcode(value); + } + }, onChanged: (value) { if (_lastFailedSearchQuery != null && value != _lastFailedSearchQuery) { @@ -4596,10 +4612,14 @@ class _QuickSalesPageState extends State }); } if (_isDesktopLike) { - if (_barcodeOverlayEntry == null && _barcodeFocus.hasFocus) { - _showBarcodeOverlay(); + if (!shouldShowProductSearchSuggestions(value)) { + _clearBarcodeSuggestions(); + } else { + if (_barcodeOverlayEntry == null && _barcodeFocus.hasFocus) { + _showBarcodeOverlay(); + } + _scheduleBarcodeSuggestionSearch(value); } - _scheduleBarcodeSuggestionSearch(value); } }, textInputAction: TextInputAction.search, diff --git a/hesabixUI/hesabix_ui/lib/utils/product_search_overlay_policy.dart b/hesabixUI/hesabix_ui/lib/utils/product_search_overlay_policy.dart new file mode 100644 index 00000000..e147b426 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/utils/product_search_overlay_policy.dart @@ -0,0 +1,20 @@ +/// سیاست نمایش و ثبت پیشنهادهای جست‌وجوی کالا در فیلدهای دسکتاپ. +/// +/// منوی پیشنهاد فقط پس از ورود متن باز می‌شود. در فروش سریع نیز Enter تنها +/// زمانی پیشنهاد هایلایت‌شده را ثبت می‌کند که کاربر با صفحه‌کلید داخل همان +/// نتایج حرکت کرده باشد؛ در غیر این صورت متن فیلد مرجع جست‌وجو/بارکد است. +bool shouldShowProductSearchSuggestions(String input) => + input.trim().isNotEmpty; + +bool shouldCommitHighlightedProductSuggestion({ + required String input, + required String loadedQuery, + required bool hasSuggestions, + required bool navigatedByKeyboard, +}) { + final normalizedInput = input.trim(); + return normalizedInput.isNotEmpty && + hasSuggestions && + navigatedByKeyboard && + loadedQuery.trim() == normalizedInput; +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart index 2503e53c..9d19243d 100755 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/product_combobox_widget.dart @@ -11,6 +11,7 @@ import '../../widgets/product/category_tree_widget.dart'; import '../../utils/responsive_helper.dart'; import '../../utils/general_barcode_utils.dart'; import '../../utils/number_formatters.dart' show formatWithThousands; +import '../../utils/product_search_overlay_policy.dart'; /// متن خلاصه‌ی نمایشی کالا (کد - نام یا فقط نام) String _pickerProductDisplayLine(Map? p) { @@ -44,10 +45,13 @@ class _ProductSearchSuggestionTile extends StatelessWidget { final VoidCallback onTap; final bool dense; final bool highlighted; + /// اگر false باشد، بخش «خرید …» در خط خلاصه نمایش داده نمی‌شود (بدون دسترسی به قیمت خرید). final bool showPurchaseInMetrics; + /// در موبایل false تا بارکد عمومی در نتایج نشان داده نشود. final bool showGeneralBarcodeLine; + /// در overlay دسکتاپ true تا کلیک موس قبل از بسته شدن لایه ثبت شود. final bool commitOnPointerDown; @@ -77,14 +81,17 @@ class _ProductSearchSuggestionTile extends StatelessWidget { : 'موجودی انبار ${_pickerFormatQty(wh ?? acc ?? 0)} · حساب ${_pickerFormatQty(acc ?? wh ?? 0)}'; metricsLine = '${purchaseSeg}فروش $salesStr · $stockPart'; } - final barcodeLine = - showGeneralBarcodeLine ? productPrimaryBarcodeForSearchDisplay(item) : null; + final barcodeLine = showGeneralBarcodeLine + ? productPrimaryBarcodeForSearchDisplay(item) + : null; final padH = dense ? 10.0 : 14.0; final padV = dense ? 8.0 : 10.0; return Material( - color: highlighted ? cs.primary.withValues(alpha: 0.08) : Colors.transparent, + color: highlighted + ? cs.primary.withValues(alpha: 0.08) + : Colors.transparent, child: InkWell( onTap: commitOnPointerDown ? null : onTap, onTapDown: commitOnPointerDown ? (_) => onTap() : null, @@ -103,14 +110,21 @@ class _ProductSearchSuggestionTile extends StatelessWidget { PositionedDirectional( start: -30, top: dense ? 1 : 2, - child: Icon(Icons.inventory_2_outlined, size: dense ? 18 : 20, color: cs.primary), + child: Icon( + Icons.inventory_2_outlined, + size: dense ? 18 : 20, + color: cs.primary, + ), ), Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( title, - style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, height: 1.25), + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + height: 1.25, + ), maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.right, @@ -119,7 +133,10 @@ class _ProductSearchSuggestionTile extends StatelessWidget { const SizedBox(height: 2), Text( itemType, - style: theme.textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant, height: 1.2), + style: theme.textTheme.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + height: 1.2, + ), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.right, @@ -129,7 +146,10 @@ class _ProductSearchSuggestionTile extends StatelessWidget { const SizedBox(height: 4), Text( 'بارکد: $barcodeLine', - style: theme.textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant, height: 1.2), + style: theme.textTheme.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + height: 1.2, + ), maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.right, @@ -176,7 +196,11 @@ Widget _buildProductSuggestionsScrollArea({ child: Padding( padding: const EdgeInsets.all(24), child: Center( - child: SizedBox(width: dense ? 24 : 32, height: dense ? 24 : 32, child: CircularProgressIndicator(strokeWidth: 2)), + child: SizedBox( + width: dense ? 24 : 32, + height: dense ? 24 : 32, + child: CircularProgressIndicator(strokeWidth: 2), + ), ), ), ); @@ -190,11 +214,17 @@ Widget _buildProductSuggestionsScrollArea({ child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.inventory_2_outlined, size: dense ? 40 : 48, color: colorScheme.onSurface.withValues(alpha: 0.45)), + Icon( + Icons.inventory_2_outlined, + size: dense ? 40 : 48, + color: colorScheme.onSurface.withValues(alpha: 0.45), + ), const SizedBox(height: 12), Text( 'کالایی یافت نشد', - style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.72)), + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.72), + ), ), ], ), @@ -212,19 +242,32 @@ Widget _buildProductSuggestionsScrollArea({ child: ListView.separated( controller: scrollController, padding: EdgeInsets.symmetric(vertical: dense ? 4 : 6), - itemCount: state.items.length + - ((state.isLoadingMore || (state.isLoading && state.items.isNotEmpty)) ? 1 : 0), + itemCount: + state.items.length + + ((state.isLoadingMore || + (state.isLoading && state.items.isNotEmpty)) + ? 1 + : 0), separatorBuilder: (separatorContext, separatorIndex) { - if (separatorIndex >= state.items.length - 1) return const SizedBox.shrink(); - return Divider(height: 1, color: colorScheme.outline.withValues(alpha: 0.12)); + if (separatorIndex >= state.items.length - 1) + return const SizedBox.shrink(); + return Divider( + height: 1, + color: colorScheme.outline.withValues(alpha: 0.12), + ); }, itemBuilder: (context, index) { if (index == state.items.length && - (state.isLoadingMore || (state.isLoading && state.items.isNotEmpty))) { + (state.isLoadingMore || + (state.isLoading && state.items.isNotEmpty))) { return const Padding( padding: EdgeInsets.all(14), child: Center( - child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2)), + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), ), ); } @@ -301,7 +344,8 @@ class ProductComboboxWidget extends StatefulWidget { class _ProductComboboxWidgetState extends State { /// افزودن کالای جدید در API نیازمند `products.add` است؛ صرفاً وجود [AuthStore] کافی نیست. bool get _canCreateProducts => - widget.authStore != null && widget.authStore!.hasBusinessPermission('products', 'add'); + widget.authStore != null && + widget.authStore!.hasBusinessPermission('products', 'add'); final ProductService _service = ProductService(apiClient: ApiClient()); final CategoryService _categoryService = CategoryService(ApiClient()); @@ -325,6 +369,7 @@ class _ProductComboboxWidgetState extends State { bool _suppressFieldNotifications = false; bool _selectingFromOverlay = false; int _highlightedIndex = -1; + int _searchRequestGeneration = 0; // دسته‌بندی‌ها List _categoryTree = []; @@ -334,7 +379,10 @@ class _ProductComboboxWidgetState extends State { double _desktopOverlayHeight(_ProductPickerState state) { if (state.isLoading && state.items.isEmpty) return 120; if (!state.isLoading && state.items.isEmpty) return 100; - final extraRow = (state.isLoadingMore || (state.isLoading && state.items.isNotEmpty)) ? 1 : 0; + final extraRow = + (state.isLoadingMore || (state.isLoading && state.items.isNotEmpty)) + ? 1 + : 0; final rows = state.items.length + extraRow; const rowHeight = 84.0; final raw = (rows * rowHeight) + (state.isLoading ? 6 : 0) + 8; @@ -366,10 +414,14 @@ class _ProductComboboxWidgetState extends State { _loadingCategories = true; }); try { - final categories = await _categoryService.getCategoriesTree(businessId: widget.businessId); + final categories = await _categoryService.getCategoriesTree( + businessId: widget.businessId, + ); if (mounted) { setState(() { - _categoryTree = categories.map((e) => CategoryNode.fromMap(e)).toList(); + _categoryTree = categories + .map((e) => CategoryNode.fromMap(e)) + .toList(); _loadingCategories = false; }); } @@ -385,11 +437,11 @@ class _ProductComboboxWidgetState extends State { List _getCategoryIdsForFilter(int? categoryId) { if (categoryId == null) return []; - + // پیدا کردن node مربوط به دسته انتخاب شده final node = findCategoryNode(_categoryTree, categoryId); if (node == null) return [categoryId]; - + // جمع‌آوری تمام IDهای زیردسته‌ها return getAllCategoryIds(node); } @@ -400,48 +452,62 @@ class _ProductComboboxWidgetState extends State { debugPrint('[ProductCombobox] didUpdateWidget called'); debugPrint('[ProductCombobox] oldProduct: ${oldWidget.selectedProduct}'); debugPrint('[ProductCombobox] newProduct: ${widget.selectedProduct}'); - debugPrint('[ProductCombobox] current _searchCtrl.text: "${_searchCtrl.text}"'); - + debugPrint( + '[ProductCombobox] current _searchCtrl.text: "${_searchCtrl.text}"', + ); + // بررسی تغییر در id final oldId = oldWidget.selectedProduct?['id']; final newId = widget.selectedProduct?['id']; if (oldId != newId) { - debugPrint('[ProductCombobox] ID changed: $oldId -> $newId, calling _initializeSelectedProduct'); + debugPrint( + '[ProductCombobox] ID changed: $oldId -> $newId, calling _initializeSelectedProduct', + ); _initializeSelectedProduct(); return; } - + // اگر id تغییر نکرده اما selectedProduct تغییر کرده (مثلاً name یا code به‌روز شده) // باید نمایش را به‌روزرسانی کنیم final oldProduct = oldWidget.selectedProduct; final newProduct = widget.selectedProduct; - + // بررسی تغییر در null بودن if (oldProduct == null && newProduct != null) { - debugPrint('[ProductCombobox] Product changed from null to not null, calling _initializeSelectedProduct'); + debugPrint( + '[ProductCombobox] Product changed from null to not null, calling _initializeSelectedProduct', + ); _initializeSelectedProduct(); return; } if (oldProduct != null && newProduct == null) { - debugPrint('[ProductCombobox] Product changed from not null to null, calling _initializeSelectedProduct'); + debugPrint( + '[ProductCombobox] Product changed from not null to null, calling _initializeSelectedProduct', + ); _initializeSelectedProduct(); return; } - + // اگر هر دو null یا هر دو not null هستند، مقایسه فیلدها if (oldProduct != null && newProduct != null) { final oldCode = oldProduct['code']?.toString(); final newCode = newProduct['code']?.toString(); final oldName = oldProduct['name']?.toString(); final newName = newProduct['name']?.toString(); - - debugPrint('[ProductCombobox] Comparing fields - oldCode: "$oldCode", newCode: "$newCode", oldName: "$oldName", newName: "$newName"'); - + + debugPrint( + '[ProductCombobox] Comparing fields - oldCode: "$oldCode", newCode: "$newCode", oldName: "$oldName", newName: "$newName"', + ); + if (oldCode != newCode || oldName != newName) { - debugPrint('[ProductCombobox] Code or name changed, calling _initializeSelectedProduct'); + debugPrint( + '[ProductCombobox] Code or name changed, calling _initializeSelectedProduct', + ); _initializeSelectedProduct(); } else { - debugPrint('[ProductCombobox] No changes detected, skipping _initializeSelectedProduct'); + debugPrint( + '[ProductCombobox] No changes detected, skipping _initializeSelectedProduct', + ); } } } @@ -449,7 +515,9 @@ class _ProductComboboxWidgetState extends State { Future _initializeSelectedProduct() async { debugPrint('[ProductCombobox] _initializeSelectedProduct called'); if (widget.selectedProduct == null) { - debugPrint('[ProductCombobox] selectedProduct is null, clearing _searchCtrl'); + debugPrint( + '[ProductCombobox] selectedProduct is null, clearing _searchCtrl', + ); _setFieldQuiet(''); return; } @@ -458,11 +526,17 @@ class _ProductComboboxWidgetState extends State { final hasCode = widget.selectedProduct!['code'] != null; final hasName = widget.selectedProduct!['name'] != null; - debugPrint('[ProductCombobox] productId: $productId, hasCode: $hasCode, hasName: $hasName'); + debugPrint( + '[ProductCombobox] productId: $productId, hasCode: $hasCode, hasName: $hasName', + ); if (hasCode || hasName) { - final displayText = _pickerProductDisplayLine(Map.from(widget.selectedProduct!)); - debugPrint('[ProductCombobox] Setting _searchCtrl.text to: "$displayText"'); + final displayText = _pickerProductDisplayLine( + Map.from(widget.selectedProduct!), + ); + debugPrint( + '[ProductCombobox] Setting _searchCtrl.text to: "$displayText"', + ); _setFieldQuiet(displayText); if (mounted) setState(() {}); return; @@ -477,7 +551,9 @@ class _ProductComboboxWidgetState extends State { if (mounted && product.isNotEmpty) { _setFieldQuiet(_pickerProductDisplayLine(product)); if (mounted) { - final existsInList = _items.any((item) => (item['id'] as num?)?.toInt() == productId); + final existsInList = _items.any( + (item) => (item['id'] as num?)?.toInt() == productId, + ); if (!existsInList) { setState(() { _items = [product, ..._items]; @@ -509,12 +585,7 @@ class _ProductComboboxWidgetState extends State { void _onDesktopFocusChanged() { if (!mounted) return; if (ResponsiveHelper.isMobile(context)) return; - if (_fieldFocus.hasFocus) { - _showDesktopOverlay(); - if (_searchCtrl.text.trim().isEmpty) { - unawaited(_loadRecent()); - } - } else { + if (!_fieldFocus.hasFocus) { Future.delayed(const Duration(milliseconds: 180), () { if (!mounted || _fieldFocus.hasFocus || _selectingFromOverlay) return; _removeDesktopOverlay(); @@ -530,11 +601,17 @@ class _ProductComboboxWidgetState extends State { final overlayBox = overlay.context.findRenderObject(); final targetBox = context.findRenderObject(); if (overlayBox is! RenderBox || targetBox is! RenderBox) return null; - if (!overlayBox.hasSize || !targetBox.hasSize || !overlayBox.attached || !targetBox.attached) { + if (!overlayBox.hasSize || + !targetBox.hasSize || + !overlayBox.attached || + !targetBox.attached) { return null; } try { - final topLeft = targetBox.localToGlobal(Offset.zero, ancestor: overlayBox); + final topLeft = targetBox.localToGlobal( + Offset.zero, + ancestor: overlayBox, + ); return topLeft & targetBox.size; } catch (_) { return null; @@ -586,7 +663,9 @@ class _ProductComboboxWidgetState extends State { elevation: 14, surfaceTintColor: cs.surfaceTint, color: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), clipBehavior: Clip.antiAlias, shadowColor: Colors.black.withValues(alpha: 0.22), child: SizedBox( @@ -599,7 +678,8 @@ class _ProductComboboxWidgetState extends State { onProductSelected: _selectFromOverlay, highlightedIndex: _highlightedIndex, dense: true, - showPurchaseInMetrics: widget.authStore?.canViewPurchasePrice() ?? false, + showPurchaseInMetrics: + widget.authStore?.canViewPurchasePrice() ?? false, showGeneralBarcodeLine: true, commitOnPointerDown: true, ), @@ -670,12 +750,31 @@ class _ProductComboboxWidgetState extends State { } } else { final sel = widget.selectedProduct; - if (sel != null && trimmed != _pickerProductDisplayLine(Map.from(sel)).trim()) { + if (sel != null && + trimmed != + _pickerProductDisplayLine( + Map.from(sel), + ).trim()) { widget.onChanged(null); } } + if (!shouldShowProductSearchSuggestions(value)) { + _debounce?.cancel(); + _searchRequestGeneration++; + if (_loading || _loadingMore) { + setState(() { + _loading = false; + _loadingMore = false; + }); + _syncPickerState(); + } + _removeDesktopOverlay(); + return; + } _onQueryChanged(value); - if (_fieldFocus.hasFocus && !ResponsiveHelper.isMobile(context) && _desktopOverlayEntry == null) { + if (_fieldFocus.hasFocus && + !ResponsiveHelper.isMobile(context) && + _desktopOverlayEntry == null) { _showDesktopOverlay(); } } @@ -719,7 +818,9 @@ class _ProductComboboxWidgetState extends State { void _selectHighlightedOrFirst() { if (_items.isEmpty) return; - final idx = (_highlightedIndex >= 0 && _highlightedIndex < _items.length) ? _highlightedIndex : 0; + final idx = (_highlightedIndex >= 0 && _highlightedIndex < _items.length) + ? _highlightedIndex + : 0; _selectFromOverlay(_items[idx]); } @@ -780,7 +881,8 @@ class _ProductComboboxWidgetState extends State { void _onPickerListScroll() { if (!_scrollController.hasClients) return; - if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { if (_hasMore && !_loadingMore && !_loading) { _loadMore(); } @@ -798,6 +900,7 @@ class _ProductComboboxWidgetState extends State { } Future _loadRecent() async { + final requestGeneration = ++_searchRequestGeneration; if (_scrollController.hasClients) { _scrollController.jumpTo(0); } @@ -822,7 +925,7 @@ class _ProductComboboxWidgetState extends State { categoryIds: categoryIds.isNotEmpty ? categoryIds : null, includeInventory: true, ); - if (!mounted) return; + if (!mounted || requestGeneration != _searchRequestGeneration) return; setState(() { _items = items; _hasMore = items.length >= _pageSize; @@ -835,7 +938,7 @@ class _ProductComboboxWidgetState extends State { widget.onProductsLoaded?.call(items); } } catch (_) { - if (!mounted) return; + if (!mounted || requestGeneration != _searchRequestGeneration) return; setState(() { _items = const >[]; _hasMore = false; @@ -848,7 +951,7 @@ class _ProductComboboxWidgetState extends State { widget.onProductsLoaded?.call(const >[]); } } finally { - if (mounted) { + if (mounted && requestGeneration == _searchRequestGeneration) { setState(() => _loading = false); _syncPickerState(); } @@ -857,7 +960,24 @@ class _ProductComboboxWidgetState extends State { void _onQueryChanged(String q) { _debounce?.cancel(); - _debounce = Timer(const Duration(milliseconds: 300), () => _performSearch(q.trim())); + _searchRequestGeneration++; + final query = q.trim(); + if (mounted) { + setState(() { + _items = const >[]; + _loading = true; + _loadingMore = false; + _hasMore = false; + _currentSkip = 0; + _currentSearchQuery = query.isEmpty ? null : query; + _highlightedIndex = -1; + }); + _syncPickerState(); + } + _debounce = Timer( + const Duration(milliseconds: 300), + () => _performSearch(query), + ); } Future _performSearch(String q) async { @@ -865,6 +985,7 @@ class _ProductComboboxWidgetState extends State { await _loadRecent(); return; } + final requestGeneration = ++_searchRequestGeneration; // Reset scroll position when starting a new search if (_scrollController.hasClients) { _scrollController.jumpTo(0); @@ -890,7 +1011,11 @@ class _ProductComboboxWidgetState extends State { categoryIds: categoryIds.isNotEmpty ? categoryIds : null, includeInventory: true, ); - if (!mounted) return; + if (!mounted || + requestGeneration != _searchRequestGeneration || + _searchCtrl.text.trim() != q) { + return; + } setState(() { _items = items; _hasMore = items.length >= _pageSize; @@ -898,7 +1023,7 @@ class _ProductComboboxWidgetState extends State { }); _syncPickerState(); } catch (_) { - if (!mounted) return; + if (!mounted || requestGeneration != _searchRequestGeneration) return; setState(() { _items = const >[]; _hasMore = false; @@ -906,7 +1031,7 @@ class _ProductComboboxWidgetState extends State { }); _syncPickerState(); } finally { - if (mounted) { + if (mounted && requestGeneration == _searchRequestGeneration) { setState(() => _loading = false); _syncPickerState(); } @@ -915,21 +1040,27 @@ class _ProductComboboxWidgetState extends State { Future _loadMore() async { if (_loadingMore || !_hasMore) return; - + + final requestGeneration = _searchRequestGeneration; + final searchQuery = _currentSearchQuery; setState(() => _loadingMore = true); _syncPickerState(); try { final categoryIds = _getCategoryIdsForFilter(_selectedCategoryId); final items = await _service.searchProducts( businessId: widget.businessId, - searchQuery: _currentSearchQuery, + searchQuery: searchQuery, limit: _pageSize, skip: _currentSkip, searchFields: const ['code', 'name', 'barcode'], categoryIds: categoryIds.isNotEmpty ? categoryIds : null, includeInventory: true, ); - if (!mounted) return; + if (!mounted || + requestGeneration != _searchRequestGeneration || + searchQuery != _currentSearchQuery) { + return; + } setState(() { _items = [..._items, ...items]; _hasMore = items.length >= _pageSize; @@ -937,11 +1068,11 @@ class _ProductComboboxWidgetState extends State { }); _syncPickerState(); } catch (_) { - if (!mounted) return; + if (!mounted || requestGeneration != _searchRequestGeneration) return; setState(() => _hasMore = false); _syncPickerState(); } finally { - if (mounted) { + if (mounted && requestGeneration == _searchRequestGeneration) { setState(() => _loadingMore = false); _syncPickerState(); } @@ -964,7 +1095,9 @@ class _ProductComboboxWidgetState extends State { debugPrint('[ProductCombobox] Calling widget.onChanged with item: $item'); _setFieldQuiet(displayText); widget.onChanged(item); - debugPrint('[ProductCombobox] After onChanged, _searchCtrl.text is: "${_searchCtrl.text}"'); + debugPrint( + '[ProductCombobox] After onChanged, _searchCtrl.text is: "${_searchCtrl.text}"', + ); if (mounted) { debugPrint('[ProductCombobox] Calling setState to update UI'); setState(() {}); // به‌روزرسانی UI برای نمایش تغییرات @@ -1073,7 +1206,9 @@ class _ProductComboboxWidgetState extends State { selectedCategoryId: _selectedCategoryId, loadingCategories: _loadingCategories, onClose: () => Navigator.pop(ctx), - onAddNewProduct: _canCreateProducts ? (bottomSheetContext) => _addNewProduct(bottomSheetContext) : null, + onAddNewProduct: _canCreateProducts + ? (bottomSheetContext) => _addNewProduct(bottomSheetContext) + : null, onQueryChanged: _onQueryChanged, onCategorySelected: (categoryId) { setState(() { @@ -1086,7 +1221,8 @@ class _ProductComboboxWidgetState extends State { Navigator.pop(ctx); }, isMobile: isMobile, - showPurchaseInMetrics: widget.authStore?.canViewPurchasePrice() ?? false, + showPurchaseInMetrics: + widget.authStore?.canViewPurchasePrice() ?? false, showGeneralBarcodeLine: false, ); }, @@ -1107,7 +1243,9 @@ class _ProductComboboxWidgetState extends State { selectedCategoryId: _selectedCategoryId, loadingCategories: _loadingCategories, onClose: () => Navigator.pop(ctx), - onAddNewProduct: _canCreateProducts ? (dialogContext) => _addNewProduct(dialogContext) : null, + onAddNewProduct: _canCreateProducts + ? (dialogContext) => _addNewProduct(dialogContext) + : null, onQueryChanged: _onQueryChanged, onCategorySelected: (categoryId) { setState(() { @@ -1119,7 +1257,8 @@ class _ProductComboboxWidgetState extends State { _select(product); Navigator.pop(ctx); }, - showPurchaseInMetrics: widget.authStore?.canViewPurchasePrice() ?? false, + showPurchaseInMetrics: + widget.authStore?.canViewPurchasePrice() ?? false, showGeneralBarcodeLine: true, ); }, @@ -1137,7 +1276,9 @@ class _ProductComboboxWidgetState extends State { if (_searchCtrl.text.isNotEmpty) { displayTooltip = _searchCtrl.text; } else if (widget.selectedProduct != null) { - displayTooltip = _pickerProductDisplayLine(Map.from(widget.selectedProduct!)); + displayTooltip = _pickerProductDisplayLine( + Map.from(widget.selectedProduct!), + ); if (displayTooltip.isEmpty) displayTooltip = widget.hintText; } else { displayTooltip = widget.hintText; @@ -1153,7 +1294,9 @@ class _ProductComboboxWidgetState extends State { child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5), + ), borderRadius: BorderRadius.circular(8), color: colorScheme.surface, ), @@ -1161,7 +1304,11 @@ class _ProductComboboxWidgetState extends State { mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(Icons.inventory_2_outlined, color: colorScheme.primary, size: 18), + Icon( + Icons.inventory_2_outlined, + color: colorScheme.primary, + size: 18, + ), const SizedBox(width: 8), Expanded( child: Text( @@ -1180,16 +1327,27 @@ class _ProductComboboxWidgetState extends State { message: 'افزودن کالای جدید', child: IconButton( visualDensity: VisualDensity.compact, - icon: Icon(Icons.add, color: colorScheme.primary, size: 22), + icon: Icon( + Icons.add, + color: colorScheme.primary, + size: 22, + ), onPressed: _addNewProductFromField, padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), ), ), const SizedBox(width: 2), ], const SizedBox(width: 4), - Icon(Icons.arrow_drop_down, color: colorScheme.onSurface.withValues(alpha: 0.6), size: 20), + Icon( + Icons.arrow_drop_down, + color: colorScheme.onSurface.withValues(alpha: 0.6), + size: 20, + ), ], ), ), @@ -1213,7 +1371,9 @@ class _ProductComboboxWidgetState extends State { } return Tooltip( - message: displayTooltip.length > 120 ? '${displayTooltip.substring(0, 120)}…' : displayTooltip, + message: displayTooltip.length > 120 + ? '${displayTooltip.substring(0, 120)}…' + : displayTooltip, waitDuration: const Duration(milliseconds: 600), child: Focus( onKeyEvent: _onDesktopFieldKeyEvent, @@ -1233,14 +1393,29 @@ class _ProductComboboxWidgetState extends State { hintText: widget.hintText, labelText: widget.label, floatingLabelBehavior: FloatingLabelBehavior.auto, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 10, + ), prefixIcon: Padding( padding: const EdgeInsetsDirectional.only(end: 8), - child: Icon(Icons.inventory_2_outlined, color: colorScheme.primary, size: 20), + child: Icon( + Icons.inventory_2_outlined, + color: colorScheme.primary, + size: 20, + ), + ), + prefixIconConstraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + suffixIconConstraints: const BoxConstraints( + minHeight: 44, + minWidth: 140, ), - prefixIconConstraints: const BoxConstraints(minWidth: 40, minHeight: 40), - suffixIconConstraints: const BoxConstraints(minHeight: 44, minWidth: 140), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -1260,29 +1435,32 @@ class _ProductComboboxWidgetState extends State { icon: Icon(Icons.add, color: colorScheme.primary), onPressed: _addNewProductFromField, padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), ), IconButton( visualDensity: VisualDensity.compact, tooltip: 'انتخاب پیشرفته (دسته‌ها و افزودن)', - icon: Icon(Icons.manage_search_rounded, color: colorScheme.primary), + icon: Icon( + Icons.manage_search_rounded, + color: colorScheme.primary, + ), onPressed: () => _openPicker(), padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), ), ], ), ), onTap: () { - final allowOverlay = !ResponsiveHelper.isMobile(context); Future.microtask(() { if (!mounted) return; if (!_fieldFocus.hasFocus) _fieldFocus.requestFocus(); - if (!allowOverlay) return; - _showDesktopOverlay(); - if (_searchCtrl.text.trim().isEmpty) { - unawaited(_loadRecent()); - } }); }, onChanged: _onDesktopFieldChanged, @@ -1335,7 +1513,8 @@ class _ProductPickerBottomSheet extends StatefulWidget { }); @override - State<_ProductPickerBottomSheet> createState() => _ProductPickerBottomSheetState(); + State<_ProductPickerBottomSheet> createState() => + _ProductPickerBottomSheetState(); } class _ProductPickerBottomSheetState extends State<_ProductPickerBottomSheet> { @@ -1367,8 +1546,8 @@ class _ProductPickerBottomSheetState extends State<_ProductPickerBottomSheet> { Text( 'انتخاب دسته‌بندی', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), const Spacer(), IconButton( @@ -1413,7 +1592,9 @@ class _ProductPickerBottomSheetState extends State<_ProductPickerBottomSheet> { children: [ Text( widget.label, - style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), const Spacer(), if (widget.canAddNewProduct && widget.onAddNewProduct != null) @@ -1423,7 +1604,10 @@ class _ProductPickerBottomSheetState extends State<_ProductPickerBottomSheet> { tooltip: 'افزودن کالا/خدمت جدید', color: colorScheme.primary, ), - IconButton(onPressed: widget.onClose, icon: const Icon(Icons.close)), + IconButton( + onPressed: widget.onClose, + icon: const Icon(Icons.close), + ), ], ), const SizedBox(height: 12), @@ -1434,7 +1618,9 @@ class _ProductPickerBottomSheetState extends State<_ProductPickerBottomSheet> { children: [ Expanded( child: Chip( - label: Text(_getCategoryLabel(widget.selectedCategoryId!)), + label: Text( + _getCategoryLabel(widget.selectedCategoryId!), + ), onDeleted: () => widget.onCategorySelected(null), deleteIcon: const Icon(Icons.close, size: 18), avatar: const Icon(Icons.category, size: 18), @@ -1469,7 +1655,9 @@ class _ProductPickerBottomSheetState extends State<_ProductPickerBottomSheet> { decoration: InputDecoration( hintText: widget.hintText, prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), ), onChanged: widget.onQueryChanged, ), @@ -1576,7 +1764,9 @@ class _ProductPickerDialog extends StatelessWidget { decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, border: Border( - bottom: BorderSide(color: colorScheme.outline.withValues(alpha: 0.2)), + bottom: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.2), + ), ), ), child: Row( @@ -1595,10 +1785,7 @@ class _ProductPickerDialog extends StatelessWidget { tooltip: 'افزودن کالا/خدمت جدید', color: colorScheme.primary, ), - IconButton( - onPressed: onClose, - icon: const Icon(Icons.close), - ), + IconButton(onPressed: onClose, icon: const Icon(Icons.close)), ], ), ), @@ -1610,7 +1797,9 @@ class _ProductPickerDialog extends StatelessWidget { decoration: InputDecoration( hintText: hintText, prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), ), onChanged: onQueryChanged, ), @@ -1624,7 +1813,9 @@ class _ProductPickerDialog extends StatelessWidget { width: 280, decoration: BoxDecoration( border: Border( - right: BorderSide(color: colorScheme.outline.withValues(alpha: 0.2)), + right: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.2), + ), ), ), child: Column( @@ -1635,7 +1826,11 @@ class _ProductPickerDialog extends StatelessWidget { decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, border: Border( - bottom: BorderSide(color: colorScheme.outline.withValues(alpha: 0.2)), + bottom: BorderSide( + color: colorScheme.outline.withValues( + alpha: 0.2, + ), + ), ), ), child: Row( @@ -1690,5 +1885,3 @@ class _ProductPickerDialog extends StatelessWidget { ); } } - - diff --git a/hesabixUI/hesabix_ui/test/product_search_overlay_policy_test.dart b/hesabixUI/hesabix_ui/test/product_search_overlay_policy_test.dart new file mode 100644 index 00000000..93bb4d5b --- /dev/null +++ b/hesabixUI/hesabix_ui/test/product_search_overlay_policy_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hesabix_ui/utils/product_search_overlay_policy.dart'; + +void main() { + group('product search suggestion visibility', () { + test('does not show suggestions for an empty or whitespace-only field', () { + expect(shouldShowProductSearchSuggestions(''), isFalse); + expect(shouldShowProductSearchSuggestions(' '), isFalse); + }); + + test('shows suggestions after the user types a query', () { + expect(shouldShowProductSearchSuggestions('کالا'), isTrue); + expect(shouldShowProductSearchSuggestions(' 626015 '), isTrue); + }); + }); + + group('quick sales Enter behavior', () { + test('keeps the field as reference for a fast barcode scan', () { + expect( + shouldCommitHighlightedProductSuggestion( + input: '6260151234567', + loadedQuery: '6260151234567', + hasSuggestions: true, + navigatedByKeyboard: false, + ), + isFalse, + ); + }); + + test('commits the highlighted row after keyboard navigation', () { + expect( + shouldCommitHighlightedProductSuggestion( + input: 'شیر', + loadedQuery: 'شیر', + hasSuggestions: true, + navigatedByKeyboard: true, + ), + isTrue, + ); + }); + + test('rejects a highlighted row from stale search results', () { + expect( + shouldCommitHighlightedProductSuggestion( + input: 'شیر کم چرب', + loadedQuery: 'شیر', + hasSuggestions: true, + navigatedByKeyboard: true, + ), + isFalse, + ); + }); + }); +} -- 2.43.0 From 647250bbbb414ec3644b5f9224f6a5569de086a6 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 21 Sep 2026 23:40:40 +0330 Subject: [PATCH 2/2] fix quick sales search keyboard flow --- docs/product-search-field-behavior.md | 13 ++++- .../lib/pages/business/quick_sales_page.dart | 41 +++++++++----- .../utils/product_search_overlay_policy.dart | 36 +++++++++--- .../product_search_overlay_policy_test.dart | 56 +++++++++++++++---- 4 files changed, 109 insertions(+), 37 deletions(-) diff --git a/docs/product-search-field-behavior.md b/docs/product-search-field-behavior.md index a8f35db1..4e79b071 100644 --- a/docs/product-search-field-behavior.md +++ b/docs/product-search-field-behavior.md @@ -14,10 +14,14 @@ ## صفحه‌کلید در فروش سریع - کلیدهای بالا و پایین، ردیف هایلایت‌شده را در نتایج همان متن تغییر می‌دهند. +- با بارگذاری نتایج، هیچ ردیفی صرفاً به دلیل قرارگرفتن در ابتدای فهرست انتخاب‌شده محسوب نمی‌شود؛ اولین فشار جهت پایین، ردیف اول را مشخص می‌کند. - اگر کاربر با بالا یا پایین داخل نتایج حرکت کرده باشد، Enter همان ردیف را به سبد اضافه می‌کند. -- اگر کاربر داخل نتایج حرکت نکرده باشد، Enter متن خود فیلد را جست‌وجو می‌کند. این قاعده برای بارکدخوان‌هایی که بارکد و Enter را سریع ارسال می‌کنند ضروری است. +- اگر فقط یک پیشنهاد برای متن فعلی باقی مانده باشد، Enter همان کالا را بدون نیاز به حرکت با بالا یا پایین به سبد اضافه می‌کند. +- اگر چند پیشنهاد نمایش داده شده باشد و کاربر داخل آن‌ها حرکت نکرده باشد، Enter هیچ کالایی اضافه نمی‌کند و دیالوگ چندنتیجه‌ای نیز باز نمی‌شود؛ کاربر باید نتیجه را با جهت‌نما و Enter یا با موس انتخاب کند. +- اگر پیشنهادهای متن هنوز بارگذاری نشده باشند، Enter متن خود فیلد را جست‌وجو می‌کند. این قاعده برای بارکدخوان‌هایی که بارکد و Enter را سریع ارسال می‌کنند ضروری است. این مسیر نیز در صورت یافتن چند کالا دیالوگ باز نمی‌کند و منتظر انتخاب از منوی پیشنهادها می‌ماند. - اگر نتایج نمایش‌داده‌شده متعلق به متن قبلی باشند، Enter آن‌ها را انتخاب نمی‌کند و متن فعلی مرجع جست‌وجو می‌ماند. - کلیک روی یک پیشنهاد، همان پیشنهاد را مستقیماً انتخاب می‌کند. +- پس از اضافه‌شدن کالا از مسیر جست‌وجوی مستقیم، فیلد در فریم بعدی دوباره فوکوس می‌گیرد تا اسکن بعدی بدون کلیک انجام شود. ## دامنهٔ اعمال @@ -28,5 +32,8 @@ 1. با باز شدن فروش سریع یا کلیک روی فیلد خالی، منویی نمایش داده نمی‌شود. 2. با تایپ بخشی از نام، فقط نتایج همان عبارت در منو نمایش داده می‌شوند. 3. با حرکت توسط بالا/پایین و زدن Enter، ردیف هایلایت‌شده مستقیماً به سبد اضافه می‌شود و پنجرهٔ «چند نتیجه پیدا شد» باز نمی‌شود. -4. با اسکن سریع بارکد و Enter، بارکد داخل فیلد مرجع انتخاب است و پیشنهاد هایلایت‌شده به‌صورت ناخواسته ثبت نمی‌شود. -5. در فاکتور و سایر مصرف‌کنندگان `ProductComboboxWidget`، کلیک روی فیلد خالی منوی ابتدای فهرست کالاها را باز نمی‌کند. +4. اگر فقط یک پیشنهاد باقی مانده باشد، Enter همان کالا را مستقیماً اضافه می‌کند. +5. در صورت وجود چند پیشنهاد و نبود حرکت با جهت‌نما، Enter نه کالایی اضافه می‌کند و نه دیالوگ چندنتیجه‌ای باز می‌کند. +6. با اسکن سریع بارکد و Enter، بارکد داخل فیلد مرجع انتخاب است و پیشنهاد هایلایت‌شده به‌صورت ناخواسته ثبت نمی‌شود. +7. پس از اضافه‌شدن کالا با بارکد یا کد، فیلد جست‌وجو خالی و آمادهٔ اسکن بعدی باقی می‌ماند. +8. در فاکتور و سایر مصرف‌کنندگان `ProductComboboxWidget`، کلیک روی فیلد خالی منوی ابتدای فهرست کالاها را باز نمی‌کند. diff --git a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart index a9ec0f66..59fc99cb 100755 --- a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart @@ -1401,7 +1401,10 @@ class _QuickSalesPageState extends State await _searchByBarcode(trimmed); } - Future _searchByBarcode(String code) async { + Future _searchByBarcode( + String code, { + bool showMultipleResultsDialog = true, + }) async { if (code.trim().isEmpty || _barcodeSearching) return; setState(() => _barcodeSearching = true); @@ -1418,6 +1421,7 @@ class _QuickSalesPageState extends State final items = instanceData['items'] as List?; if (multipleResults && items != null && items.isNotEmpty) { + if (!showMultipleResultsDialog) return; // اگر چند نتیجه پیدا شد، دیالوگ انتخاب نمایش بده if (!mounted) return; final selected = await showDialog>( @@ -1450,7 +1454,6 @@ class _QuickSalesPageState extends State ); await _saveRecentProduct(product); _barcodeController.clear(); - _barcodeFocus.requestFocus(); // پاک کردن جستجوی ناموفق قبلی if (mounted) { setState(() { @@ -1500,7 +1503,6 @@ class _QuickSalesPageState extends State ); await _saveRecentProduct(product); _barcodeController.clear(); - _barcodeFocus.requestFocus(); // پاک کردن جستجوی ناموفق قبلی if (mounted) { setState(() { @@ -1547,6 +1549,7 @@ class _QuickSalesPageState extends State // اگر چند نتیجه پیدا شد، دیالوگ انتخاب نمایش بده if (products.length > 1) { + if (!showMultipleResultsDialog) return; if (!mounted) return; final selected = await showDialog>( context: context, @@ -1560,7 +1563,6 @@ class _QuickSalesPageState extends State await _addToCart(selected, skipHydrate: true); await _saveRecentProduct(selected); _barcodeController.clear(); - _barcodeFocus.requestFocus(); // پاک کردن جستجوی ناموفق قبلی if (mounted) { setState(() { @@ -1590,7 +1592,6 @@ class _QuickSalesPageState extends State await _addToCart(product, skipHydrate: true); await _saveRecentProduct(product); _barcodeController.clear(); - _barcodeFocus.requestFocus(); // پاک کردن جستجوی ناموفق قبلی if (mounted) { setState(() { @@ -1613,7 +1614,12 @@ class _QuickSalesPageState extends State ); } } finally { - if (mounted) setState(() => _barcodeSearching = false); + if (mounted) { + setState(() => _barcodeSearching = false); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _barcodeFocus.requestFocus(); + }); + } } } @@ -2911,7 +2917,7 @@ class _QuickSalesPageState extends State _barcodeSuggestionsHasMore = products.length >= 20; _barcodeSuggestionsSkip = products.length; _barcodeSuggestionsQuery = query; - _barcodeHighlightedIndex = products.isEmpty ? -1 : 0; + _barcodeHighlightedIndex = -1; _barcodeSuggestionNavigatedByKeyboard = false; }); _ensureHighlightedSuggestionVisible(); @@ -4593,15 +4599,24 @@ class _QuickSalesPageState extends State ), ), onSubmitted: (value) { - if (shouldCommitHighlightedProductSuggestion( + final action = resolveQuickSalesProductSearchSubmitAction( input: value, loadedQuery: _barcodeSuggestionsQuery, - hasSuggestions: _barcodeSuggestions.isNotEmpty, + suggestionCount: _barcodeSuggestions.length, + hasMoreSuggestions: _barcodeSuggestionsHasMore, navigatedByKeyboard: _barcodeSuggestionNavigatedByKeyboard, - )) { - unawaited(_selectHighlightedBarcodeSuggestion()); - } else { - _searchByBarcode(value); + ); + switch (action) { + case QuickSalesProductSearchSubmitAction.selectSuggestion: + unawaited(_selectHighlightedBarcodeSuggestion()); + break; + case QuickSalesProductSearchSubmitAction.searchField: + unawaited( + _searchByBarcode(value, showMultipleResultsDialog: false), + ); + break; + case QuickSalesProductSearchSubmitAction.waitForSuggestionSelection: + break; } }, onChanged: (value) { diff --git a/hesabixUI/hesabix_ui/lib/utils/product_search_overlay_policy.dart b/hesabixUI/hesabix_ui/lib/utils/product_search_overlay_policy.dart index e147b426..dce15ada 100644 --- a/hesabixUI/hesabix_ui/lib/utils/product_search_overlay_policy.dart +++ b/hesabixUI/hesabix_ui/lib/utils/product_search_overlay_policy.dart @@ -1,20 +1,38 @@ /// سیاست نمایش و ثبت پیشنهادهای جست‌وجوی کالا در فیلدهای دسکتاپ. /// -/// منوی پیشنهاد فقط پس از ورود متن باز می‌شود. در فروش سریع نیز Enter تنها -/// زمانی پیشنهاد هایلایت‌شده را ثبت می‌کند که کاربر با صفحه‌کلید داخل همان -/// نتایج حرکت کرده باشد؛ در غیر این صورت متن فیلد مرجع جست‌وجو/بارکد است. +/// منوی پیشنهاد فقط پس از ورود متن باز می‌شود. در فروش سریع، Enter پیشنهاد +/// انتخاب‌شده با صفحه‌کلید یا تنها نتیجهٔ موجود را ثبت می‌کند. اگر نتایج جاری +/// متعدد باشند، تا انتخاب صریح کاربر هیچ عملی انجام نمی‌شود. bool shouldShowProductSearchSuggestions(String input) => input.trim().isNotEmpty; -bool shouldCommitHighlightedProductSuggestion({ +enum QuickSalesProductSearchSubmitAction { + selectSuggestion, + searchField, + waitForSuggestionSelection, +} + +QuickSalesProductSearchSubmitAction resolveQuickSalesProductSearchSubmitAction({ required String input, required String loadedQuery, - required bool hasSuggestions, + required int suggestionCount, + required bool hasMoreSuggestions, required bool navigatedByKeyboard, }) { final normalizedInput = input.trim(); - return normalizedInput.isNotEmpty && - hasSuggestions && - navigatedByKeyboard && - loadedQuery.trim() == normalizedInput; + if (normalizedInput.isEmpty) { + return QuickSalesProductSearchSubmitAction.waitForSuggestionSelection; + } + + final suggestionsBelongToInput = + suggestionCount > 0 && loadedQuery.trim() == normalizedInput; + if (!suggestionsBelongToInput) { + return QuickSalesProductSearchSubmitAction.searchField; + } + + if (navigatedByKeyboard || (suggestionCount == 1 && !hasMoreSuggestions)) { + return QuickSalesProductSearchSubmitAction.selectSuggestion; + } + + return QuickSalesProductSearchSubmitAction.waitForSuggestionSelection; } diff --git a/hesabixUI/hesabix_ui/test/product_search_overlay_policy_test.dart b/hesabixUI/hesabix_ui/test/product_search_overlay_policy_test.dart index 93bb4d5b..68227195 100644 --- a/hesabixUI/hesabix_ui/test/product_search_overlay_policy_test.dart +++ b/hesabixUI/hesabix_ui/test/product_search_overlay_policy_test.dart @@ -15,39 +15,71 @@ void main() { }); group('quick sales Enter behavior', () { - test('keeps the field as reference for a fast barcode scan', () { + test('searches the field when fast barcode suggestions are not loaded', () { expect( - shouldCommitHighlightedProductSuggestion( + resolveQuickSalesProductSearchSubmitAction( input: '6260151234567', - loadedQuery: '6260151234567', - hasSuggestions: true, + loadedQuery: '', + suggestionCount: 0, + hasMoreSuggestions: false, navigatedByKeyboard: false, ), - isFalse, + QuickSalesProductSearchSubmitAction.searchField, ); }); test('commits the highlighted row after keyboard navigation', () { expect( - shouldCommitHighlightedProductSuggestion( + resolveQuickSalesProductSearchSubmitAction( input: 'شیر', loadedQuery: 'شیر', - hasSuggestions: true, + suggestionCount: 4, + hasMoreSuggestions: false, navigatedByKeyboard: true, ), - isTrue, + QuickSalesProductSearchSubmitAction.selectSuggestion, ); }); - test('rejects a highlighted row from stale search results', () { + test('commits the only current suggestion without keyboard navigation', () { expect( - shouldCommitHighlightedProductSuggestion( + resolveQuickSalesProductSearchSubmitAction( + input: 'شیر کم چرب', + loadedQuery: 'شیر کم چرب', + suggestionCount: 1, + hasMoreSuggestions: false, + navigatedByKeyboard: false, + ), + QuickSalesProductSearchSubmitAction.selectSuggestion, + ); + }); + + test( + 'waits for an explicit selection when several results are visible', + () { + expect( + resolveQuickSalesProductSearchSubmitAction( + input: 'شیر', + loadedQuery: 'شیر', + suggestionCount: 4, + hasMoreSuggestions: false, + navigatedByKeyboard: false, + ), + QuickSalesProductSearchSubmitAction.waitForSuggestionSelection, + ); + }, + ); + + test('does not select suggestions from stale search results', () { + expect( + resolveQuickSalesProductSearchSubmitAction( input: 'شیر کم چرب', loadedQuery: 'شیر', - hasSuggestions: true, + suggestionCount: 4, + hasMoreSuggestions: false, navigatedByKeyboard: true, ), - isFalse, + QuickSalesProductSearchSubmitAction.searchField, ); }); }); -- 2.43.0