codex/quick-sales-product-search #4

Merged
morrning merged 3 commits from Seyyed/Seyyed_arc:codex/quick-sales-product-search into master 2026-09-22 18:08:37 +03:30
5 changed files with 590 additions and 199 deletions

View 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`، کلیک روی فیلد خالی منوی ابتدای فهرست کالاها را باز نمی‌کند.

View file

@ -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,

View file

@ -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;
}

View file

@ -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 {
);
}
}

View file

@ -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,
);
});
});
}