diff --git a/docs/product-search-field-behavior.md b/docs/product-search-field-behavior.md new file mode 100644 index 00000000..4e79b071 --- /dev/null +++ b/docs/product-search-field-behavior.md @@ -0,0 +1,39 @@ +# رفتار فیلد جست‌وجوی کالا + +این سند رفتار فیلد انتخاب کالا در «فروش سریع»، فاکتور و سایر فرم‌هایی را که از انتخاب‌گر مشترک کالا استفاده می‌کنند تعریف می‌کند. + +## نمایش پیشنهادها + +- فوکوس یا کلیک روی فیلد خالی نباید منوی شناور پیشنهادها را باز کند. +- منوی پیشنهادها پس از وارد شدن متن غیرخالی نمایش داده می‌شود. +- پاک کردن متن، منو و انتخاب هایلایت‌شده را می‌بندد. +- پاسخ جست‌وجوی قدیمی نباید جای نتایج متن جدید را بگیرد. + +کالاهای اخیر در بخش مستقل «محصولات اخیر» فروش سریع و در انتخاب‌گر پیشرفته کالا همچنان قابل دسترسی‌اند؛ فیلد خالی از آن‌ها برای ساخت منوی شناور استفاده نمی‌کند. + +## صفحه‌کلید در فروش سریع + +- کلیدهای بالا و پایین، ردیف هایلایت‌شده را در نتایج همان متن تغییر می‌دهند. +- با بارگذاری نتایج، هیچ ردیفی صرفاً به دلیل قرارگرفتن در ابتدای فهرست انتخاب‌شده محسوب نمی‌شود؛ اولین فشار جهت پایین، ردیف اول را مشخص می‌کند. +- اگر کاربر با بالا یا پایین داخل نتایج حرکت کرده باشد، Enter همان ردیف را به سبد اضافه می‌کند. +- اگر فقط یک پیشنهاد برای متن فعلی باقی مانده باشد، Enter همان کالا را بدون نیاز به حرکت با بالا یا پایین به سبد اضافه می‌کند. +- اگر چند پیشنهاد نمایش داده شده باشد و کاربر داخل آن‌ها حرکت نکرده باشد، Enter هیچ کالایی اضافه نمی‌کند و دیالوگ چندنتیجه‌ای نیز باز نمی‌شود؛ کاربر باید نتیجه را با جهت‌نما و Enter یا با موس انتخاب کند. +- اگر پیشنهادهای متن هنوز بارگذاری نشده باشند، Enter متن خود فیلد را جست‌وجو می‌کند. این قاعده برای بارکدخوان‌هایی که بارکد و Enter را سریع ارسال می‌کنند ضروری است. این مسیر نیز در صورت یافتن چند کالا دیالوگ باز نمی‌کند و منتظر انتخاب از منوی پیشنهادها می‌ماند. +- اگر نتایج نمایش‌داده‌شده متعلق به متن قبلی باشند، Enter آن‌ها را انتخاب نمی‌کند و متن فعلی مرجع جست‌وجو می‌ماند. +- کلیک روی یک پیشنهاد، همان پیشنهاد را مستقیماً انتخاب می‌کند. +- پس از اضافه‌شدن کالا از مسیر جست‌وجوی مستقیم، فیلد در فریم بعدی دوباره فوکوس می‌گیرد تا اسکن بعدی بدون کلیک انجام شود. + +## دامنهٔ اعمال + +فروش سریع انتخاب‌گر اختصاصی خود را دارد. فاکتور جدید و ویرایش فاکتور از `ProductComboboxWidget` در جدول اقلام استفاده می‌کنند. این کامپوننت مشترک در فرم‌های انبار، گزارش‌ها، تولید، توزیع، CRM و گردش‌کار نیز استفاده می‌شود؛ بنابراین قاعدهٔ «عدم نمایش منو برای فیلد خالی» در همهٔ آن‌ها یکسان است. + +## سناریوهای پذیرش + +1. با باز شدن فروش سریع یا کلیک روی فیلد خالی، منویی نمایش داده نمی‌شود. +2. با تایپ بخشی از نام، فقط نتایج همان عبارت در منو نمایش داده می‌شوند. +3. با حرکت توسط بالا/پایین و زدن Enter، ردیف هایلایت‌شده مستقیماً به سبد اضافه می‌شود و پنجرهٔ «چند نتیجه پیدا شد» باز نمی‌شود. +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 7a101924..59fc99cb 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; @@ -1398,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); @@ -1415,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>( @@ -1447,7 +1454,6 @@ class _QuickSalesPageState extends State ); await _saveRecentProduct(product); _barcodeController.clear(); - _barcodeFocus.requestFocus(); // پاک کردن جستجوی ناموفق قبلی if (mounted) { setState(() { @@ -1497,7 +1503,6 @@ class _QuickSalesPageState extends State ); await _saveRecentProduct(product); _barcodeController.clear(); - _barcodeFocus.requestFocus(); // پاک کردن جستجوی ناموفق قبلی if (mounted) { setState(() { @@ -1544,6 +1549,7 @@ class _QuickSalesPageState extends State // اگر چند نتیجه پیدا شد، دیالوگ انتخاب نمایش بده if (products.length > 1) { + if (!showMultipleResultsDialog) return; if (!mounted) return; final selected = await showDialog>( context: context, @@ -1557,7 +1563,6 @@ class _QuickSalesPageState extends State await _addToCart(selected, skipHydrate: true); await _saveRecentProduct(selected); _barcodeController.clear(); - _barcodeFocus.requestFocus(); // پاک کردن جستجوی ناموفق قبلی if (mounted) { setState(() { @@ -1587,7 +1592,6 @@ class _QuickSalesPageState extends State await _addToCart(product, skipHydrate: true); await _saveRecentProduct(product); _barcodeController.clear(); - _barcodeFocus.requestFocus(); // پاک کردن جستجوی ناموفق قبلی if (mounted) { setState(() { @@ -1610,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(); + }); + } } } @@ -2662,8 +2671,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 +2701,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 +2859,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,18 +2906,26 @@ 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; _barcodeSuggestionsHasMore = products.length >= 20; _barcodeSuggestionsSkip = products.length; _barcodeSuggestionsQuery = query; - _barcodeHighlightedIndex = products.isEmpty ? -1 : 0; + _barcodeHighlightedIndex = -1; + _barcodeSuggestionNavigatedByKeyboard = false; }); _ensureHighlightedSuggestionVisible(); } catch (_) { - if (!mounted) return; + if (!mounted || + requestGeneration != _barcodeSuggestionRequestGeneration) { + return; + } setState(() { _barcodeSuggestions = const >[]; _barcodeSuggestionsLoadingMore = false; @@ -2936,7 +2934,7 @@ class _QuickSalesPageState extends State _barcodeHighlightedIndex = -1; }); } finally { - if (mounted) { + if (mounted && requestGeneration == _barcodeSuggestionRequestGeneration) { setState(() => _barcodeSuggestionsLoading = false); _barcodeOverlayEntry?.markNeedsBuild(); } @@ -2950,20 +2948,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 +2975,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 +3055,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 +4576,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 +4598,27 @@ class _QuickSalesPageState extends State ], ), ), - onSubmitted: (value) => _searchByBarcode(value), + onSubmitted: (value) { + final action = resolveQuickSalesProductSearchSubmitAction( + input: value, + loadedQuery: _barcodeSuggestionsQuery, + suggestionCount: _barcodeSuggestions.length, + hasMoreSuggestions: _barcodeSuggestionsHasMore, + navigatedByKeyboard: _barcodeSuggestionNavigatedByKeyboard, + ); + switch (action) { + case QuickSalesProductSearchSubmitAction.selectSuggestion: + unawaited(_selectHighlightedBarcodeSuggestion()); + break; + case QuickSalesProductSearchSubmitAction.searchField: + unawaited( + _searchByBarcode(value, showMultipleResultsDialog: false), + ); + break; + case QuickSalesProductSearchSubmitAction.waitForSuggestionSelection: + break; + } + }, onChanged: (value) { if (_lastFailedSearchQuery != null && value != _lastFailedSearchQuery) { @@ -4596,10 +4627,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..dce15ada --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/utils/product_search_overlay_policy.dart @@ -0,0 +1,38 @@ +/// سیاست نمایش و ثبت پیشنهادهای جست‌وجوی کالا در فیلدهای دسکتاپ. +/// +/// منوی پیشنهاد فقط پس از ورود متن باز می‌شود. در فروش سریع، Enter پیشنهاد +/// انتخاب‌شده با صفحه‌کلید یا تنها نتیجهٔ موجود را ثبت می‌کند. اگر نتایج جاری +/// متعدد باشند، تا انتخاب صریح کاربر هیچ عملی انجام نمی‌شود. +bool shouldShowProductSearchSuggestions(String input) => + input.trim().isNotEmpty; + +enum QuickSalesProductSearchSubmitAction { + selectSuggestion, + searchField, + waitForSuggestionSelection, +} + +QuickSalesProductSearchSubmitAction resolveQuickSalesProductSearchSubmitAction({ + required String input, + required String loadedQuery, + required int suggestionCount, + required bool hasMoreSuggestions, + required bool navigatedByKeyboard, +}) { + final normalizedInput = input.trim(); + 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/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..68227195 --- /dev/null +++ b/hesabixUI/hesabix_ui/test/product_search_overlay_policy_test.dart @@ -0,0 +1,86 @@ +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('searches the field when fast barcode suggestions are not loaded', () { + expect( + resolveQuickSalesProductSearchSubmitAction( + input: '6260151234567', + loadedQuery: '', + suggestionCount: 0, + hasMoreSuggestions: false, + navigatedByKeyboard: false, + ), + QuickSalesProductSearchSubmitAction.searchField, + ); + }); + + test('commits the highlighted row after keyboard navigation', () { + expect( + resolveQuickSalesProductSearchSubmitAction( + input: 'شیر', + loadedQuery: 'شیر', + suggestionCount: 4, + hasMoreSuggestions: false, + navigatedByKeyboard: true, + ), + QuickSalesProductSearchSubmitAction.selectSuggestion, + ); + }); + + test('commits the only current suggestion without keyboard navigation', () { + expect( + 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: 'شیر', + suggestionCount: 4, + hasMoreSuggestions: false, + navigatedByKeyboard: true, + ), + QuickSalesProductSearchSubmitAction.searchField, + ); + }); + }); +}