This commit is contained in:
Surendiran 2026-07-14 12:01:08 +05:30
parent 1594274fa2
commit ce0979d782
13 changed files with 420 additions and 182 deletions

View File

@ -142,6 +142,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
Map<String, dynamic> _buildCreatePayload() { Map<String, dynamic> _buildCreatePayload() {
final poId = int.tryParse(_selectedPoId ?? ''); final poId = int.tryParse(_selectedPoId ?? '');
final receivableLines =
_lines.where((line) => line.currentQty > 0).toList();
final payload = <String, dynamic>{ final payload = <String, dynamic>{
'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()), 'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()),
'po_id': poId, 'po_id': poId,
@ -162,7 +164,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
_putOptionalUserId(payload, 'received_by', _receivedById); _putOptionalUserId(payload, 'received_by', _receivedById);
_putOptionalUserId(payload, 'quality_checked_by', _qualityCheckedById); _putOptionalUserId(payload, 'quality_checked_by', _qualityCheckedById);
payload['remarks'] = _remarksController.text.trim(); payload['remarks'] = _remarksController.text.trim();
payload['items'] = _lines.map((line) => line.toPayload()).toList(); payload['items'] =
receivableLines.map((line) => line.toPayload()).toList();
return payload; return payload;
} }
@ -188,14 +191,21 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
} }
String? _lineItemsError() { String? _lineItemsError() {
if (_lines.isEmpty) return 'Add at least one line item with quantity'; final receivableLines =
for (final line in _lines) { _lines.where((line) => line.currentQty > 0).toList();
if (line.currentQty <= 0) { if (receivableLines.isEmpty) {
return 'Enter quantity for line ${line.lineNo}'; return 'Enter quantity for at least one line item';
} }
for (final line in receivableLines) {
if (line.acceptedQty < 0) { if (line.acceptedQty < 0) {
return 'Accepted quantity must be zero or more for line ${line.lineNo}'; 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 && if (line.rejectedQty > 0 &&
line.rejectionReasonController.text.trim().isEmpty) { line.rejectionReasonController.text.trim().isEmpty) {
return 'Rejection reason is required for line ${line.lineNo}'; return 'Rejection reason is required for line ${line.lineNo}';
@ -204,6 +214,11 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
return null; return null;
} }
String _formatOrdered(double value) {
if (value % 1 == 0) return value.toInt().toString();
return value.toStringAsFixed(2);
}
Future<void> _submit() async { Future<void> _submit() async {
final formState = _formKey.currentState; final formState = _formKey.currentState;
if (formState == null) return; if (formState == null) return;

View File

@ -332,6 +332,26 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
const TextInputType.numberWithOptions(decimal: true), const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters, inputFormatters: _qtyFormatters,
isDense: true, 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( AppTextField(
key: ValueKey('$lineKey-rejected'), key: ValueKey('$lineKey-rejected'),
@ -342,6 +362,20 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
const TextInputType.numberWithOptions(decimal: true), const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters, inputFormatters: _qtyFormatters,
isDense: true, 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( _GrnLineReadOnlyField(
key: ValueKey('$lineKey-current'), key: ValueKey('$lineKey-current'),

View File

@ -514,7 +514,7 @@ String masterCellValue(Map<String, dynamic> row, MasterFieldDef field) {
} }
final nested = row[baseKey]; final nested = row[baseKey];
if (nested is Map) { if (nested is Map) {
for (final nestedKey in ['code', 'name', 'description']) { for (final nestedKey in ['name', 'code', 'description']) {
final nestedValue = nested[nestedKey]; final nestedValue = nested[nestedKey];
if (nestedValue != null && if (nestedValue != null &&
nestedValue.toString().trim().isNotEmpty) { nestedValue.toString().trim().isNotEmpty) {

View File

@ -84,6 +84,22 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
String _fieldLabel(MasterFieldDef field) => String _fieldLabel(MasterFieldDef field) =>
field.required ? '${field.label} *' : field.label; 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( Widget _buildField(
BuildContext context, { BuildContext context, {
required MasterFieldDef field, required MasterFieldDef field,
@ -213,15 +229,17 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
); );
case MasterFieldType.number: case MasterFieldType.number:
return TextFormField( return _wrapField(
key: ValueKey('$_formSessionId-${field.key}'), TextFormField(
initialValue: value?.toString() ?? '', key: ValueKey('$_formSessionId-${field.key}'),
keyboardType: const TextInputType.numberWithOptions(decimal: true), initialValue: value?.toString() ?? '',
decoration: InputDecoration(labelText: _fieldLabel(field)), keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: field.required decoration: _inputDecoration(field),
? (v) => Validators.required(v, fieldName: field.label) validator: field.required
: null, ? (v) => Validators.required(v, fieldName: field.label)
onChanged: (text) => notifier.updateValue(field.key, text), : null,
onChanged: (text) => notifier.updateValue(field.key, text),
),
); );
case MasterFieldType.text: case MasterFieldType.text:
@ -230,50 +248,52 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
final formatters = isHsnCodeField final formatters = isHsnCodeField
? Validators.hsnCodeInput ? Validators.hsnCodeInput
: Validators.inputFormattersForFieldKey(field.key); : Validators.inputFormattersForFieldKey(field.key);
return TextFormField( return _wrapField(
key: ValueKey('$_formSessionId-${field.key}'), TextFormField(
initialValue: value?.toString() ?? '', key: ValueKey('$_formSessionId-${field.key}'),
maxLines: field.multiline ? 3 : 1, initialValue: value?.toString() ?? '',
keyboardType: isHsnCodeField maxLines: field.multiline ? 3 : 1,
? TextInputType.number keyboardType: isHsnCodeField
: _keyboardTypeForFieldKey(field.key), ? TextInputType.number
inputFormatters: formatters.isEmpty ? null : formatters, : _keyboardTypeForFieldKey(field.key),
decoration: InputDecoration(labelText: _fieldLabel(field)), inputFormatters: formatters.isEmpty ? null : formatters,
validator: (v) { decoration: _inputDecoration(field),
if (isHsnCodeField) { validator: (v) {
return Validators.uniqueHsnCode( 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, v,
existingRecords: formState.existingRecords, required: field.required,
currentRecordId: widget.recordId,
fieldName: field.label, fieldName: field.label,
); );
} },
if (Validators.isMasterNameFieldKey(field.key)) { onChanged: (text) => notifier.updateValue(field.key, text),
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),
); );
} }
} }
@ -328,7 +348,10 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
widgets.add( widgets.add(
Padding( Padding(
padding: const EdgeInsets.only(bottom: 12), 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),
),
), ),
); );
} }

View File

@ -73,9 +73,16 @@ class MasterRemoteDataSource {
uomByItemId[id] = _asInt(item['uom_id']); uomByItemId[id] = _asInt(item['uom_id']);
gstRateByItemId[id] = _asInt(item['gst_rate_id']); gstRateByItemId[id] = _asInt(item['gst_rate_id']);
final name = _optionLabel(item); final itemName = (item['item_name'] ?? item['name'])?.toString().trim() ?? '';
if (name.isEmpty) continue; if (itemName.isEmpty) continue;
options.add(FilterOptionModel(id: id, name: name)); final itemCode = item['item_code']?.toString().trim();
options.add(
FilterOptionModel(
id: id,
name: itemName,
slug: (itemCode == null || itemCode.isEmpty) ? null : itemCode,
),
);
} }
return ( return (
@ -246,13 +253,8 @@ class MasterRemoteDataSource {
? ratePct.toDouble() ? ratePct.toDouble()
: double.tryParse(ratePct.toString()); : double.tryParse(ratePct.toString());
if (rate != null) { if (rate != null) {
final rateLabel = // Dropdowns show the rate only (e.g. "5%"), not the description.
rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%'; return rate % 1 == 0 ? '${rate.toInt()}%' : '$rate%';
final desc = item['description'];
if (desc is String && desc.trim().isNotEmpty) {
return '$rateLabel${desc.trim()}';
}
return rateLabel;
} }
} }

View File

@ -156,9 +156,16 @@ Future<List<FilterOptionModel>> _fetchActiveVendors(
final data = result.data!; final data = result.data!;
vendors.addAll( vendors.addAll(
data.items.map( data.items
(vendor) => FilterOptionModel(id: vendor.id, name: vendor.vendorName), .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; totalPages = data.totalPages;
page++; page++;

View File

@ -811,10 +811,23 @@ class _AmountSummaryCard extends StatelessWidget {
final TextEditingController discountController; final TextEditingController discountController;
final bool isEditing; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final discountValue = double.tryParse(discountController.text.trim()) ?? 0; final discountValue = double.tryParse(discountController.text.trim()) ?? 0;
final discountInvalid = _validateDiscount(discountController.text) != null;
final isDark = theme.brightness == Brightness.dark; final isDark = theme.brightness == Brightness.dark;
final primaryTint = theme.colorScheme.primary.withValues( final primaryTint = theme.colorScheme.primary.withValues(
alpha: isDark ? 0.22 : 0.1, alpha: isDark ? 0.22 : 0.1,
@ -846,7 +859,11 @@ class _AmountSummaryCard extends StatelessWidget {
_SummaryInputRow( _SummaryInputRow(
label: 'Discount amount', label: 'Discount amount',
controller: discountController, controller: discountController,
valueColor: discountValue > 0 ? AppColors.error : null, valueColor: discountInvalid || discountValue > 0
? AppColors.error
: null,
validator: _validateDiscount,
autovalidateMode: AutovalidateMode.always,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
@ -928,11 +945,15 @@ class _SummaryInputRow extends StatelessWidget {
required this.label, required this.label,
required this.controller, required this.controller,
this.valueColor, this.valueColor,
this.validator,
this.autovalidateMode,
}); });
final String label; final String label;
final TextEditingController controller; final TextEditingController controller;
final Color? valueColor; final Color? valueColor;
final String? Function(String?)? validator;
final AutovalidateMode? autovalidateMode;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -940,21 +961,28 @@ class _SummaryInputRow extends StatelessWidget {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: Text( child: Padding(
label, padding: const EdgeInsets.only(top: 10),
style: theme.textTheme.bodyMedium?.copyWith( child: Text(
color: theme.colorScheme.onSurfaceVariant, label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
SizedBox( SizedBox(
width: 120, width: 140,
child: TextFormField( child: TextFormField(
controller: controller, controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType:
const TextInputType.numberWithOptions(decimal: true),
textAlign: TextAlign.right, textAlign: TextAlign.right,
autovalidateMode: autovalidateMode,
validator: validator,
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: valueColor, color: valueColor,
@ -968,6 +996,11 @@ class _SummaryInputRow extends StatelessWidget {
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
errorMaxLines: 2,
errorStyle: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
fontSize: 11,
),
), ),
), ),
), ),

View File

@ -60,16 +60,21 @@ class PoOrderTotals {
required this.taxableAmount, required this.taxableAmount,
required this.taxAmount, required this.taxAmount,
required this.grandTotal, required this.grandTotal,
required this.maxDiscountAmount,
}); });
final double taxableAmount; final double taxableAmount;
final double taxAmount; final double taxAmount;
final double grandTotal; final double grandTotal;
/// Taxable + Tax + Freight + Other (discount cannot exceed this).
final double maxDiscountAmount;
static const zero = PoOrderTotals( static const zero = PoOrderTotals(
taxableAmount: 0, taxableAmount: 0,
taxAmount: 0, taxAmount: 0,
grandTotal: 0, grandTotal: 0,
maxDiscountAmount: 0,
); );
/// 3.5 Taxable = sum of Line Amounts /// 3.5 Taxable = sum of Line Amounts
@ -87,11 +92,15 @@ class PoOrderTotals {
taxable += line.lineAmount; taxable += line.lineAmount;
tax += line.gstAmount; 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( return PoOrderTotals(
taxableAmount: taxable, taxableAmount: taxable,
taxAmount: tax, 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) { .map((e) {
final id = _parseId(e.id); final id = _parseId(e.id);
if (id == null) return null; 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<AppDropdownOption<int>>() .whereType<AppDropdownOption<int>>()
.toList(); .toList();
@ -427,107 +441,158 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
...widget.gstRates.map((e) { ...widget.gstRates.map((e) {
final id = _parseId(e.id); final id = _parseId(e.id);
if (id == null) return null; if (id == null) return null;
return AppDropdownOption<int?>(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<int?>(value: id, label: label);
}), }),
].whereType<AppDropdownOption<int?>>().toList(); ].whereType<AppDropdownOption<int?>>().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 = <Widget>[
flex(
flex: 3,
child: MasterQuickAddDropdown<int>(
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<int>(
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<int?>(
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( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: borderColor), border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: FormRow( child: QuickAddInlineHost(
columnCount: 12, child: LayoutBuilder(
spans: const [3, 1, 2, 1, 1, 2, 2], builder: (context, constraints) {
spacing: 8, final rowWidth = constraints.maxWidth < minRowWidth
stackBelowWidth: 1100, ? minRowWidth
children: [ : constraints.maxWidth;
MasterQuickAddDropdown<int>( final row = SizedBox(
key: ValueKey('$lineKey-item'), width: rowWidth,
masterId: 'items', child: Row(
label: 'Item *', crossAxisAlignment: CrossAxisAlignment.start,
value: line.itemId, children: [
hint: 'Select item', for (var i = 0; i < fields.length; i++) ...[
searchHint: 'Search item...', if (i > 0) const SizedBox(width: spacing),
options: itemOptions, fields[i],
refreshLookups: () => ],
ref.invalidate(purchaseOrderLookupsProvider), ],
parseCreatedId: int.tryParse, ),
onChanged: _onItemChanged, );
validator: (v) => v == null ? 'Item is required' : null,
), if (constraints.maxWidth < minRowWidth) {
AppTextField( return SingleChildScrollView(
key: ValueKey('$lineKey-qty'), scrollDirection: Axis.horizontal,
controller: line.qtyController, child: row,
label: 'Qty *', );
hint: '0', }
keyboardType: return row;
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<int>(
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<int?>(
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,
),
],
), ),
); );
} }

View File

@ -3,10 +3,31 @@ import 'package:flutter/material.dart';
import 'app_searchable_dropdown.dart'; import 'app_searchable_dropdown.dart';
class AppDropdownOption<T> { class AppDropdownOption<T> {
const AppDropdownOption({required this.value, required this.label}); const AppDropdownOption({
required this.value,
required this.label,
this.subtitle,
this.searchText,
});
final T value; final T value;
final String label; 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. /// Searchable dropdown all app dropdowns use [AppSearchableDropdown] under the hood.

View File

@ -237,15 +237,22 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
_overlayEntry != null _overlayEntry != null
? Icons.arrow_drop_up ? Icons.arrow_drop_up
: Icons.arrow_drop_down, : Icons.arrow_drop_down,
size: 24,
color: canOpen color: canOpen
? colors.onSurfaceVariant ? colors.onSurfaceVariant
: theme.disabledColor, : theme.disabledColor,
), ),
// Match AppTextField height (default icon box is 48px).
suffixIconConstraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
enabled: canOpen, enabled: canOpen,
), ),
child: Text( child: Text(
displayLabel ?? '\u00A0', displayLabel ?? '\u00A0',
maxLines: 1, maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith( style: theme.textTheme.bodyLarge?.copyWith(
color: displayLabel == null color: displayLabel == null
@ -301,9 +308,7 @@ class _SearchableDropdownPanelState<T>
List<AppDropdownOption<T>> get _filtered { List<AppDropdownOption<T>> get _filtered {
final q = _query.trim().toLowerCase(); final q = _query.trim().toLowerCase();
if (q.isEmpty) return widget.options; if (q.isEmpty) return widget.options;
return widget.options return widget.options.where((option) => option.matchesQuery(q)).toList();
.where((option) => option.label.toLowerCase().contains(q))
.toList();
} }
@override @override
@ -364,6 +369,7 @@ class _SearchableDropdownPanelState<T>
itemBuilder: (context, index) { itemBuilder: (context, index) {
final option = filtered[index]; final option = filtered[index];
final isSelected = option.value == widget.selected; final isSelected = option.value == widget.selected;
final subtitle = option.subtitle?.trim();
return ListTile( return ListTile(
dense: true, dense: true,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
@ -371,6 +377,15 @@ class _SearchableDropdownPanelState<T>
option.label, option.label,
overflow: TextOverflow.ellipsis, 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 trailing: isSelected
? Icon( ? Icon(
Icons.check, Icons.check,
@ -603,15 +618,21 @@ class _AppSearchableLookupFieldState<T>
_overlayEntry != null _overlayEntry != null
? Icons.arrow_drop_up ? Icons.arrow_drop_up
: Icons.arrow_drop_down, : Icons.arrow_drop_down,
size: 24,
color: canOpen color: canOpen
? colors.onSurfaceVariant ? colors.onSurfaceVariant
: theme.disabledColor, : theme.disabledColor,
), ),
suffixIconConstraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
enabled: canOpen, enabled: canOpen,
), ),
child: Text( child: Text(
displayLabel ?? '\u00A0', displayLabel ?? '\u00A0',
maxLines: 1, maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith( style: theme.textTheme.bodyLarge?.copyWith(
color: displayLabel == null color: displayLabel == null

View File

@ -197,10 +197,15 @@ class _AppSearchableMultiSelectDropdownState<T>
_overlayEntry != null _overlayEntry != null
? Icons.arrow_drop_up ? Icons.arrow_drop_up
: Icons.arrow_drop_down, : Icons.arrow_drop_down,
size: 24,
color: canOpen color: canOpen
? colors.onSurfaceVariant ? colors.onSurfaceVariant
: theme.disabledColor, : theme.disabledColor,
), ),
suffixIconConstraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
enabled: canOpen, enabled: canOpen,
), ),
child: displayText.isEmpty child: displayText.isEmpty
@ -262,9 +267,7 @@ class _SearchableMultiSelectPanelState<T>
List<AppDropdownOption<T>> get _filtered { List<AppDropdownOption<T>> get _filtered {
final q = _query.trim().toLowerCase(); final q = _query.trim().toLowerCase();
if (q.isEmpty) return widget.options; if (q.isEmpty) return widget.options;
return widget.options return widget.options.where((option) => option.matchesQuery(q)).toList();
.where((option) => option.label.toLowerCase().contains(q))
.toList();
} }
@override @override
@ -328,6 +331,17 @@ class _SearchableMultiSelectPanelState<T>
option.label, option.label,
overflow: TextOverflow.ellipsis, 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, value: isSelected,
onChanged: (checked) => onChanged: (checked) =>
widget.onToggle(option.value, checked ?? false), widget.onToggle(option.value, checked ?? false),

View File

@ -251,9 +251,9 @@ class SidePanelSection extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Divider(height: 1, color: dividerColor), Divider(height: 1, color: dividerColor),
const SizedBox(height: 16), const SizedBox(height: 12),
...children, ...children,
const SizedBox(height: 24), const SizedBox(height: 16),
], ],
); );
} }

View File

@ -19,6 +19,7 @@ class AppTextField extends StatelessWidget {
this.enabled = true, this.enabled = true,
this.autofillHints, this.autofillHints,
this.isDense = false, this.isDense = false,
this.autovalidateMode,
}); });
final TextEditingController controller; final TextEditingController controller;
@ -36,6 +37,7 @@ class AppTextField extends StatelessWidget {
final bool enabled; final bool enabled;
final Iterable<String>? autofillHints; final Iterable<String>? autofillHints;
final bool isDense; final bool isDense;
final AutovalidateMode? autovalidateMode;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -46,6 +48,7 @@ class AppTextField extends StatelessWidget {
obscureText: obscureText, obscureText: obscureText,
keyboardType: keyboardType, keyboardType: keyboardType,
validator: validator, validator: validator,
autovalidateMode: autovalidateMode,
onChanged: onChanged, onChanged: onChanged,
maxLines: maxLines, maxLines: maxLines,
maxLength: maxLength, maxLength: maxLength,