quick add option

This commit is contained in:
Surendiran 2026-07-13 18:34:38 +05:30
parent 7a6bde09dd
commit 1594274fa2
18 changed files with 1579 additions and 137 deletions

View File

@ -18,6 +18,7 @@ import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../../../master_data/presentation/widgets/master_quick_add.dart';
import '../../data/repositories/asset_repository_impl.dart';
import '../providers/asset_categories_provider.dart';
import '../providers/asset_form_lookups_provider.dart';
@ -675,12 +676,14 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
label: 'Department',
value: _departmentId,
options: lookups.departments,
masterId: 'departments',
onChanged: (v) => setState(() => _departmentId = v),
),
right: _optionalLookupDropdown(
label: 'Warehouse',
value: _warehouseId,
options: lookups.warehouses,
masterId: 'warehouses',
onChanged: (v) => setState(() => _warehouseId = v),
),
),
@ -980,6 +983,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
required int? value,
required List<FilterOptionModel> options,
required ValueChanged<int?> onChanged,
String? masterId,
String? emptyHint,
bool enabled = true,
bool required = false,
@ -998,17 +1002,43 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
.map((option) => option.value)
.whereType<int>()
.toList();
final resolvedValue =
value != null && validIds.contains(value) ? value : null;
final fieldEnabled =
enabled && (options.isNotEmpty || masterId != null);
return AppSearchableDropdown<int?>(
if (masterId == null) {
return AppSearchableDropdown<int?>(
label: required ? '$label *' : label,
value: resolvedValue,
hint: emptyHint ?? 'None',
searchHint: 'Search ${label.toLowerCase()}...',
isDense: true,
enabled: fieldEnabled,
options: dropdownOptions,
onChanged: onChanged,
validator:
required ? (v) => v == null ? '$label is required' : null : null,
);
}
return MasterQuickAddDropdown<int?>(
masterId: masterId,
label: required ? '$label *' : label,
value: value != null && validIds.contains(value) ? value : null,
value: resolvedValue,
hint: emptyHint ?? 'None',
searchHint: 'Search ${label.toLowerCase()}...',
isDense: true,
enabled: enabled && options.isNotEmpty,
enabled: fieldEnabled,
options: dropdownOptions,
refreshLookups: () {
ref.invalidate(assetFormLookupsProvider);
ref.invalidate(assetPlantsProvider);
},
parseCreatedId: int.tryParse,
onChanged: onChanged,
validator: required ? (v) => v == null ? '$label is required' : null : null,
validator:
required ? (v) => v == null ? '$label is required' : null : null,
);
}
@ -1024,7 +1054,8 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
)
.where((option) => option.value != 0)
.toList();
return AppSearchableDropdown<int>(
return MasterQuickAddDropdown<int>(
masterId: 'item_subcategories',
label: 'Subcategory *',
value: _dropdownValue(_subcategoryId, ids),
hint: !hasCategory
@ -1034,8 +1065,13 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
: 'Select subcategory *',
searchHint: 'Search subcategory...',
isDense: true,
enabled: hasCategory && options.isNotEmpty,
enabled: hasCategory,
options: options,
initialValues: {'item_category_id': _categoryId},
refreshLookups: () {
ref.invalidate(itemSubcategoriesProvider(_categoryId));
},
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _subcategoryId = v),
validator: (v) => v == null ? 'Subcategory is required' : null,
);
@ -1046,7 +1082,8 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
.map((c) => int.tryParse(c.id))
.whereType<int>()
.toList();
return AppSearchableDropdown<int>(
return MasterQuickAddDropdown<int>(
masterId: 'item_categories',
label: 'Category *',
value: _dropdownValue(_categoryId, categoryIds),
searchHint: 'Search category...',
@ -1060,6 +1097,10 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
)
.where((option) => option.value != 0)
.toList(),
refreshLookups: () {
ref.invalidate(itemCategoriesProvider);
},
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() {
_categoryId = v;
_subcategoryId = null;
@ -1087,7 +1128,8 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
.map((p) => int.tryParse(p.id))
.whereType<int>()
.toList();
return AppSearchableDropdown<int>(
return MasterQuickAddDropdown<int>(
masterId: 'plants',
label: 'Plant *',
value: _dropdownValue(_plantId, plantIds),
searchHint: 'Search plant...',
@ -1101,6 +1143,11 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
)
.where((option) => option.value != 0)
.toList(),
refreshLookups: () {
ref.invalidate(assetPlantsProvider);
ref.invalidate(assetFormLookupsProvider);
},
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _plantId = v),
validator: (v) => v == null ? 'Plant is required' : null,
);

View File

@ -15,6 +15,7 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../master_data/presentation/widgets/master_quick_add.dart';
import '../providers/asset_form_lookups_provider.dart';
import '../providers/assets_provider.dart';
@ -1334,7 +1335,8 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
onPick: _pickDate,
),
const SizedBox(height: 12),
AppSearchableDropdown<int>(
MasterQuickAddDropdown<int>(
masterId: 'plants',
label: 'To Plant',
value: _toPlantId,
searchHint: 'Search plant...',
@ -1346,10 +1348,14 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
})
.whereType<AppDropdownOption<int>>()
.toList(),
refreshLookups: () =>
ref.invalidate(assetFormLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _toPlantId = v),
),
const SizedBox(height: 12),
AppSearchableDropdown<int>(
MasterQuickAddDropdown<int>(
masterId: 'departments',
label: 'To Department',
value: _toDepartmentId,
searchHint: 'Search department...',
@ -1361,6 +1367,9 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
})
.whereType<AppDropdownOption<int>>()
.toList(),
refreshLookups: () =>
ref.invalidate(assetFormLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _toDepartmentId = v),
),
const SizedBox(height: 12),
@ -1379,7 +1388,8 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
onChanged: (v) => setState(() => _toUserId = v),
),
const SizedBox(height: 12),
AppSearchableDropdown<int>(
MasterQuickAddDropdown<int>(
masterId: 'warehouses',
label: 'To Warehouse',
value: _toWarehouseId,
searchHint: 'Search warehouse...',
@ -1391,6 +1401,9 @@ class _TransferAssetPanelState extends ConsumerState<TransferAssetPanel> {
})
.whereType<AppDropdownOption<int>>()
.toList(),
refreshLookups: () =>
ref.invalidate(assetFormLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _toWarehouseId = v),
),
const SizedBox(height: 12),

View File

@ -18,6 +18,7 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../master_data/presentation/widgets/master_quick_add.dart';
import '../providers/grn_lookups_provider.dart';
import '../providers/grn_provider.dart';
import '../widgets/grn_line_items_editor.dart';
@ -439,12 +440,16 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
label: 'Purchase order',
value: existing?.poNumber ?? '',
),
AppSearchableDropdown<int>(
MasterQuickAddDropdown<int>(
masterId: 'warehouses',
label: 'Warehouse *',
value: _dropdownValue(_warehouseId, warehouseIds),
hint: 'Select warehouse',
searchHint: 'Search warehouse...',
options: _intOptions(lookups.warehouses),
refreshLookups: () =>
ref.invalidate(grnLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: widget.isEditing
? (_) {}
: (v) => setState(() => _warehouseId = v),

View File

@ -282,7 +282,7 @@ const masterDefinitions = <MasterDefinition>[
),
MasterFieldDef(key: 'contact_person', label: 'Contact Person'),
MasterFieldDef(key: 'phone', label: 'Phone'),
MasterFieldDef(key: 'email', label: 'Email'),
MasterFieldDef(key: 'email', label: 'Email', required: true),
_activeField,
],
),

View File

@ -230,7 +230,12 @@ class MasterListNotifier extends FamilyAsyncNotifier<MasterListState, String> {
}
}
typedef MasterFormArgs = ({String masterId, String? recordId});
typedef MasterFormArgs = ({
String masterId,
String? recordId,
Map<String, dynamic>? initialValues,
String formSessionId,
});
final masterFormProvider = AsyncNotifierProvider.family<
MasterFormNotifier, MasterFormState, MasterFormArgs>(MasterFormNotifier.new);
@ -260,6 +265,10 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
values[field.key] = field.key == 'is_active' ? true : false;
}
}
final initials = arg.initialValues;
if (initials != null && initials.isNotEmpty) {
values.addAll(initials);
}
}
return MasterFormState(
@ -352,6 +361,13 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
state = AsyncData(current.copyWith(values: values));
}
Future<void> reloadDropdownOptions() async {
final current = state.valueOrNull;
if (current == null) return;
final options = await _loadDropdownOptions();
state = AsyncData(current.copyWith(dropdownOptions: options));
}
Map<String, dynamic> _buildPayload(MasterFormState current) {
final payload = <String, dynamic>{};
for (final field in _definition.formFields) {
@ -368,7 +384,8 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
return payload;
}
Future<bool> submit() async {
/// Returns the created/updated record id on success, otherwise null.
Future<String?> submit() async {
final current = state.valueOrNull ?? const MasterFormState();
state = AsyncData(current.copyWith(isSubmitting: true, clearError: true));
@ -386,10 +403,12 @@ class MasterFormNotifier extends FamilyAsyncNotifier<MasterFormState, MasterForm
errorMessage: result.failure!.message,
),
);
return false;
return null;
}
state = AsyncData(current.copyWith(isSubmitting: false, clearError: true));
return true;
final createdId = result.data?['id']?.toString();
if (createdId != null && createdId.isNotEmpty) return createdId;
return arg.recordId ?? 'created';
}
}

View File

@ -85,13 +85,24 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
}
Future<void> _openFormPanel({String? recordId}) async {
ref.invalidate(masterFormProvider((masterId: widget.masterId, recordId: recordId)));
final saved = await showSidePanel<bool>(
final sessionId =
'${widget.masterId}-${recordId ?? 'new'}-${DateTime.now().microsecondsSinceEpoch}';
ref.invalidate(masterFormProvider((
masterId: widget.masterId,
recordId: recordId,
initialValues: null,
formSessionId: sessionId,
)));
final saved = await showSidePanel<String>(
context,
MasterFormPanel(masterId: widget.masterId, recordId: recordId),
MasterFormPanel(
masterId: widget.masterId,
recordId: recordId,
formSessionId: sessionId,
),
width: 560,
);
if (saved == true && mounted) {
if (saved != null && mounted) {
ref.invalidate(masterListProvider(widget.masterId));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(

View File

@ -11,17 +11,26 @@ import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../domain/entities/master_definition.dart';
import '../providers/master_provider.dart';
import 'master_quick_add.dart';
class MasterFormPanel extends ConsumerStatefulWidget {
const MasterFormPanel({
super.key,
required this.masterId,
this.recordId,
this.initialValues,
this.formSessionId,
});
final String masterId;
final String? recordId;
/// Prefill values for create mode (used by Quick Add for filtered FKs).
final Map<String, dynamic>? initialValues;
/// Unique per open so create forms always start empty.
final String? formSessionId;
bool get isEditing => recordId != null;
@override
@ -30,6 +39,9 @@ class MasterFormPanel extends ConsumerStatefulWidget {
class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
final _formKey = GlobalKey<FormState>();
late final String _formSessionId =
widget.formSessionId ??
'${widget.masterId}-${widget.recordId ?? 'new'}-${DateTime.now().microsecondsSinceEpoch}';
MasterDefinition get _definition {
final def = masterDefinitionById(widget.masterId);
@ -37,18 +49,22 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
return def;
}
MasterFormArgs get _args =>
(masterId: widget.masterId, recordId: widget.recordId);
MasterFormArgs get _args => (
masterId: widget.masterId,
recordId: widget.recordId,
initialValues: widget.initialValues,
formSessionId: _formSessionId,
);
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final success =
final createdId =
await ref.read(masterFormProvider(_args).notifier).submit();
if (!mounted) return;
if (success) {
Navigator.of(context, rootNavigator: true).pop(true);
if (createdId != null) {
Navigator.of(context, rootNavigator: true).pop(createdId);
return;
}
@ -127,9 +143,11 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
final parentSelected = filterField == null ||
(formState.values[filterField] != null &&
formState.values[filterField].toString().isNotEmpty);
final enabled = field.staticOptions != null
? true
: parentSelected && dropdownOptions.isNotEmpty;
final optionsMasterKey = field.optionsMasterKey;
final canQuickAdd = field.staticOptions == null &&
optionsMasterKey != null &&
masterDefinitionById(optionsMasterKey) != null &&
parentSelected;
String parentLabel = 'parent';
if (filterField != null) {
for (final f in _definition.formFields) {
@ -140,9 +158,41 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
}
}
if (canQuickAdd) {
return MasterQuickAddDropdown<String>(
key: ValueKey(
'$_formSessionId-${field.key}-${filterField == null ? '' : formState.values[filterField]}',
),
masterId: optionsMasterKey,
label: _fieldLabel(field),
value: value?.toString(),
options: dropdownOptions,
hint: !parentSelected
? 'Select $parentLabel first'
: dropdownOptions.isEmpty
? 'No options available'
: 'Select ${field.label.toLowerCase()}',
searchHint: 'Search ${field.label.toLowerCase()}...',
enabled: parentSelected,
initialValues: filterField == null
? null
: {filterField: formState.values[filterField]},
refreshLookups: () {
ref
.read(masterFormProvider(_args).notifier)
.reloadDropdownOptions();
},
parseCreatedId: (id) => id,
onChanged: (selected) => notifier.updateValue(field.key, selected),
validator: field.required
? (v) => v == null ? '${field.label} is required' : null
: null,
);
}
return AppSearchableDropdown<String>(
key: ValueKey(
'${field.key}-${filterField == null ? '' : formState.values[filterField]}',
'$_formSessionId-${field.key}-${filterField == null ? '' : formState.values[filterField]}',
),
label: _fieldLabel(field),
value: value?.toString(),
@ -153,7 +203,9 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
? 'No options available'
: 'Select ${field.label.toLowerCase()}',
searchHint: 'Search ${field.label.toLowerCase()}...',
enabled: enabled,
enabled: field.staticOptions != null
? true
: parentSelected && dropdownOptions.isNotEmpty,
onChanged: (selected) => notifier.updateValue(field.key, selected),
validator: field.required
? (v) => v == null ? '${field.label} is required' : null
@ -162,8 +214,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
case MasterFieldType.number:
return TextFormField(
key: ValueKey(field.key),
initialValue: value?.toString(),
key: ValueKey('$_formSessionId-${field.key}'),
initialValue: value?.toString() ?? '',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(labelText: _fieldLabel(field)),
validator: field.required
@ -179,8 +231,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
? Validators.hsnCodeInput
: Validators.inputFormattersForFieldKey(field.key);
return TextFormField(
key: ValueKey(field.key),
initialValue: value?.toString(),
key: ValueKey('$_formSessionId-${field.key}'),
initialValue: value?.toString() ?? '',
maxLines: field.multiline ? 3 : 1,
keyboardType: isHsnCodeField
? TextInputType.number
@ -335,9 +387,12 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
data: (formState) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _buildFieldLayout(context, formState),
child: KeyedSubtree(
key: ValueKey(_formSessionId),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _buildFieldLayout(context, formState),
),
),
);
},

View File

@ -0,0 +1,488 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../domain/entities/master_definition.dart';
import '../providers/master_provider.dart';
/// Compact create form for inline Quick Add (no side panel / dialog).
class MasterInlineCreateForm extends ConsumerStatefulWidget {
const MasterInlineCreateForm({
super.key,
required this.masterId,
required this.formSessionId,
required this.onSaved,
required this.onCancel,
this.initialValues,
});
final String masterId;
final String formSessionId;
final Map<String, dynamic>? initialValues;
final ValueChanged<String> onSaved;
final VoidCallback onCancel;
@override
ConsumerState<MasterInlineCreateForm> createState() =>
_MasterInlineCreateFormState();
}
class _MasterInlineCreateFormState
extends ConsumerState<MasterInlineCreateForm> {
final _formKey = GlobalKey<FormState>();
MasterDefinition get _definition {
final def = masterDefinitionById(widget.masterId);
if (def == null) throw StateError('Unknown master: ${widget.masterId}');
return def;
}
MasterFormArgs get _args => (
masterId: widget.masterId,
recordId: null,
initialValues: widget.initialValues,
formSessionId: widget.formSessionId,
);
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final createdId =
await ref.read(masterFormProvider(_args).notifier).submit();
if (!mounted) return;
if (createdId != null) {
widget.onSaved(createdId);
return;
}
final error = ref.read(masterFormProvider(_args)).valueOrNull?.errorMessage;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
error ?? 'Failed to create ${_definition.title.toLowerCase()}',
),
),
);
}
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),
isDense: true,
floatingLabelBehavior: FloatingLabelBehavior.always,
alignLabelWithHint: true,
);
}
Widget _buildField({
required MasterFieldDef field,
required MasterFormState formState,
}) {
final notifier = ref.read(masterFormProvider(_args).notifier);
final value = formState.values[field.key];
switch (field.type) {
case MasterFieldType.boolean:
if (field.key == 'is_active') {
return SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(field.label),
value: value == true,
onChanged: (checked) => notifier.updateValue(field.key, checked),
);
}
return CheckboxListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(field.label),
value: value == true,
onChanged: (checked) =>
notifier.updateValue(field.key, checked ?? false),
);
case MasterFieldType.dropdown:
final List<AppDropdownOption<String>> dropdownOptions;
if (field.staticOptions != null) {
dropdownOptions = stringDropdownOptions(field.staticOptions!);
} else {
var options = formState.dropdownOptions[field.optionsMasterKey] ??
const <Map<String, dynamic>>[];
final filterField = field.filterByFieldKey;
if (filterField != null) {
final parentId = formState.values[filterField]?.toString();
final optionKey = field.filterByOptionKey ?? filterField;
if (parentId == null || parentId.isEmpty) {
options = const [];
} else {
options = options
.where((item) => item[optionKey]?.toString() == parentId)
.toList();
}
}
dropdownOptions = <AppDropdownOption<String>>[];
for (final item in options) {
final id = item['id']?.toString();
if (id == null || id.isEmpty) continue;
dropdownOptions.add(
AppDropdownOption(value: id, label: masterRecordLabel(item)),
);
}
}
final filterField = field.filterByFieldKey;
final parentSelected = filterField == null ||
(formState.values[filterField] != null &&
formState.values[filterField].toString().isNotEmpty);
String parentLabel = 'parent';
if (filterField != null) {
for (final f in _definition.formFields) {
if (f.key == filterField) {
parentLabel = f.label.toLowerCase();
break;
}
}
}
// AppSearchableDropdown already applies top label padding.
return AppSearchableDropdown<String>(
key: ValueKey(
'${widget.formSessionId}-${field.key}-${formState.values[filterField]}',
),
label: _fieldLabel(field),
value: value?.toString(),
options: dropdownOptions,
isDense: true,
hint: !parentSelected
? 'Select $parentLabel first'
: dropdownOptions.isEmpty
? 'No options available'
: 'Select ${field.label.toLowerCase()}',
searchHint: 'Search ${field.label.toLowerCase()}...',
enabled: field.staticOptions != null
? true
: parentSelected && dropdownOptions.isNotEmpty,
onChanged: (selected) => notifier.updateValue(field.key, selected),
validator: field.required
? (v) => v == null ? '${field.label} is required' : null
: null,
);
case MasterFieldType.number:
return _wrapField(
TextFormField(
key: ValueKey('${widget.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:
final isHsnCodeField =
widget.masterId == 'hsn_codes' && field.key == 'code';
final formatters = isHsnCodeField
? Validators.hsnCodeInput
: Validators.inputFormattersForFieldKey(field.key);
final isEmailField = field.key.trim().toLowerCase() == 'email';
final isPhoneField = field.key.trim().toLowerCase() == 'phone' ||
field.key.trim().toLowerCase() == 'mobile';
return _wrapField(
TextFormField(
key: ValueKey('${widget.formSessionId}-${field.key}'),
initialValue: value?.toString() ?? '',
maxLines: field.multiline ? 3 : 1,
keyboardType: isHsnCodeField
? TextInputType.number
: _keyboardTypeForFieldKey(field.key),
inputFormatters: formatters.isEmpty ? null : formatters,
autovalidateMode: (isEmailField || isPhoneField)
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled,
decoration: _inputDecoration(field),
validator: (v) {
if (isHsnCodeField) {
return Validators.uniqueHsnCode(
v,
existingRecords: formState.existingRecords,
currentRecordId: null,
fieldName: field.label,
);
}
if (Validators.isMasterNameFieldKey(field.key)) {
return Validators.uniqueMasterName(
v,
nameKey: field.key,
existingRecords: formState.existingRecords,
currentRecordId: null,
fieldName: field.label,
);
}
if (Validators.isMasterCodeFieldKey(field.key)) {
return Validators.uniqueMasterCode(
v,
codeKey: field.key,
existingRecords: formState.existingRecords,
currentRecordId: null,
fieldName: field.label,
);
}
if (isEmailField) {
return field.required
? Validators.email(v)
: Validators.optionalEmail(v);
}
return Validators.forFieldKey(
field.key,
v,
required: field.required,
fieldName: field.label,
);
},
onChanged: (text) => notifier.updateValue(field.key, text),
),
);
}
}
TextInputType? _keyboardTypeForFieldKey(String key) {
final normalizedKey = key.trim().toLowerCase();
if (normalizedKey == 'phone' || normalizedKey == 'mobile') {
return TextInputType.phone;
}
if (normalizedKey == 'email') {
return TextInputType.emailAddress;
}
if (normalizedKey == 'pincode' ||
normalizedKey == 'postal_code' ||
normalizedKey == 'zip') {
return TextInputType.number;
}
if (normalizedKey == 'account_number' || normalizedKey == 'account_no') {
return TextInputType.number;
}
return null;
}
List<Widget> _buildFieldRows({
required List<MasterFieldDef> fields,
required MasterFormState formState,
required bool twoColumns,
}) {
if (!twoColumns) {
return [
for (var i = 0; i < fields.length; i++) ...[
SizedBox(
width: double.infinity,
child: _buildField(field: fields[i], formState: formState),
),
if (i < fields.length - 1) const SizedBox(height: 4),
],
];
}
final rows = <Widget>[];
var i = 0;
while (i < fields.length) {
final left = fields[i];
if (left.multiline) {
rows.add(
SizedBox(
width: double.infinity,
child: _buildField(field: left, formState: formState),
),
);
if (i < fields.length - 1) rows.add(const SizedBox(height: 4));
i += 1;
continue;
}
final hasRight =
i + 1 < fields.length && !fields[i + 1].multiline;
final right = hasRight ? fields[i + 1] : null;
if (right == null) {
// Odd last field: span full width (no empty right column gap).
rows.add(
SizedBox(
width: double.infinity,
child: _buildField(field: left, formState: formState),
),
);
} else {
rows.add(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildField(field: left, formState: formState),
),
const SizedBox(width: 12),
Expanded(
child: _buildField(field: right, formState: formState),
),
],
),
);
}
final consumed = right != null ? 2 : 1;
if (i + consumed < fields.length) {
rows.add(const SizedBox(height: 4));
}
i += consumed;
}
return rows;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final formAsync = ref.watch(masterFormProvider(_args));
final def = _definition;
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
return Material(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.none,
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.28),
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.add_circle_outline,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Add ${def.title.toLowerCase()}',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
tooltip: 'Close',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: isSubmitting ? null : widget.onCancel,
icon: const Icon(Icons.close, size: 18),
),
],
),
const SizedBox(height: 4),
formAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: AppLoadingView(message: 'Preparing form...'),
),
error: (error, _) => ErrorView.fromFailure(
error is Failure
? error
: Failure.unknown(message: error.toString()),
onRetry: () => ref.invalidate(masterFormProvider(_args)),
),
data: (formState) {
final fields = def.formFields
.where((f) => f.key != 'is_active')
.toList();
final active = def.formFields
.where((f) => f.key == 'is_active')
.firstOrNull;
return Form(
key: _formKey,
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final useTwoColumns =
width.isFinite && width >= 420;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
..._buildFieldRows(
fields: fields,
formState: formState,
twoColumns: useTwoColumns,
),
if (active != null) ...[
const SizedBox(height: 4),
_buildField(
field: active,
formState: formState,
),
],
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed:
isSubmitting ? null : widget.onCancel,
child: const Text('Cancel'),
),
const SizedBox(width: 8),
AppButton(
label: 'Save',
expand: false,
icon: Icons.check,
isLoading: isSubmitting,
onPressed: isSubmitting ? null : _submit,
),
],
),
],
);
},
),
);
},
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/enums.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../providers/master_provider.dart';
import 'master_inline_create_form.dart';
/// Dropdown with inline Quick Add that expands below the field (no dialog).
///
/// When placed inside a [QuickAddInlineHost] (FormRow / SidePanelFormRow), the
/// create form is rendered at full row width under the row so every field and
/// button stays clickable. Without a host, the form expands directly below the
/// dropdown at the available column width.
class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
const MasterQuickAddDropdown({
super.key,
required this.masterId,
required this.label,
required this.value,
required this.options,
required this.onChanged,
required this.parseCreatedId,
this.validator,
this.hint,
this.searchHint,
this.enabled = true,
this.isDense = false,
this.initialValues,
this.refreshLookups,
this.addNewLabel,
});
final String masterId;
final String label;
final T? value;
final List<AppDropdownOption<T>> options;
final ValueChanged<T?> onChanged;
final T? Function(String createdId) parseCreatedId;
final String? Function(T?)? validator;
final String? hint;
final String? searchHint;
final bool enabled;
final bool isDense;
final Map<String, dynamic>? initialValues;
final VoidCallback? refreshLookups;
final String? addNewLabel;
@override
ConsumerState<MasterQuickAddDropdown<T>> createState() =>
_MasterQuickAddDropdownState<T>();
}
class _MasterQuickAddDropdownState<T>
extends ConsumerState<MasterQuickAddDropdown<T>> {
bool _expanded = false;
String? _sessionId;
final _dropdownFocus = FocusNode();
QuickAddInlineController? _host;
@override
void dispose() {
_host?.dismiss(this);
_dropdownFocus.dispose();
super.dispose();
}
bool get _canQuickAdd => ref.can('masters', PermissionAction.create);
void _openInlineForm() {
if (!_canQuickAdd) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('You do not have permission to add master data'),
),
);
return;
}
final sessionId =
'inline-${widget.masterId}-${DateTime.now().microsecondsSinceEpoch}';
ref.invalidate(
masterFormProvider((
masterId: widget.masterId,
recordId: null,
initialValues: widget.initialValues,
formSessionId: sessionId,
)),
);
_host = QuickAddInlineScope.maybeOf(context);
setState(() {
_sessionId = sessionId;
_expanded = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _publishHostForm();
});
}
void _collapse() {
if (!_expanded) return;
_host?.dismiss(this);
setState(() {
_expanded = false;
_sessionId = null;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _dropdownFocus.requestFocus();
});
}
void _onSaved(String createdId) {
widget.refreshLookups?.call();
if (createdId != 'created') {
final parsed = widget.parseCreatedId(createdId);
if (parsed != null) {
widget.onChanged(parsed);
}
}
_collapse();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'),
),
);
}
Widget _buildCreateForm() {
return MasterInlineCreateForm(
key: ValueKey(_sessionId),
masterId: widget.masterId,
formSessionId: _sessionId!,
initialValues: widget.initialValues,
onSaved: _onSaved,
onCancel: _collapse,
);
}
void _publishHostForm() {
if (!_expanded || _sessionId == null) return;
final host = _host ?? QuickAddInlineScope.maybeOf(context);
if (host == null) return;
_host = host;
host.present(owner: this, form: _buildCreateForm());
}
@override
Widget build(BuildContext context) {
final addLabel = widget.addNewLabel ?? masterQuickAddLabel(widget.masterId);
_host ??= QuickAddInlineScope.maybeOf(context);
final dropdown = Focus(
focusNode: _dropdownFocus,
child: AppSearchableDropdown<T>(
label: widget.label,
value: widget.value,
options: widget.options,
onChanged: widget.onChanged,
validator: widget.validator,
hint: widget.hint,
searchHint:
widget.searchHint ?? 'Search ${widget.label.toLowerCase()}...',
enabled: widget.enabled,
isDense: widget.isDense,
addNewLabel: _canQuickAdd ? addLabel : null,
onAddNew: !_canQuickAdd || !widget.enabled
? null
: () async => _openInlineForm(),
),
);
// Host renders the form at full row width.
if (_host != null || !_expanded || _sessionId == null) {
return dropdown;
}
// Fallback when no FormRow/SidePanelFormRow host is present.
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
dropdown,
AnimatedSize(
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 10),
child: _buildCreateForm(),
),
),
],
);
}
}
String masterQuickAddLabel(String masterId) =>
'Add ${masterQuickAddNoun(masterId)}';
String masterQuickAddNoun(String masterId) {
return switch (masterId) {
'uom' => 'UOM',
'item_categories' => 'category',
'item_subcategories' => 'subcategory',
'items' => 'item',
'hsn_codes' => 'HSN code',
'brands' => 'brand',
'plants' => 'plant',
'warehouses' => 'warehouse',
'designations' => 'designation',
'departments' => 'department',
'delivery_terms' => 'delivery term',
'payment_terms' => 'payment term',
'gst_rates' => 'GST rate',
'document_series' => 'document series',
_ => masterId.replaceAll('_', ' '),
};
}
String _titleCase(String value) {
if (value.isEmpty) return value;
return value[0].toUpperCase() + value.substring(1);
}

View File

@ -20,6 +20,7 @@ import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../data/repositories/purchase_order_repository_impl.dart';
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
import '../../../master_data/presentation/widgets/master_quick_add.dart';
import '../providers/purchase_order_lookups_provider.dart';
import '../providers/purchase_orders_provider.dart';
import '../widgets/po_status_chip.dart';
@ -420,12 +421,16 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
validator: (v) =>
v == null ? 'Vendor is required' : null,
),
AppSearchableDropdown<int>(
MasterQuickAddDropdown<int>(
masterId: 'plants',
label: 'Plant *',
value: _dropdownValue(_plantId, plantIds),
hint: 'Select plant',
searchHint: 'Search plant...',
options: _intOptions(lookups.plants),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _plantId = v),
validator: (v) =>
v == null ? 'Plant is required' : null,
@ -434,40 +439,56 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
),
FormRowFour(
children: [
AppSearchableDropdown<int?>(
MasterQuickAddDropdown<int?>(
masterId: 'warehouses',
label: 'Warehouse',
value: _warehouseId,
hint: 'Select warehouse',
searchHint: 'Search warehouse...',
options: _nullableIntOptions(lookups.warehouses),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _warehouseId = v),
),
AppSearchableDropdown<int?>(
MasterQuickAddDropdown<int?>(
masterId: 'brands',
label: 'Brand',
value: _brandId,
hint: 'Select brand',
searchHint: 'Search brand...',
options: _nullableIntOptions(lookups.brands),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _brandId = v),
),
AppSearchableDropdown<int?>(
MasterQuickAddDropdown<int?>(
masterId: 'payment_terms',
label: 'Payment term',
value: _paymentTermId,
hint: 'Select payment term',
searchHint: 'Search payment term...',
options:
_nullableIntOptions(lookups.paymentTerms),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _paymentTermId = v),
),
AppSearchableDropdown<int?>(
MasterQuickAddDropdown<int?>(
masterId: 'delivery_terms',
label: 'Delivery term',
value: _deliveryTermId,
hint: 'Select delivery term',
searchHint: 'Search delivery term...',
options:
_nullableIntOptions(lookups.deliveryTerms),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _deliveryTermId = v),
),

View File

@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../master_data/presentation/widgets/master_quick_add.dart';
import '../providers/purchase_order_lookups_provider.dart';
/// Per-line amount breakdown for PO calculations.
class PoLineCalculation {
@ -173,7 +175,7 @@ class PoLineItemDraft {
}
}
class PurchaseOrderLineItemsEditor extends StatefulWidget {
class PurchaseOrderLineItemsEditor extends ConsumerStatefulWidget {
const PurchaseOrderLineItemsEditor({
super.key,
required this.lines,
@ -202,12 +204,12 @@ class PurchaseOrderLineItemsEditor extends StatefulWidget {
final VoidCallback? onChanged;
@override
State<PurchaseOrderLineItemsEditor> createState() =>
ConsumerState<PurchaseOrderLineItemsEditor> createState() =>
_PurchaseOrderLineItemsEditorState();
}
class _PurchaseOrderLineItemsEditorState
extends State<PurchaseOrderLineItemsEditor> {
extends ConsumerState<PurchaseOrderLineItemsEditor> {
void _notifyChanged() {
widget.onChanged?.call();
setState(() {});
@ -310,7 +312,7 @@ class _PurchaseOrderLineItemsEditorState
}
}
class _LineItemCard extends StatefulWidget {
class _LineItemCard extends ConsumerStatefulWidget {
const _LineItemCard({
super.key,
required this.line,
@ -337,10 +339,10 @@ class _LineItemCard extends StatefulWidget {
final VoidCallback? onRemove;
@override
State<_LineItemCard> createState() => _LineItemCardState();
ConsumerState<_LineItemCard> createState() => _LineItemCardState();
}
class _LineItemCardState extends State<_LineItemCard> {
class _LineItemCardState extends ConsumerState<_LineItemCard> {
@override
void initState() {
super.initState();
@ -441,13 +443,17 @@ class _LineItemCardState extends State<_LineItemCard> {
spacing: 8,
stackBelowWidth: 1100,
children: [
AppSearchableDropdown<int>(
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,
),
@ -467,13 +473,17 @@ class _LineItemCardState extends State<_LineItemCard> {
return null;
},
),
AppSearchableDropdown<int>(
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,
),
@ -499,13 +509,17 @@ class _LineItemCardState extends State<_LineItemCard> {
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
),
AppSearchableDropdown<int?>(
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(

View File

@ -12,6 +12,7 @@ import '../../../../shared/widgets/app_searchable_multi_select_dropdown.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../master_data/presentation/widgets/master_quick_add.dart';
import '../providers/add_user_form_provider.dart';
class AddUserPanel extends ConsumerStatefulWidget {
@ -184,16 +185,40 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
required ValueChanged<String?> onChanged,
String? hint,
bool required = false,
String? masterId,
}) {
return AppSearchableDropdown<String>(
label: required ? '$label *' : label,
final fieldLabel = required ? '$label *' : label;
final fieldHint = hint ?? 'Select ${label.toLowerCase()}';
final fieldEnabled = options.isNotEmpty || masterId != null;
final validator =
required ? (String? v) => v == null ? 'Please select $label' : null : null;
if (masterId == null) {
return AppSearchableDropdown<String>(
label: fieldLabel,
value: value,
hint: fieldHint,
searchHint: 'Search $label...',
enabled: fieldEnabled,
options: _toOptions(options),
onChanged: onChanged,
validator: validator,
);
}
return MasterQuickAddDropdown<String>(
masterId: masterId,
label: fieldLabel,
value: value,
hint: hint ?? 'Select ${label.toLowerCase()}',
hint: fieldHint,
searchHint: 'Search $label...',
enabled: options.isNotEmpty,
enabled: fieldEnabled,
options: _toOptions(options),
refreshLookups: () =>
ref.invalidate(addUserFormProvider(widget.userId)),
parseCreatedId: (id) => id,
onChanged: onChanged,
validator: required ? (v) => v == null ? 'Please select $label' : null : null,
validator: validator,
);
}
@ -313,6 +338,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
label: 'Department',
value: _selectedDepartmentId,
options: formState.departments,
masterId: 'departments',
onChanged: (v) =>
setState(() => _selectedDepartmentId = v),
),
@ -320,6 +346,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
label: 'Designation',
value: _selectedDesignationId,
options: formState.designations,
masterId: 'designations',
onChanged: (v) =>
setState(() => _selectedDesignationId = v),
),
@ -329,6 +356,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
label: 'Plant / Unit',
value: _selectedPlantId,
options: formState.plants,
masterId: 'plants',
onChanged: (v) => setState(() => _selectedPlantId = v),
),
right: _buildDropdown(

View File

@ -11,6 +11,7 @@ import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../master_data/presentation/widgets/master_quick_add.dart';
import '../providers/users_provider.dart';
class UserFormScreen extends ConsumerStatefulWidget {
@ -223,7 +224,8 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
validator: (v) => v == null ? 'Role is required' : null,
),
const SizedBox(height: 16),
AppSearchableDropdown<int?>(
MasterQuickAddDropdown<int?>(
masterId: 'departments',
label: 'Department',
value: _selectedDepartmentId,
searchHint: 'Search department...',
@ -236,7 +238,11 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
),
),
],
onChanged: (v) => setState(() => _selectedDepartmentId = v),
refreshLookups: () =>
ref.invalidate(usersListProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _selectedDepartmentId = v),
),
const SizedBox(height: 16),
AppSearchableDropdown<String>(

View File

@ -13,6 +13,7 @@ import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../../../master_data/presentation/widgets/master_quick_add.dart';
import '../../data/repositories/vendor_repository_impl.dart';
import '../providers/vendors_provider.dart';
@ -344,7 +345,8 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
final value = _paymentTermId != null && termIds.contains(_paymentTermId)
? _paymentTermId
: null;
return AppSearchableDropdown<int>(
return MasterQuickAddDropdown<int>(
masterId: 'payment_terms',
label: 'Payment Term',
value: value,
searchHint: 'Search payment term...',
@ -357,6 +359,8 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
)
.where((option) => option.value != 0)
.toList(),
refreshLookups: () => ref.invalidate(vendorPaymentTermsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _paymentTermId = v),
);
}

View File

@ -22,6 +22,8 @@ class AppDropdown<T> extends StatelessWidget {
this.searchHint,
this.enabled = true,
this.isDense = false,
this.addNewLabel,
this.onAddNew,
});
final String label;
@ -33,6 +35,8 @@ class AppDropdown<T> extends StatelessWidget {
final String? searchHint;
final bool enabled;
final bool isDense;
final String? addNewLabel;
final Future<void> Function()? onAddNew;
@override
Widget build(BuildContext context) {
@ -46,6 +50,8 @@ class AppDropdown<T> extends StatelessWidget {
searchHint: searchHint ?? 'Search ${label.toLowerCase()}...',
enabled: enabled,
isDense: isDense,
addNewLabel: addNewLabel,
onAddNew: onAddNew,
);
}
}

View File

@ -15,6 +15,8 @@ class AppSearchableDropdown<T> extends StatefulWidget {
this.searchHint = 'Search...',
this.enabled = true,
this.isDense = false,
this.addNewLabel,
this.onAddNew,
});
final String label;
@ -27,6 +29,13 @@ class AppSearchableDropdown<T> extends StatefulWidget {
final bool enabled;
final bool isDense;
/// Shown as a footer action in the picker (e.g. "Add plant").
final String? addNewLabel;
/// Opens Quick Add / create flow. When set, the field can open even if
/// [options] is empty.
final Future<void> Function()? onAddNew;
@override
State<AppSearchableDropdown<T>> createState() =>
_AppSearchableDropdownState<T>();
@ -83,7 +92,9 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
}
void _openPicker(FormFieldState<T> field) {
if (!widget.enabled || widget.options.isEmpty) return;
final canOpen =
widget.enabled && (widget.options.isNotEmpty || widget.onAddNew != null);
if (!canOpen) return;
if (_overlayEntry != null) {
_removeOverlay();
return;
@ -153,6 +164,13 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
options: widget.options,
selected: field.value ?? widget.value,
searchHint: widget.searchHint,
addNewLabel: widget.addNewLabel,
onAddNew: widget.onAddNew == null
? null
: () async {
_removeOverlay();
await widget.onAddNew!();
},
onSelected: (value) {
_removeOverlay();
field.didChange(value);
@ -189,7 +207,8 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
validator: widget.validator,
builder: (field) {
final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}';
final canOpen = widget.enabled && widget.options.isNotEmpty;
final canOpen = widget.enabled &&
(widget.options.isNotEmpty || widget.onAddNew != null);
final colors = theme.colorScheme;
final selected = field.value ?? widget.value;
final displayLabel = _labelForValue(selected);
@ -251,6 +270,8 @@ class _SearchableDropdownPanel<T> extends StatefulWidget {
required this.selected,
required this.searchHint,
required this.onSelected,
this.addNewLabel,
this.onAddNew,
});
final double maxHeight;
@ -258,6 +279,8 @@ class _SearchableDropdownPanel<T> extends StatefulWidget {
final T? selected;
final String searchHint;
final ValueChanged<T> onSelected;
final String? addNewLabel;
final Future<void> Function()? onAddNew;
@override
State<_SearchableDropdownPanel<T>> createState() =>
@ -287,6 +310,8 @@ class _SearchableDropdownPanelState<T>
Widget build(BuildContext context) {
final theme = Theme.of(context);
final filtered = _filtered;
final addLabel = widget.addNewLabel ?? 'Quick add';
final hasAdd = widget.onAddNew != null;
return Column(
mainAxisSize: MainAxisSize.min,
@ -310,12 +335,16 @@ class _SearchableDropdownPanelState<T>
),
),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: widget.maxHeight - 56),
constraints: BoxConstraints(
maxHeight: widget.maxHeight - (hasAdd ? 108 : 56),
),
child: filtered.isEmpty
? Padding(
padding: const EdgeInsets.all(20),
child: Text(
'No options found',
hasAdd
? 'No options found. Use Quick add below.'
: 'No options found',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
@ -355,6 +384,17 @@ class _SearchableDropdownPanelState<T>
},
),
),
if (hasAdd) ...[
Divider(
height: 1,
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.5),
),
TextButton.icon(
onPressed: () => widget.onAddNew?.call(),
icon: const Icon(Icons.add, size: 18),
label: Text(addLabel),
),
],
],
);
}
@ -373,6 +413,8 @@ class AppSearchableLookupField<T> extends StatefulWidget {
this.searchHint = 'Search...',
this.enabled = true,
this.isDense = false,
this.addNewLabel,
this.onAddNew,
});
final String label;
@ -383,6 +425,8 @@ class AppSearchableLookupField<T> extends StatefulWidget {
final String searchHint;
final bool enabled;
final bool isDense;
final String? addNewLabel;
final Future<void> Function()? onAddNew;
@override
State<AppSearchableLookupField<T>> createState() =>
@ -423,7 +467,9 @@ class _AppSearchableLookupFieldState<T>
}
void _openPicker() {
if (!widget.enabled || widget.options.isEmpty) return;
final canOpen =
widget.enabled && (widget.options.isNotEmpty || widget.onAddNew != null);
if (!canOpen) return;
if (_overlayEntry != null) {
_removeOverlay();
return;
@ -495,6 +541,13 @@ class _AppSearchableLookupFieldState<T>
options: widget.options,
selected: widget.value,
searchHint: widget.searchHint,
addNewLabel: widget.addNewLabel,
onAddNew: widget.onAddNew == null
? null
: () async {
_removeOverlay();
await widget.onAddNew!();
},
onSelected: (value) {
_removeOverlay();
widget.onChanged(value);
@ -524,7 +577,8 @@ class _AppSearchableLookupFieldState<T>
final theme = Theme.of(context);
final effectiveHint =
widget.hint ?? 'Select ${widget.label.toLowerCase()}';
final canOpen = widget.enabled && widget.options.isNotEmpty;
final canOpen = widget.enabled &&
(widget.options.isNotEmpty || widget.onAddNew != null);
final colors = theme.colorScheme;
final displayLabel = _labelForValue(widget.value);

View File

@ -2,6 +2,98 @@ import 'package:flutter/material.dart';
import '../../core/utils/responsive_utils.dart';
/// Holds a full-width Quick Add form below a form row (so it stays clickable).
class QuickAddInlineController extends ChangeNotifier {
Object? _owner;
Widget? _form;
Widget? get form => _form;
void present({required Object owner, required Widget form}) {
_owner = owner;
_form = form;
notifyListeners();
}
void dismiss(Object owner) {
if (_owner != owner) return;
_owner = null;
_form = null;
notifyListeners();
}
void dismissAll() {
if (_owner == null && _form == null) return;
_owner = null;
_form = null;
notifyListeners();
}
}
class QuickAddInlineScope extends InheritedNotifier<QuickAddInlineController> {
const QuickAddInlineScope({
super.key,
required QuickAddInlineController controller,
required super.child,
}) : super(notifier: controller);
static QuickAddInlineController? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<QuickAddInlineScope>()
?.notifier;
}
}
/// Wraps a form row and renders any active Quick Add form at full row width.
class QuickAddInlineHost extends StatefulWidget {
const QuickAddInlineHost({super.key, required this.child});
final Widget child;
@override
State<QuickAddInlineHost> createState() => _QuickAddInlineHostState();
}
class _QuickAddInlineHostState extends State<QuickAddInlineHost> {
final _controller = QuickAddInlineController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return QuickAddInlineScope(
controller: _controller,
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final form = _controller.form;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
widget.child,
AnimatedSize(
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: form == null
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(top: 10, bottom: 4),
child: form,
),
),
],
);
},
),
);
}
}
Future<T?> showSidePanel<T>(
BuildContext context,
Widget panel, {
@ -181,36 +273,38 @@ class SidePanelFormRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 420;
return QuickAddInlineHost(
child: LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 420;
if (stack) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
left,
const SizedBox(height: 12),
right,
],
),
);
}
if (stack) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
left,
const SizedBox(height: 12),
right,
Expanded(child: left),
SizedBox(width: spacing),
Expanded(child: right),
],
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: left),
SizedBox(width: spacing),
Expanded(child: right),
],
),
);
},
},
),
);
}
}
@ -403,66 +497,68 @@ class FormRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final contentPadding = EdgeInsets.fromLTRB(
horizontalPadding,
0,
horizontalPadding,
spacing,
);
if (constraints.maxWidth < stackBelowWidth) {
return Padding(
padding: contentPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) SizedBox(height: spacing),
children[i],
],
],
),
return QuickAddInlineHost(
child: LayoutBuilder(
builder: (context, constraints) {
final contentPadding = EdgeInsets.fromLTRB(
horizontalPadding,
0,
horizontalPadding,
spacing,
);
}
if (columnCount != null) {
final slots = _columnSlots();
final cols = columnCount!;
final innerWidth = constraints.maxWidth - (horizontalPadding * 2);
final columnWidth = (innerWidth - (cols - 1) * spacing) / cols;
if (constraints.maxWidth < stackBelowWidth) {
return Padding(
padding: contentPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) SizedBox(height: spacing),
children[i],
],
],
),
);
}
if (columnCount != null) {
final slots = _columnSlots();
final cols = columnCount!;
final innerWidth = constraints.maxWidth - (horizontalPadding * 2);
final columnWidth = (innerWidth - (cols - 1) * spacing) / cols;
return Padding(
padding: contentPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < slots.length; i++) ...[
if (i > 0) SizedBox(width: spacing),
SizedBox(
width: _spanWidth(columnWidth, slots[i].span),
child: slots[i].child,
),
],
],
),
);
}
return Padding(
padding: contentPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < slots.length; i++) ...[
for (var i = 0; i < children.length; i++) ...[
if (i > 0) SizedBox(width: spacing),
SizedBox(
width: _spanWidth(columnWidth, slots[i].span),
child: slots[i].child,
),
Expanded(child: children[i]),
],
],
),
);
}
return Padding(
padding: contentPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) SizedBox(width: spacing),
Expanded(child: children[i]),
],
],
),
);
},
},
),
);
}
}

View File

@ -0,0 +1,344 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/validators.dart';
import '../../modules/master_data/data/repositories/master_repository_impl.dart';
import '../../modules/master_data/domain/entities/master_definition.dart';
/// Compact master create form for use inside a dropdown panel.
class MasterInlineQuickAddForm extends ConsumerStatefulWidget {
const MasterInlineQuickAddForm({
super.key,
required this.masterId,
required this.onCreated,
required this.onCancel,
this.initialValues,
});
final String masterId;
final ValueChanged<String> onCreated;
final VoidCallback onCancel;
final Map<String, dynamic>? initialValues;
@override
ConsumerState<MasterInlineQuickAddForm> createState() =>
_MasterInlineQuickAddFormState();
}
class _MasterInlineQuickAddFormState
extends ConsumerState<MasterInlineQuickAddForm> {
final _formKey = GlobalKey<FormState>();
final _values = <String, dynamic>{};
final _controllers = <String, TextEditingController>{};
var _loadingOptions = true;
var _submitting = false;
String? _error;
Map<String, List<Map<String, dynamic>>> _dropdownOptions = {};
MasterDefinition get _definition {
final def = masterDefinitionById(widget.masterId);
if (def == null) {
throw StateError('Unknown master: ${widget.masterId}');
}
return def;
}
@override
void initState() {
super.initState();
for (final field in _definition.formFields) {
if (field.type == MasterFieldType.boolean) {
_values[field.key] = field.key == 'is_active' ? true : false;
}
}
final initials = widget.initialValues;
if (initials != null) {
_values.addAll(initials);
}
for (final field in _definition.formFields) {
if (field.type == MasterFieldType.text ||
field.type == MasterFieldType.number) {
_controllers[field.key] = TextEditingController(
text: _values[field.key]?.toString() ?? '',
);
}
}
_loadOptions();
}
@override
void dispose() {
for (final controller in _controllers.values) {
controller.dispose();
}
super.dispose();
}
Future<void> _loadOptions() async {
final options = <String, List<Map<String, dynamic>>>{};
final keys = _definition.formFields
.where((f) => f.optionsMasterKey != null && f.staticOptions == null)
.map((f) => f.optionsMasterKey!)
.toSet();
for (final key in keys) {
final def = masterDefinitionById(key);
if (def == null) continue;
final result = await ref.read(masterRepositoryProvider).listOptions(def);
if (result.failure == null) {
options[key] = result.data ?? const [];
}
}
if (!mounted) return;
setState(() {
_dropdownOptions = options;
_loadingOptions = false;
});
}
Map<String, dynamic> _buildPayload() {
final payload = <String, dynamic>{};
for (final field in _definition.formFields) {
var value = _values[field.key];
if (field.type == MasterFieldType.text ||
field.type == MasterFieldType.number) {
value = _controllers[field.key]?.text.trim();
}
if (value == null || value == '') continue;
payload[field.key] = switch (field.type) {
MasterFieldType.number => num.tryParse(value.toString()) ?? value,
MasterFieldType.dropdown => int.tryParse(value.toString()) ?? value,
MasterFieldType.boolean => value == true,
MasterFieldType.text => value.toString().trim(),
};
}
return payload;
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_submitting = true;
_error = null;
});
final result = await ref.read(masterRepositoryProvider).create(
_definition,
_buildPayload(),
);
if (!mounted) return;
if (result.failure != null) {
setState(() {
_submitting = false;
_error = result.failure!.message;
});
return;
}
final id = result.data?['id']?.toString();
if (id == null || id.isEmpty) {
setState(() {
_submitting = false;
_error = 'Created, but no id was returned';
});
return;
}
widget.onCreated(id);
}
List<Map<String, dynamic>> _optionsFor(MasterFieldDef field) {
if (field.staticOptions != null) {
return field.staticOptions!
.map((v) => <String, dynamic>{'id': v, 'name': v})
.toList();
}
var options =
_dropdownOptions[field.optionsMasterKey] ?? const <Map<String, dynamic>>[];
final filterField = field.filterByFieldKey;
if (filterField != null) {
final parentId = _values[filterField]?.toString();
final optionKey = field.filterByOptionKey ?? filterField;
if (parentId == null || parentId.isEmpty) return const [];
options = options
.where((item) => item[optionKey]?.toString() == parentId)
.toList();
}
return options;
}
Widget _buildField(MasterFieldDef field) {
final prefilled = widget.initialValues?.containsKey(field.key) == true;
switch (field.type) {
case MasterFieldType.boolean:
if (field.key == 'is_active') {
return SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(field.label, style: const TextStyle(fontSize: 13)),
value: _values[field.key] == true,
onChanged: _submitting
? null
: (v) => setState(() => _values[field.key] = v),
);
}
return CheckboxListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(field.label, style: const TextStyle(fontSize: 13)),
value: _values[field.key] == true,
onChanged: _submitting
? null
: (v) => setState(() => _values[field.key] = v ?? false),
);
case MasterFieldType.dropdown:
final options = _optionsFor(field);
final current = _values[field.key]?.toString();
return DropdownButtonFormField<String>(
// ignore: deprecated_member_use
value: options.any((o) => o['id']?.toString() == current)
? current
: null,
isDense: true,
decoration: InputDecoration(
labelText: field.required ? '${field.label} *' : field.label,
isDense: true,
),
items: [
for (final item in options)
if (item['id'] != null)
DropdownMenuItem(
value: item['id'].toString(),
child: Text(
(item['name'] ?? item['label'] ?? item['id']).toString(),
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (_submitting || prefilled)
? null
: (v) => setState(() => _values[field.key] = v),
validator: field.required
? (v) => v == null ? '${field.label} is required' : null
: null,
);
case MasterFieldType.number:
case MasterFieldType.text:
return TextFormField(
controller: _controllers[field.key],
enabled: !_submitting && !prefilled,
maxLines: field.multiline ? 2 : 1,
keyboardType: field.type == MasterFieldType.number
? const TextInputType.numberWithOptions(decimal: true)
: null,
decoration: InputDecoration(
labelText: field.required ? '${field.label} *' : field.label,
isDense: true,
),
validator: field.required
? (v) => Validators.required(v, fieldName: field.label)
: null,
onChanged: (text) => _values[field.key] = text,
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fields = _definition.formFields;
return Material(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(Icons.add_circle_outline,
size: 16, color: theme.colorScheme.primary),
const SizedBox(width: 6),
Expanded(
child: Text(
'Add ${_definition.title.toLowerCase()}',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
tooltip: 'Cancel',
visualDensity: VisualDensity.compact,
onPressed: _submitting ? null : widget.onCancel,
icon: const Icon(Icons.close, size: 18),
),
],
),
if (_loadingOptions)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
else ...[
for (final field in fields) ...[
_buildField(field),
const SizedBox(height: 8),
],
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
),
Row(
children: [
const Spacer(),
TextButton(
onPressed: _submitting ? null : widget.onCancel,
child: const Text('Cancel'),
),
const SizedBox(width: 4),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Save'),
),
],
),
],
],
),
),
),
);
}
}