diff --git a/lib/modules/grn/presentation/screens/grn_form_screen.dart b/lib/modules/grn/presentation/screens/grn_form_screen.dart index bf1c4dc..a8e61c7 100644 --- a/lib/modules/grn/presentation/screens/grn_form_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_form_screen.dart @@ -142,6 +142,8 @@ class _GrnFormScreenState extends ConsumerState { Map _buildCreatePayload() { final poId = int.tryParse(_selectedPoId ?? ''); + final receivableLines = + _lines.where((line) => line.currentQty > 0).toList(); final payload = { 'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()), 'po_id': poId, @@ -162,7 +164,8 @@ class _GrnFormScreenState extends ConsumerState { _putOptionalUserId(payload, 'received_by', _receivedById); _putOptionalUserId(payload, 'quality_checked_by', _qualityCheckedById); payload['remarks'] = _remarksController.text.trim(); - payload['items'] = _lines.map((line) => line.toPayload()).toList(); + payload['items'] = + receivableLines.map((line) => line.toPayload()).toList(); return payload; } @@ -188,14 +191,21 @@ class _GrnFormScreenState extends ConsumerState { } String? _lineItemsError() { - if (_lines.isEmpty) return 'Add at least one line item with quantity'; - for (final line in _lines) { - if (line.currentQty <= 0) { - return 'Enter quantity for line ${line.lineNo}'; - } + final receivableLines = + _lines.where((line) => line.currentQty > 0).toList(); + if (receivableLines.isEmpty) { + return 'Enter quantity for at least one line item'; + } + for (final line in receivableLines) { if (line.acceptedQty < 0) { return 'Accepted quantity must be zero or more for line ${line.lineNo}'; } + if (line.acceptedQty > line.orderedQty) { + return 'Line ${line.lineNo}: accepted qty cannot exceed ordered qty (${_formatOrdered(line.orderedQty)})'; + } + if (line.currentQty > line.remainingQty) { + return 'Line ${line.lineNo}: quantity cannot exceed remaining (${_formatOrdered(line.remainingQty)})'; + } if (line.rejectedQty > 0 && line.rejectionReasonController.text.trim().isEmpty) { return 'Rejection reason is required for line ${line.lineNo}'; @@ -204,6 +214,11 @@ class _GrnFormScreenState extends ConsumerState { return null; } + String _formatOrdered(double value) { + if (value % 1 == 0) return value.toInt().toString(); + return value.toStringAsFixed(2); + } + Future _submit() async { final formState = _formKey.currentState; if (formState == null) return; diff --git a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart index faa2aa0..b5291e7 100644 --- a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart +++ b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart @@ -332,6 +332,26 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> { const TextInputType.numberWithOptions(decimal: true), inputFormatters: _qtyFormatters, isDense: true, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: (_) => widget.onChanged(), + validator: (value) { + final text = value?.trim() ?? ''; + if (text.isEmpty) return null; + final accepted = double.tryParse(text); + if (accepted == null) return 'Enter a valid quantity'; + if (accepted < 0) return 'Cannot be negative'; + if (accepted > item.orderedQty) { + return 'Cannot exceed ordered qty (${_formatQty(item.orderedQty)})'; + } + if (accepted > item.remainingQty) { + return 'Cannot exceed remaining qty (${_formatQty(item.remainingQty)})'; + } + final current = accepted + item.rejectedQty; + if (current > item.remainingQty) { + return 'Accepted + rejected exceeds remaining (${_formatQty(item.remainingQty)})'; + } + return null; + }, ), AppTextField( key: ValueKey('$lineKey-rejected'), @@ -342,6 +362,20 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> { const TextInputType.numberWithOptions(decimal: true), inputFormatters: _qtyFormatters, isDense: true, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: (_) => widget.onChanged(), + validator: (value) { + final text = value?.trim() ?? ''; + if (text.isEmpty) return null; + final rejected = double.tryParse(text); + if (rejected == null) return 'Enter a valid quantity'; + if (rejected < 0) return 'Cannot be negative'; + final current = item.acceptedQty + rejected; + if (current > item.remainingQty) { + return 'Accepted + rejected exceeds remaining (${_formatQty(item.remainingQty)})'; + } + return null; + }, ), _GrnLineReadOnlyField( key: ValueKey('$lineKey-current'), diff --git a/lib/modules/master_data/domain/entities/master_definition.dart b/lib/modules/master_data/domain/entities/master_definition.dart index 1cd897a..12fab4d 100644 --- a/lib/modules/master_data/domain/entities/master_definition.dart +++ b/lib/modules/master_data/domain/entities/master_definition.dart @@ -514,7 +514,7 @@ String masterCellValue(Map row, MasterFieldDef field) { } final nested = row[baseKey]; if (nested is Map) { - for (final nestedKey in ['code', 'name', 'description']) { + for (final nestedKey in ['name', 'code', 'description']) { final nestedValue = nested[nestedKey]; if (nestedValue != null && nestedValue.toString().trim().isNotEmpty) { diff --git a/lib/modules/master_data/presentation/widgets/master_form_panel.dart b/lib/modules/master_data/presentation/widgets/master_form_panel.dart index b601bf8..0e8ae0c 100644 --- a/lib/modules/master_data/presentation/widgets/master_form_panel.dart +++ b/lib/modules/master_data/presentation/widgets/master_form_panel.dart @@ -84,6 +84,22 @@ class _MasterFormPanelState extends ConsumerState { String _fieldLabel(MasterFieldDef field) => field.required ? '${field.label} *' : field.label; + /// Matches [AppTextField] / [AppSearchableDropdown] label clearance. + Widget _wrapField(Widget field) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: field, + ); + } + + InputDecoration _inputDecoration(MasterFieldDef field) { + return InputDecoration( + labelText: _fieldLabel(field), + floatingLabelBehavior: FloatingLabelBehavior.always, + alignLabelWithHint: true, + ); + } + Widget _buildField( BuildContext context, { required MasterFieldDef field, @@ -213,15 +229,17 @@ class _MasterFormPanelState extends ConsumerState { ); case MasterFieldType.number: - return TextFormField( - key: ValueKey('$_formSessionId-${field.key}'), - initialValue: value?.toString() ?? '', - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: InputDecoration(labelText: _fieldLabel(field)), - validator: field.required - ? (v) => Validators.required(v, fieldName: field.label) - : null, - onChanged: (text) => notifier.updateValue(field.key, text), + return _wrapField( + TextFormField( + key: ValueKey('$_formSessionId-${field.key}'), + initialValue: value?.toString() ?? '', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: _inputDecoration(field), + validator: field.required + ? (v) => Validators.required(v, fieldName: field.label) + : null, + onChanged: (text) => notifier.updateValue(field.key, text), + ), ); case MasterFieldType.text: @@ -230,50 +248,52 @@ class _MasterFormPanelState extends ConsumerState { final formatters = isHsnCodeField ? Validators.hsnCodeInput : Validators.inputFormattersForFieldKey(field.key); - return TextFormField( - key: ValueKey('$_formSessionId-${field.key}'), - initialValue: value?.toString() ?? '', - maxLines: field.multiline ? 3 : 1, - keyboardType: isHsnCodeField - ? TextInputType.number - : _keyboardTypeForFieldKey(field.key), - inputFormatters: formatters.isEmpty ? null : formatters, - decoration: InputDecoration(labelText: _fieldLabel(field)), - validator: (v) { - if (isHsnCodeField) { - return Validators.uniqueHsnCode( + return _wrapField( + TextFormField( + key: ValueKey('$_formSessionId-${field.key}'), + initialValue: value?.toString() ?? '', + maxLines: field.multiline ? 3 : 1, + keyboardType: isHsnCodeField + ? TextInputType.number + : _keyboardTypeForFieldKey(field.key), + inputFormatters: formatters.isEmpty ? null : formatters, + decoration: _inputDecoration(field), + validator: (v) { + if (isHsnCodeField) { + return Validators.uniqueHsnCode( + v, + existingRecords: formState.existingRecords, + currentRecordId: widget.recordId, + fieldName: field.label, + ); + } + if (Validators.isMasterNameFieldKey(field.key)) { + return Validators.uniqueMasterName( + v, + nameKey: field.key, + existingRecords: formState.existingRecords, + currentRecordId: widget.recordId, + fieldName: field.label, + ); + } + if (Validators.isMasterCodeFieldKey(field.key)) { + return Validators.uniqueMasterCode( + v, + codeKey: field.key, + existingRecords: formState.existingRecords, + currentRecordId: widget.recordId, + fieldName: field.label, + ); + } + return Validators.forFieldKey( + field.key, v, - existingRecords: formState.existingRecords, - currentRecordId: widget.recordId, + required: field.required, fieldName: field.label, ); - } - if (Validators.isMasterNameFieldKey(field.key)) { - return Validators.uniqueMasterName( - v, - nameKey: field.key, - existingRecords: formState.existingRecords, - currentRecordId: widget.recordId, - fieldName: field.label, - ); - } - if (Validators.isMasterCodeFieldKey(field.key)) { - return Validators.uniqueMasterCode( - v, - codeKey: field.key, - existingRecords: formState.existingRecords, - currentRecordId: widget.recordId, - fieldName: field.label, - ); - } - return Validators.forFieldKey( - field.key, - v, - required: field.required, - fieldName: field.label, - ); - }, - onChanged: (text) => notifier.updateValue(field.key, text), + }, + onChanged: (text) => notifier.updateValue(field.key, text), + ), ); } } @@ -328,7 +348,10 @@ class _MasterFormPanelState extends ConsumerState { widgets.add( Padding( padding: const EdgeInsets.only(bottom: 12), - child: _buildField(context, field: left, formState: formState), + child: SizedBox( + width: double.infinity, + child: _buildField(context, field: left, formState: formState), + ), ), ); } diff --git a/lib/modules/masters/data/datasources/master_remote_data_source.dart b/lib/modules/masters/data/datasources/master_remote_data_source.dart index 15c0c17..5bccac6 100644 --- a/lib/modules/masters/data/datasources/master_remote_data_source.dart +++ b/lib/modules/masters/data/datasources/master_remote_data_source.dart @@ -73,9 +73,16 @@ class MasterRemoteDataSource { uomByItemId[id] = _asInt(item['uom_id']); gstRateByItemId[id] = _asInt(item['gst_rate_id']); - final name = _optionLabel(item); - if (name.isEmpty) continue; - options.add(FilterOptionModel(id: id, name: name)); + final itemName = (item['item_name'] ?? item['name'])?.toString().trim() ?? ''; + if (itemName.isEmpty) continue; + final itemCode = item['item_code']?.toString().trim(); + options.add( + FilterOptionModel( + id: id, + name: itemName, + slug: (itemCode == null || itemCode.isEmpty) ? null : itemCode, + ), + ); } return ( @@ -246,13 +253,8 @@ class MasterRemoteDataSource { ? ratePct.toDouble() : double.tryParse(ratePct.toString()); if (rate != null) { - final rateLabel = - rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%'; - final desc = item['description']; - if (desc is String && desc.trim().isNotEmpty) { - return '$rateLabel — ${desc.trim()}'; - } - return rateLabel; + // Dropdowns show the rate only (e.g. "5%"), not the description. + return rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%'; } } diff --git a/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart b/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart index a175fcc..2bd8f55 100644 --- a/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart +++ b/lib/modules/purchase_orders/presentation/providers/purchase_order_lookups_provider.dart @@ -156,9 +156,16 @@ Future> _fetchActiveVendors( final data = result.data!; vendors.addAll( - data.items.map( - (vendor) => FilterOptionModel(id: vendor.id, name: vendor.vendorName), - ), + data.items + .where((vendor) { + if (!vendor.isActive) return false; + final status = (vendor.status ?? '').trim().toLowerCase(); + return status != 'blacklisted'; + }) + .map( + (vendor) => + FilterOptionModel(id: vendor.id, name: vendor.vendorName), + ), ); totalPages = data.totalPages; page++; diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart index 2c645a5..0a7e7f0 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart @@ -811,10 +811,23 @@ class _AmountSummaryCard extends StatelessWidget { final TextEditingController discountController; final bool isEditing; + String? _validateDiscount(String? value) { + final text = value?.trim() ?? ''; + if (text.isEmpty) return null; + final discount = double.tryParse(text); + if (discount == null) return 'Enter a valid amount'; + if (discount < 0) return 'Cannot be negative'; + if (discount > totals.maxDiscountAmount) { + return 'Cannot exceed ${CurrencyFormatter.format(totals.maxDiscountAmount)}'; + } + return null; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final discountValue = double.tryParse(discountController.text.trim()) ?? 0; + final discountInvalid = _validateDiscount(discountController.text) != null; final isDark = theme.brightness == Brightness.dark; final primaryTint = theme.colorScheme.primary.withValues( alpha: isDark ? 0.22 : 0.1, @@ -846,7 +859,11 @@ class _AmountSummaryCard extends StatelessWidget { _SummaryInputRow( label: 'Discount amount', controller: discountController, - valueColor: discountValue > 0 ? AppColors.error : null, + valueColor: discountInvalid || discountValue > 0 + ? AppColors.error + : null, + validator: _validateDiscount, + autovalidateMode: AutovalidateMode.always, ), const SizedBox(height: 12), Container( @@ -928,11 +945,15 @@ class _SummaryInputRow extends StatelessWidget { required this.label, required this.controller, this.valueColor, + this.validator, + this.autovalidateMode, }); final String label; final TextEditingController controller; final Color? valueColor; + final String? Function(String?)? validator; + final AutovalidateMode? autovalidateMode; @override Widget build(BuildContext context) { @@ -940,21 +961,28 @@ class _SummaryInputRow extends StatelessWidget { return Padding( padding: const EdgeInsets.only(top: 4), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Text( - label, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + child: Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ), ), SizedBox( - width: 120, + width: 140, child: TextFormField( controller: controller, - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), textAlign: TextAlign.right, + autovalidateMode: autovalidateMode, + validator: validator, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: valueColor, @@ -968,6 +996,11 @@ class _SummaryInputRow extends StatelessWidget { border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), + errorMaxLines: 2, + errorStyle: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + fontSize: 11, + ), ), ), ), diff --git a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart index 3ecdc26..18fb392 100644 --- a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart +++ b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart @@ -60,16 +60,21 @@ class PoOrderTotals { required this.taxableAmount, required this.taxAmount, required this.grandTotal, + required this.maxDiscountAmount, }); final double taxableAmount; final double taxAmount; final double grandTotal; + /// Taxable + Tax + Freight + Other (discount cannot exceed this). + final double maxDiscountAmount; + static const zero = PoOrderTotals( taxableAmount: 0, taxAmount: 0, grandTotal: 0, + maxDiscountAmount: 0, ); /// 3.5 Taxable = sum of Line Amounts @@ -87,11 +92,15 @@ class PoOrderTotals { taxable += line.lineAmount; tax += line.gstAmount; } - final grandTotal = taxable + tax + freight + otherCharges - discountAmount; + final maxDiscount = taxable + tax + freight + otherCharges; + final clampedDiscount = + discountAmount < 0 ? 0.0 : discountAmount; + final grandTotalRaw = maxDiscount - clampedDiscount; return PoOrderTotals( taxableAmount: taxable, taxAmount: tax, - grandTotal: grandTotal, + grandTotal: grandTotalRaw < 0 ? 0 : grandTotalRaw, + maxDiscountAmount: maxDiscount < 0 ? 0 : maxDiscount, ); } } @@ -410,7 +419,12 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { .map((e) { final id = _parseId(e.id); if (id == null) return null; - return AppDropdownOption(value: id, label: e.name); + final code = e.slug?.trim(); + return AppDropdownOption( + value: id, + label: e.name, + subtitle: (code == null || code.isEmpty) ? null : code, + ); }) .whereType>() .toList(); @@ -427,107 +441,158 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { ...widget.gstRates.map((e) { final id = _parseId(e.id); if (id == null) return null; - return AppDropdownOption(value: id, label: e.name); + final pct = widget.gstRatePctById[e.id]; + final label = pct != null + ? (pct % 1 == 0 ? '${pct.toInt()}%' : '$pct%') + : e.name.split(' — ').first.trim(); + return AppDropdownOption(value: id, label: label); }), ].whereType>().toList(); + const spacing = 8.0; + const minRowWidth = 1040.0; + + Widget flex({required int flex, required Widget child}) { + return Expanded(flex: flex, child: child); + } + + final fields = [ + flex( + flex: 3, + child: MasterQuickAddDropdown( + key: ValueKey('$lineKey-item'), + masterId: 'items', + label: 'Item *', + value: line.itemId, + hint: 'Select item', + searchHint: 'Search item name or code...', + options: itemOptions, + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, + onChanged: _onItemChanged, + validator: (v) => v == null ? 'Item is required' : null, + ), + ), + flex( + flex: 1, + child: AppTextField( + key: ValueKey('$lineKey-qty'), + controller: line.qtyController, + label: 'Qty *', + hint: '0', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Required'; + final qty = double.tryParse(v); + if (qty == null || qty <= 0) return 'Invalid'; + return null; + }, + ), + ), + flex( + flex: 2, + child: MasterQuickAddDropdown( + key: ValueKey('$lineKey-uom'), + masterId: 'uom', + label: 'UOM *', + value: line.uomId, + hint: 'Select UOM', + searchHint: 'Search UOM...', + options: uomOptions, + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, + onChanged: (v) => _updateLine(() => line.uomId = v), + validator: (v) => v == null ? 'Required' : null, + ), + ), + flex( + flex: 1, + child: AppTextField( + key: ValueKey('$lineKey-rate'), + controller: line.rateController, + label: 'Rate *', + hint: '0.00', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Required'; + final rate = double.tryParse(v); + if (rate == null || rate < 0) return 'Invalid'; + return null; + }, + ), + ), + flex( + flex: 1, + child: AppTextField( + key: ValueKey('$lineKey-discount'), + controller: line.discountController, + label: 'Disc %', + hint: '0', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + ), + flex( + flex: 2, + child: MasterQuickAddDropdown( + key: ValueKey('$lineKey-gst'), + masterId: 'gst_rates', + label: 'GST %', + value: line.gstRateId, + hint: 'Select', + searchHint: 'Search GST %...', + options: gstOptions, + refreshLookups: () => + ref.invalidate(purchaseOrderLookupsProvider), + parseCreatedId: int.tryParse, + onChanged: (v) => _updateLine(() => line.gstRateId = v), + ), + ), + flex( + flex: 2, + child: _AmountWithRemove( + amount: CurrencyFormatter.format(calc.lineAmount), + backgroundColor: amountBg, + onRemove: widget.onRemove, + ), + ), + ]; + return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( border: Border.all(color: borderColor), borderRadius: BorderRadius.circular(10), ), - child: FormRow( - columnCount: 12, - spans: const [3, 1, 2, 1, 1, 2, 2], - spacing: 8, - stackBelowWidth: 1100, - children: [ - MasterQuickAddDropdown( - key: ValueKey('$lineKey-item'), - masterId: 'items', - label: 'Item *', - value: line.itemId, - hint: 'Select item', - searchHint: 'Search item...', - options: itemOptions, - refreshLookups: () => - ref.invalidate(purchaseOrderLookupsProvider), - parseCreatedId: int.tryParse, - onChanged: _onItemChanged, - validator: (v) => v == null ? 'Item is required' : null, - ), - AppTextField( - key: ValueKey('$lineKey-qty'), - controller: line.qtyController, - label: 'Qty *', - hint: '0', - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - validator: (v) { - if (v == null || v.trim().isEmpty) { - return 'Required'; - } - final qty = double.tryParse(v); - if (qty == null || qty <= 0) return 'Invalid'; - return null; - }, - ), - MasterQuickAddDropdown( - key: ValueKey('$lineKey-uom'), - masterId: 'uom', - label: 'UOM *', - value: line.uomId, - hint: 'Select UOM', - searchHint: 'Search UOM...', - options: uomOptions, - refreshLookups: () => - ref.invalidate(purchaseOrderLookupsProvider), - parseCreatedId: int.tryParse, - onChanged: (v) => _updateLine(() => line.uomId = v), - validator: (v) => v == null ? 'Required' : null, - ), - AppTextField( - key: ValueKey('$lineKey-rate'), - controller: line.rateController, - label: 'Rate *', - hint: '0.00', - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - validator: (v) { - if (v == null || v.trim().isEmpty) return 'Required'; - final rate = double.tryParse(v); - if (rate == null || rate < 0) return 'Invalid'; - return null; - }, - ), - AppTextField( - key: ValueKey('$lineKey-discount'), - controller: line.discountController, - label: 'Disc %', - hint: '0', - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - ), - MasterQuickAddDropdown( - key: ValueKey('$lineKey-gst'), - masterId: 'gst_rates', - label: 'GST rate', - value: line.gstRateId, - hint: 'Select GST rate', - searchHint: 'Search GST rate...', - options: gstOptions, - refreshLookups: () => - ref.invalidate(purchaseOrderLookupsProvider), - parseCreatedId: int.tryParse, - onChanged: (v) => _updateLine(() => line.gstRateId = v), - ), - _AmountWithRemove( - amount: CurrencyFormatter.format(calc.lineAmount), - backgroundColor: amountBg, - onRemove: widget.onRemove, - ), - ], + child: QuickAddInlineHost( + child: LayoutBuilder( + builder: (context, constraints) { + final rowWidth = constraints.maxWidth < minRowWidth + ? minRowWidth + : constraints.maxWidth; + final row = SizedBox( + width: rowWidth, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < fields.length; i++) ...[ + if (i > 0) const SizedBox(width: spacing), + fields[i], + ], + ], + ), + ); + + if (constraints.maxWidth < minRowWidth) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: row, + ); + } + return row; + }, + ), ), ); } diff --git a/lib/shared/widgets/app_dropdown.dart b/lib/shared/widgets/app_dropdown.dart index 26196c6..44d15bd 100644 --- a/lib/shared/widgets/app_dropdown.dart +++ b/lib/shared/widgets/app_dropdown.dart @@ -3,10 +3,31 @@ import 'package:flutter/material.dart'; import 'app_searchable_dropdown.dart'; class AppDropdownOption { - const AppDropdownOption({required this.value, required this.label}); + const AppDropdownOption({ + required this.value, + required this.label, + this.subtitle, + this.searchText, + }); final T value; final String label; + + /// Secondary line under [label] in the picker (e.g. item code). + final String? subtitle; + + /// Extra searchable text (defaults to [label] + [subtitle]). + final String? searchText; + + String get searchable => + (searchText ?? [label, if (subtitle != null) subtitle!].join(' ')) + .toLowerCase(); + + bool matchesQuery(String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return true; + return searchable.contains(q); + } } /// Searchable dropdown — all app dropdowns use [AppSearchableDropdown] under the hood. diff --git a/lib/shared/widgets/app_searchable_dropdown.dart b/lib/shared/widgets/app_searchable_dropdown.dart index f239b17..217d54b 100644 --- a/lib/shared/widgets/app_searchable_dropdown.dart +++ b/lib/shared/widgets/app_searchable_dropdown.dart @@ -237,15 +237,22 @@ class _AppSearchableDropdownState extends State> { _overlayEntry != null ? Icons.arrow_drop_up : Icons.arrow_drop_down, + size: 24, color: canOpen ? colors.onSurfaceVariant : theme.disabledColor, ), + // Match AppTextField height (default icon box is 48px). + suffixIconConstraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), enabled: canOpen, ), child: Text( displayLabel ?? '\u00A0', maxLines: 1, + softWrap: false, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyLarge?.copyWith( color: displayLabel == null @@ -301,9 +308,7 @@ class _SearchableDropdownPanelState List> get _filtered { final q = _query.trim().toLowerCase(); if (q.isEmpty) return widget.options; - return widget.options - .where((option) => option.label.toLowerCase().contains(q)) - .toList(); + return widget.options.where((option) => option.matchesQuery(q)).toList(); } @override @@ -364,6 +369,7 @@ class _SearchableDropdownPanelState itemBuilder: (context, index) { final option = filtered[index]; final isSelected = option.value == widget.selected; + final subtitle = option.subtitle?.trim(); return ListTile( dense: true, visualDensity: VisualDensity.compact, @@ -371,6 +377,15 @@ class _SearchableDropdownPanelState option.label, overflow: TextOverflow.ellipsis, ), + subtitle: subtitle == null || subtitle.isEmpty + ? null + : Text( + subtitle, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), trailing: isSelected ? Icon( Icons.check, @@ -603,15 +618,21 @@ class _AppSearchableLookupFieldState _overlayEntry != null ? Icons.arrow_drop_up : Icons.arrow_drop_down, + size: 24, color: canOpen ? colors.onSurfaceVariant : theme.disabledColor, ), + suffixIconConstraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), enabled: canOpen, ), child: Text( displayLabel ?? '\u00A0', maxLines: 1, + softWrap: false, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyLarge?.copyWith( color: displayLabel == null diff --git a/lib/shared/widgets/app_searchable_multi_select_dropdown.dart b/lib/shared/widgets/app_searchable_multi_select_dropdown.dart index 0adb8de..b78b78e 100644 --- a/lib/shared/widgets/app_searchable_multi_select_dropdown.dart +++ b/lib/shared/widgets/app_searchable_multi_select_dropdown.dart @@ -197,10 +197,15 @@ class _AppSearchableMultiSelectDropdownState _overlayEntry != null ? Icons.arrow_drop_up : Icons.arrow_drop_down, + size: 24, color: canOpen ? colors.onSurfaceVariant : theme.disabledColor, ), + suffixIconConstraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), enabled: canOpen, ), child: displayText.isEmpty @@ -262,9 +267,7 @@ class _SearchableMultiSelectPanelState List> get _filtered { final q = _query.trim().toLowerCase(); if (q.isEmpty) return widget.options; - return widget.options - .where((option) => option.label.toLowerCase().contains(q)) - .toList(); + return widget.options.where((option) => option.matchesQuery(q)).toList(); } @override @@ -328,6 +331,17 @@ class _SearchableMultiSelectPanelState option.label, overflow: TextOverflow.ellipsis, ), + subtitle: () { + final subtitle = option.subtitle?.trim(); + if (subtitle == null || subtitle.isEmpty) return null; + return Text( + subtitle, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ); + }(), value: isSelected, onChanged: (checked) => widget.onToggle(option.value, checked ?? false), diff --git a/lib/shared/widgets/app_side_panel.dart b/lib/shared/widgets/app_side_panel.dart index 39a9dc2..db19ee5 100644 --- a/lib/shared/widgets/app_side_panel.dart +++ b/lib/shared/widgets/app_side_panel.dart @@ -251,9 +251,9 @@ class SidePanelSection extends StatelessWidget { ), const SizedBox(height: 8), Divider(height: 1, color: dividerColor), - const SizedBox(height: 16), + const SizedBox(height: 12), ...children, - const SizedBox(height: 24), + const SizedBox(height: 16), ], ); } diff --git a/lib/shared/widgets/app_text_field.dart b/lib/shared/widgets/app_text_field.dart index 1c40c16..15d8aeb 100644 --- a/lib/shared/widgets/app_text_field.dart +++ b/lib/shared/widgets/app_text_field.dart @@ -19,6 +19,7 @@ class AppTextField extends StatelessWidget { this.enabled = true, this.autofillHints, this.isDense = false, + this.autovalidateMode, }); final TextEditingController controller; @@ -36,6 +37,7 @@ class AppTextField extends StatelessWidget { final bool enabled; final Iterable? autofillHints; final bool isDense; + final AutovalidateMode? autovalidateMode; @override Widget build(BuildContext context) { @@ -46,6 +48,7 @@ class AppTextField extends StatelessWidget { obscureText: obscureText, keyboardType: keyboardType, validator: validator, + autovalidateMode: autovalidateMode, onChanged: onChanged, maxLines: maxLines, maxLength: maxLength,