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() {
final poId = int.tryParse(_selectedPoId ?? '');
final receivableLines =
_lines.where((line) => line.currentQty > 0).toList();
final payload = <String, dynamic>{
'grn_date': DateFormatter.toApiDate(_grnDate ?? DateTime.now()),
'po_id': poId,
@ -162,7 +164,8 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
_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<GrnFormScreen> {
}
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<GrnFormScreen> {
return null;
}
String _formatOrdered(double value) {
if (value % 1 == 0) return value.toInt().toString();
return value.toStringAsFixed(2);
}
Future<void> _submit() async {
final formState = _formKey.currentState;
if (formState == null) return;

View File

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

View File

@ -514,7 +514,7 @@ String masterCellValue(Map<String, dynamic> 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) {

View File

@ -84,6 +84,22 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
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<MasterFormPanel> {
);
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<MasterFormPanel> {
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<MasterFormPanel> {
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),
),
),
);
}

View File

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

View File

@ -156,9 +156,16 @@ Future<List<FilterOptionModel>> _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++;

View File

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

View File

@ -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<AppDropdownOption<int>>()
.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<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();
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(
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<int>(
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<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,
),
],
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;
},
),
),
);
}

View File

@ -3,10 +3,31 @@ import 'package:flutter/material.dart';
import 'app_searchable_dropdown.dart';
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 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.

View File

@ -237,15 +237,22 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
_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<T>
List<AppDropdownOption<T>> 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<T>
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<T>
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<T>
_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

View File

@ -197,10 +197,15 @@ class _AppSearchableMultiSelectDropdownState<T>
_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<T>
List<AppDropdownOption<T>> 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<T>
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),

View File

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

View File

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