quick add option
This commit is contained in:
parent
7a6bde09dd
commit
1594274fa2
@ -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,
|
||||
);
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
],
|
||||
),
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
344
lib/shared/widgets/master_inline_quick_add_form.dart
Normal file
344
lib/shared/widgets/master_inline_quick_add_form.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user