codex/quick-sales-product-search #4
5 changed files with 590 additions and 199 deletions
39
docs/product-search-field-behavior.md
Normal file
39
docs/product-search-field-behavior.md
Normal file
|
|
@ -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`، کلیک روی فیلد خالی منوی ابتدای فهرست کالاها را باز نمیکند.
|
||||
|
|
@ -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<QuickSalesPage>
|
|||
int _barcodeSuggestionsSkip = 0;
|
||||
String _barcodeSuggestionsQuery = '';
|
||||
int _barcodeHighlightedIndex = -1;
|
||||
bool _barcodeSuggestionNavigatedByKeyboard = false;
|
||||
int _barcodeSuggestionRequestGeneration = 0;
|
||||
|
||||
/// جلوگیری از دوبار اجرا شدن انتخاب با دابلکلیک روی همان ردیف اورلی پیشنهادها.
|
||||
DateTime? _lastOverlaySuggestionTapAt;
|
||||
|
|
@ -1398,7 +1401,10 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
|||
await _searchByBarcode(trimmed);
|
||||
}
|
||||
|
||||
Future<void> _searchByBarcode(String code) async {
|
||||
Future<void> _searchByBarcode(
|
||||
String code, {
|
||||
bool showMultipleResultsDialog = true,
|
||||
}) async {
|
||||
if (code.trim().isEmpty || _barcodeSearching) return;
|
||||
|
||||
setState(() => _barcodeSearching = true);
|
||||
|
|
@ -1415,6 +1421,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
|||
final items = instanceData['items'] as List?;
|
||||
|
||||
if (multipleResults && items != null && items.isNotEmpty) {
|
||||
if (!showMultipleResultsDialog) return;
|
||||
// اگر چند نتیجه پیدا شد، دیالوگ انتخاب نمایش بده
|
||||
if (!mounted) return;
|
||||
final selected = await showDialog<Map<String, dynamic>>(
|
||||
|
|
@ -1447,7 +1454,6 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
|||
);
|
||||
await _saveRecentProduct(product);
|
||||
_barcodeController.clear();
|
||||
_barcodeFocus.requestFocus();
|
||||
// پاک کردن جستجوی ناموفق قبلی
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
|
@ -1497,7 +1503,6 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
|||
);
|
||||
await _saveRecentProduct(product);
|
||||
_barcodeController.clear();
|
||||
_barcodeFocus.requestFocus();
|
||||
// پاک کردن جستجوی ناموفق قبلی
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
|
@ -1544,6 +1549,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
|||
|
||||
// اگر چند نتیجه پیدا شد، دیالوگ انتخاب نمایش بده
|
||||
if (products.length > 1) {
|
||||
if (!showMultipleResultsDialog) return;
|
||||
if (!mounted) return;
|
||||
final selected = await showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
|
|
@ -1557,7 +1563,6 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
|||
await _addToCart(selected, skipHydrate: true);
|
||||
await _saveRecentProduct(selected);
|
||||
_barcodeController.clear();
|
||||
_barcodeFocus.requestFocus();
|
||||
// پاک کردن جستجوی ناموفق قبلی
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
|
@ -1587,7 +1592,6 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
|||
await _addToCart(product, skipHydrate: true);
|
||||
await _saveRecentProduct(product);
|
||||
_barcodeController.clear();
|
||||
_barcodeFocus.requestFocus();
|
||||
// پاک کردن جستجوی ناموفق قبلی
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
|
@ -1610,7 +1614,12 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
|||
);
|
||||
}
|
||||
} 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<QuickSalesPage>
|
|||
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<QuickSalesPage>
|
|||
_barcodeOverlayEntry?.remove();
|
||||
_barcodeOverlayEntry = null;
|
||||
_barcodeHighlightedIndex = -1;
|
||||
_barcodeSuggestionNavigatedByKeyboard = false;
|
||||
}
|
||||
|
||||
void _clearBarcodeSuggestions({bool removeOverlay = true}) {
|
||||
_searchDebounce?.cancel();
|
||||
_barcodeSuggestionRequestGeneration++;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_barcodeSuggestions = const <Map<String, dynamic>>[];
|
||||
_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<QuickSalesPage>
|
|||
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 <Map<String, dynamic>>[];
|
||||
_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<void> _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<Map<String, dynamic>>.from(items);
|
||||
final seen = <int>{};
|
||||
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 <Map<String, dynamic>>[];
|
||||
_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<QuickSalesPage>
|
|||
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 <Map<String, dynamic>>[];
|
||||
_barcodeSuggestionsLoadingMore = false;
|
||||
|
|
@ -2936,7 +2934,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
|||
_barcodeHighlightedIndex = -1;
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
if (mounted && requestGeneration == _barcodeSuggestionRequestGeneration) {
|
||||
setState(() => _barcodeSuggestionsLoading = false);
|
||||
_barcodeOverlayEntry?.markNeedsBuild();
|
||||
}
|
||||
|
|
@ -2950,20 +2948,26 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
|||
_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<QuickSalesPage>
|
|||
_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<QuickSalesPage>
|
|||
await _saveRecentProduct(product);
|
||||
if (!mounted) return;
|
||||
_barcodeController.clear();
|
||||
_barcodeSuggestions = const <Map<String, dynamic>>[];
|
||||
_barcodeHighlightedIndex = -1;
|
||||
_clearBarcodeSuggestions();
|
||||
_barcodeFocus.requestFocus();
|
||||
_barcodeOverlayEntry?.markNeedsBuild();
|
||||
setState(() {
|
||||
_lastFailedSearchQuery = null;
|
||||
});
|
||||
|
|
@ -4568,7 +4576,10 @@ class _QuickSalesPageState extends State<QuickSalesPage>
|
|||
_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<QuickSalesPage>
|
|||
],
|
||||
),
|
||||
),
|
||||
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<QuickSalesPage>
|
|||
});
|
||||
}
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<String, dynamic>? 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<ProductComboboxWidget> {
|
||||
/// افزودن کالای جدید در 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<ProductComboboxWidget> {
|
|||
bool _suppressFieldNotifications = false;
|
||||
bool _selectingFromOverlay = false;
|
||||
int _highlightedIndex = -1;
|
||||
int _searchRequestGeneration = 0;
|
||||
|
||||
// دستهبندیها
|
||||
List<CategoryNode> _categoryTree = [];
|
||||
|
|
@ -334,7 +379,10 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
_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<ProductComboboxWidget> {
|
|||
|
||||
List<int> _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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
Future<void> _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<ProductComboboxWidget> {
|
|||
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<String, dynamic>.from(widget.selectedProduct!));
|
||||
debugPrint('[ProductCombobox] Setting _searchCtrl.text to: "$displayText"');
|
||||
final displayText = _pickerProductDisplayLine(
|
||||
Map<String, dynamic>.from(widget.selectedProduct!),
|
||||
);
|
||||
debugPrint(
|
||||
'[ProductCombobox] Setting _searchCtrl.text to: "$displayText"',
|
||||
);
|
||||
_setFieldQuiet(displayText);
|
||||
if (mounted) setState(() {});
|
||||
return;
|
||||
|
|
@ -477,7 +551,9 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
}
|
||||
} else {
|
||||
final sel = widget.selectedProduct;
|
||||
if (sel != null && trimmed != _pickerProductDisplayLine(Map<String, dynamic>.from(sel)).trim()) {
|
||||
if (sel != null &&
|
||||
trimmed !=
|
||||
_pickerProductDisplayLine(
|
||||
Map<String, dynamic>.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<ProductComboboxWidget> {
|
|||
|
||||
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<ProductComboboxWidget> {
|
|||
|
||||
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<ProductComboboxWidget> {
|
|||
}
|
||||
|
||||
Future<void> _loadRecent() async {
|
||||
final requestGeneration = ++_searchRequestGeneration;
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(0);
|
||||
}
|
||||
|
|
@ -822,7 +925,7 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
widget.onProductsLoaded?.call(items);
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted || requestGeneration != _searchRequestGeneration) return;
|
||||
setState(() {
|
||||
_items = const <Map<String, dynamic>>[];
|
||||
_hasMore = false;
|
||||
|
|
@ -848,7 +951,7 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
widget.onProductsLoaded?.call(const <Map<String, dynamic>>[]);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
if (mounted && requestGeneration == _searchRequestGeneration) {
|
||||
setState(() => _loading = false);
|
||||
_syncPickerState();
|
||||
}
|
||||
|
|
@ -857,7 +960,24 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
|
||||
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 <Map<String, dynamic>>[];
|
||||
_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<void> _performSearch(String q) async {
|
||||
|
|
@ -865,6 +985,7 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
});
|
||||
_syncPickerState();
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted || requestGeneration != _searchRequestGeneration) return;
|
||||
setState(() {
|
||||
_items = const <Map<String, dynamic>>[];
|
||||
_hasMore = false;
|
||||
|
|
@ -906,7 +1031,7 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
});
|
||||
_syncPickerState();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
if (mounted && requestGeneration == _searchRequestGeneration) {
|
||||
setState(() => _loading = false);
|
||||
_syncPickerState();
|
||||
}
|
||||
|
|
@ -915,21 +1040,27 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
|
||||
Future<void> _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<ProductComboboxWidget> {
|
|||
});
|
||||
_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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
_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<ProductComboboxWidget> {
|
|||
if (_searchCtrl.text.isNotEmpty) {
|
||||
displayTooltip = _searchCtrl.text;
|
||||
} else if (widget.selectedProduct != null) {
|
||||
displayTooltip = _pickerProductDisplayLine(Map<String, dynamic>.from(widget.selectedProduct!));
|
||||
displayTooltip = _pickerProductDisplayLine(
|
||||
Map<String, dynamic>.from(widget.selectedProduct!),
|
||||
);
|
||||
if (displayTooltip.isEmpty) displayTooltip = widget.hintText;
|
||||
} else {
|
||||
displayTooltip = widget.hintText;
|
||||
|
|
@ -1153,7 +1294,9 @@ class _ProductComboboxWidgetState extends State<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
}
|
||||
|
||||
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<ProductComboboxWidget> {
|
|||
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<ProductComboboxWidget> {
|
|||
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 {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue