From 1e8b6112286c316bc0f891eb679d3a9f7a097f65 Mon Sep 17 00:00:00 2001 From: Gaurav Kumar Date: Tue, 23 Sep 2025 08:57:16 +0530 Subject: [PATCH] entityfield --- .../fields/realted_entity_insert_field.dart | 651 ++++++++++++++++++ .../BuilderField/shared/ui/entity_list.dart | 52 +- 2 files changed, 675 insertions(+), 28 deletions(-) create mode 100644 base_project/lib/BuilderField/shared/fields/realted_entity_insert_field.dart diff --git a/base_project/lib/BuilderField/shared/fields/realted_entity_insert_field.dart b/base_project/lib/BuilderField/shared/fields/realted_entity_insert_field.dart new file mode 100644 index 0000000..6a45438 --- /dev/null +++ b/base_project/lib/BuilderField/shared/fields/realted_entity_insert_field.dart @@ -0,0 +1,651 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/constants/ui_constants.dart'; +import '../../../core/providers/dynamic_theme_provider.dart'; +import '../ui/entity_form.dart'; +import 'autocomplete_dropdown_field.dart' as FAutoDropdown; +import 'autocomplete_multiselect_field.dart' as FAutoMultiSelect; +import 'base_field.dart'; +import 'captcha_field.dart' as FCaptcha; +import 'checkbox_field.dart' as FCheckbox; +import 'custom_text_field.dart' as FText; +import 'date_field.dart' as FDate; +import 'datetime_field.dart' as FDateTime; +import 'dropdown_field.dart' as FDropdown; +import 'email_field.dart' as FEmail; +import 'number_field.dart' as FNumber; +import 'password_field.dart' as FPassword; +import 'phone_field.dart' as FPhone; +import 'switch_field.dart' as FSwitch; +import 'url_field.dart' as FUrl; + +/// A generic field that shows an Insert button to create a related entity inline. +/// +/// Usage: +/// - Provide `fieldKey` for where to store the selected/created value +/// - Provide `label` for display +/// - Provide `relationTitle` for the modal header +/// - Either provide `fieldSchema` (preferred, like OneToOne/OneToMany) or `formFieldsBuilder` +/// - Provide `onCreate` callback which receives form data and returns created entity map +/// - Configure `valueKey` to pick what to write back (e.g., 'id') into the parent controller +/// - Optionally set `displayKey` to show a summary of the selected value +class RelatedEntityInsertField extends BaseField { + final String fieldKey; + final String label; + final String hint; + final bool isRequired; + final String relationTitle; + final Future> Function(Map formData) + onCreate; + final List>? fieldSchema; + final List Function()? formFieldsBuilder; + final String valueKey; + final String? displayKey; + + RelatedEntityInsertField({ + required this.fieldKey, + required this.label, + this.hint = '', + this.isRequired = false, + required this.relationTitle, + required this.onCreate, + this.fieldSchema, + this.formFieldsBuilder, + this.valueKey = 'id', + this.displayKey, + }) : assert(fieldSchema != null || formFieldsBuilder != null, + 'Either fieldSchema or formFieldsBuilder must be provided'); + + @override + String? Function(String?)? get validator => (value) { + if (!isRequired) return null; + if (value == null || value.trim().isEmpty) return '$label is required'; + return null; + }; + + @override + Map? get customProperties => { + 'isRelationInsert': true, + 'relationTitle': relationTitle, + 'valueKey': valueKey, + 'displayKey': displayKey, + }; + + @override + Widget buildField({ + required TextEditingController controller, + required ColorScheme colorScheme, + VoidCallback? onChanged, + }) { + return _RelatedEntityInsertWidget( + label: label, + hint: hint, + controller: controller, + colorScheme: colorScheme, + relationTitle: relationTitle, + onCreate: onCreate, + fieldSchema: fieldSchema, + formFieldsBuilder: formFieldsBuilder, + valueKey: valueKey, + displayKey: displayKey, + validate: validator, + onChanged: onChanged, + ); + } + + /// Static helper to open the insert dialog anywhere (e.g., in list actions) + /// Returns the written value (e.g., created id as string) or null if cancelled + static Future showInsertDialog({ + required BuildContext context, + required String relationTitle, + required Future> Function(Map) + onCreate, + List>? fieldSchema, + List Function()? formFieldsBuilder, + String valueKey = 'id', + String? displayKey, + }) async { + final TextEditingController ctrl = TextEditingController(); + bool isSubmitting = false; + final List fields = (formFieldsBuilder != null) + ? formFieldsBuilder() + : _fieldsFromSchemaStatic(fieldSchema ?? const []); + + final Map? created = + await showDialog>( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return Consumer( + builder: (context, theme, child) { + final cs = theme.getCurrentColorScheme( + Theme.of(dialogContext).brightness == Brightness.dark); + return StatefulBuilder( + builder: (context, setLocal) { + return AlertDialog( + backgroundColor: cs.surface, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(UIConstants.radius16)), + title: Text( + relationTitle, + style: TextStyle( + color: cs.onSurface, fontWeight: FontWeight.w700), + ), + content: SingleChildScrollView( + child: EntityForm( + fields: fields, + onSubmit: (formData) async { + if (isSubmitting) return; + setLocal(() => isSubmitting = true); + try { + final res = await onCreate(formData); + if (!Navigator.of(dialogContext).mounted) return; + Navigator.of(dialogContext).pop(res); + } catch (_) { + setLocal(() => isSubmitting = false); + } + }, + submitButtonText: 'Create', + isLoading: isSubmitting, + ), + ), + actions: [ + TextButton( + onPressed: isSubmitting + ? null + : () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + }, + ); + }, + ); + + if (created != null) { + ctrl.text = (created[valueKey])?.toString() ?? ''; + return ctrl.text.isEmpty ? null : ctrl.text; + } + return null; + } + + // Static schema → fields mapper for the dialog helper + static List _fieldsFromSchemaStatic( + List> schema) { + return schema.map((f) { + final String type = f['type']?.toString() ?? 'text'; + final String flabel = f['label']?.toString() ?? ''; + final String fhint = f['hint']?.toString() ?? ''; + final String path = f['path']?.toString() ?? ''; + final bool requiredField = f['required'] == true; + final List>? options = + (f['options'] as List?)?.cast>(); + final String valueKey = f['valueKey']?.toString() ?? 'id'; + final String displayKey = f['displayKey']?.toString() ?? 'name'; + final int? maxLength = + f['maxLength'] is int ? f['maxLength'] as int : null; + final int? decimalPlaces = + f['decimalPlaces'] is int ? f['decimalPlaces'] as int : null; + final double? min = + (f['min'] is num) ? (f['min'] as num).toDouble() : null; + final double? max = + (f['max'] is num) ? (f['max'] as num).toDouble() : null; + + switch (type) { + case 'number': + return FNumber.NumberField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + min: min, + max: max, + decimalPlaces: decimalPlaces, + ); + case 'email': + return FEmail.EmailField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'phone': + return FPhone.PhoneField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'password': + return FPassword.PasswordField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'dropdown': + return FDropdown.DropdownField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + options: options ?? const [], + valueKey: valueKey, + displayKey: displayKey, + ); + case 'date': + return FDate.DateField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'datetime': + return FDateTime.DateTimeField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'switch': + return FSwitch.SwitchField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'checkbox': + return FCheckbox.CheckboxField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'captcha': + return FCaptcha.CaptchaField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'url': + return FUrl.UrlField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'autocomplete_dropdown': + return FAutoDropdown.AutocompleteDropdownField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + optionsLoader: () async => options ?? [], + valueKey: valueKey, + displayKey: displayKey, + ); + case 'autocomplete_multiselect': + return FAutoMultiSelect.AutocompleteMultiSelectField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + optionsLoader: () async => options ?? [], + valueKey: valueKey, + displayKey: displayKey, + ); + case 'text': + default: + return FText.CustomTextField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + maxLength: maxLength, + ); + } + }).toList(); + } +} + +class _RelatedEntityInsertWidget extends StatefulWidget { + final String label; + final String hint; + final TextEditingController controller; + final ColorScheme colorScheme; + final String relationTitle; + final Future> Function(Map) onCreate; + final List>? fieldSchema; + final List Function()? formFieldsBuilder; + final String valueKey; + final String? displayKey; + final String? Function(String?)? validate; + final VoidCallback? onChanged; + + const _RelatedEntityInsertWidget({ + required this.label, + required this.hint, + required this.controller, + required this.colorScheme, + required this.relationTitle, + required this.onCreate, + this.fieldSchema, + this.formFieldsBuilder, + required this.valueKey, + required this.displayKey, + required this.validate, + required this.onChanged, + }); + + @override + State<_RelatedEntityInsertWidget> createState() => + _RelatedEntityInsertWidgetState(); +} + +class _RelatedEntityInsertWidgetState + extends State<_RelatedEntityInsertWidget> { + bool _creating = false; + + @override + Widget build(BuildContext context) { + final cs = widget.colorScheme; + final String selectedDisplay = _computeDisplay(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.label.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + widget.label, + style: TextStyle( + fontWeight: FontWeight.w700, + color: cs.onSurface, + fontSize: 14), + ), + ), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.primary.withOpacity(0.15)), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + cs.surface, + cs.surface.withOpacity(0.97), + ], + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Expanded( + child: Text( + selectedDisplay.isEmpty + ? (widget.hint.isEmpty ? 'No selection' : widget.hint) + : selectedDisplay, + style: TextStyle( + color: cs.onSurface.withOpacity(0.75), + fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _creating ? null : _openInsertDialog, + icon: Icon(Icons.add_rounded, color: cs.onPrimary, size: 18), + label: Text('Insert', + style: TextStyle( + color: cs.onPrimary, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: cs.primary, + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ), + ), + if (widget.validate != null) + Builder( + builder: (context) { + final error = widget.validate!(widget.controller.text); + return error != null + ? Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(error, + style: TextStyle( + color: cs.error, + fontSize: 12, + fontWeight: FontWeight.w600)), + ) + : const SizedBox.shrink(); + }, + ) + ], + ); + } + + String _computeDisplay() { + if (widget.displayKey == null || widget.displayKey!.isEmpty) { + return widget.controller.text.trim(); + } + return widget.controller.text.trim(); + } + + Future _openInsertDialog() async { + setState(() => _creating = true); + try { + final List fields = (widget.formFieldsBuilder != null) + ? widget.formFieldsBuilder!() + : _fieldsFromSchema(widget.fieldSchema ?? const []); + + final result = await showDialog>( + context: context, + barrierDismissible: false, + builder: (context) { + bool isSubmitting = false; + return StatefulBuilder( + builder: (context, setLocal) { + return AlertDialog( + backgroundColor: Theme.of(context).colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16)), + title: Text( + widget.relationTitle, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + ), + content: SingleChildScrollView( + child: EntityForm( + fields: fields, + onSubmit: (formData) async { + if (isSubmitting) return; + setLocal(() => isSubmitting = true); + try { + final created = await widget.onCreate(formData); + if (!mounted) return; + Navigator.of(context).pop(created); + } catch (_) { + setLocal(() => isSubmitting = false); + } + }, + submitButtonText: 'Create', + isLoading: isSubmitting, + ), + ), + actions: [ + TextButton( + onPressed: + isSubmitting ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + }, + ); + + if (result != null) { + final dynamic value = result[widget.valueKey]; + final String textValue = value?.toString() ?? ''; + widget.controller.text = textValue; + widget.onChanged?.call(); + EntityFormScope.of(context)?.notifyParent(); + } + } finally { + if (mounted) setState(() => _creating = false); + } + } + + List _fieldsFromSchema(List> schema) { + return schema.map((f) { + final String type = f['type']?.toString() ?? 'text'; + final String flabel = f['label']?.toString() ?? ''; + final String fhint = f['hint']?.toString() ?? ''; + final String path = f['path']?.toString() ?? ''; + final bool requiredField = f['required'] == true; + final List>? options = + (f['options'] as List?)?.cast>(); + final String valueKey = f['valueKey']?.toString() ?? 'id'; + final String displayKey = f['displayKey']?.toString() ?? 'name'; + final int? maxLength = + f['maxLength'] is int ? f['maxLength'] as int : null; + final int? decimalPlaces = + f['decimalPlaces'] is int ? f['decimalPlaces'] as int : null; + final double? min = + (f['min'] is num) ? (f['min'] as num).toDouble() : null; + final double? max = + (f['max'] is num) ? (f['max'] as num).toDouble() : null; + + switch (type) { + case 'number': + return FNumber.NumberField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + min: min, + max: max, + decimalPlaces: decimalPlaces, + ); + case 'email': + return FEmail.EmailField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'phone': + return FPhone.PhoneField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'password': + return FPassword.PasswordField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'dropdown': + return FDropdown.DropdownField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + options: options ?? const [], + valueKey: valueKey, + displayKey: displayKey, + ); + case 'date': + return FDate.DateField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'datetime': + return FDateTime.DateTimeField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'switch': + return FSwitch.SwitchField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'checkbox': + return FCheckbox.CheckboxField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'captcha': + return FCaptcha.CaptchaField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'url': + return FUrl.UrlField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ); + case 'autocomplete_dropdown': + return FAutoDropdown.AutocompleteDropdownField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + optionsLoader: () async => options ?? [], + valueKey: valueKey, + displayKey: displayKey, + ); + case 'autocomplete_multiselect': + return FAutoMultiSelect.AutocompleteMultiSelectField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + optionsLoader: () async => options ?? [], + valueKey: valueKey, + displayKey: displayKey, + ); + case 'text': + default: + return FText.CustomTextField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + maxLength: maxLength, + ); + } + }).toList(); + } +} diff --git a/base_project/lib/BuilderField/shared/ui/entity_list.dart b/base_project/lib/BuilderField/shared/ui/entity_list.dart index 69a1420..e7ba31e 100644 --- a/base_project/lib/BuilderField/shared/ui/entity_list.dart +++ b/base_project/lib/BuilderField/shared/ui/entity_list.dart @@ -1,6 +1,6 @@ -import 'package:base_project/core/providers/dynamic_theme_provider.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../../core/providers/dynamic_theme_provider.dart'; import '../../../core/constants/ui_constants.dart'; import '../../../shared/widgets/app_bar/modern_app_bar.dart'; import 'entity_card.dart'; @@ -22,6 +22,9 @@ class EntityList extends StatefulWidget { final String title; final Function()? onAddNew; final List> displayFields; + // Optional: header insert action (e.g., Childform/Insert Support) + final Function()? onInsertAction; + final String? insertActionLabel; const EntityList({ super.key, @@ -39,6 +42,8 @@ class EntityList extends StatefulWidget { required this.title, this.onAddNew, this.displayFields = const [], + this.onInsertAction, + this.insertActionLabel, }); @override @@ -97,13 +102,21 @@ class _EntityListState extends State with TickerProviderStateMixin { title: widget.title, showBackButton: true, actions: [ + if (widget.onInsertAction != null) + TextButton( + onPressed: widget.onInsertAction, + child: Text( + widget.insertActionLabel ?? 'Unknown', + style: TextStyle( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), if (widget.onAddNew != null) IconButton( onPressed: widget.onAddNew, - icon: Icon( - Icons.add_rounded, - color: colorScheme.primary, - ), + icon: Icon(Icons.add_rounded, color: colorScheme.primary), tooltip: 'Add New', ), ], @@ -114,9 +127,7 @@ class _EntityListState extends State with TickerProviderStateMixin { _buildSearchBar(colorScheme), // Content - Expanded( - child: _buildContent(colorScheme), - ), + Expanded(child: _buildContent(colorScheme)), ], ), ); @@ -136,10 +147,7 @@ class _EntityListState extends State with TickerProviderStateMixin { gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [ - colorScheme.surface, - colorScheme.surface.withOpacity(0.95), - ], + colors: [colorScheme.surface, colorScheme.surface.withOpacity(0.95)], ), borderRadius: BorderRadius.circular(UIConstants.radius16), border: Border.all( @@ -234,10 +242,7 @@ class _EntityListState extends State with TickerProviderStateMixin { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator( - color: colorScheme.primary, - strokeWidth: 3, - ), + CircularProgressIndicator(color: colorScheme.primary, strokeWidth: 3), const SizedBox(height: UIConstants.spacing16), Text( 'Loading ${widget.title.toLowerCase()}...', @@ -292,10 +297,7 @@ class _EntityListState extends State with TickerProviderStateMixin { const SizedBox(height: UIConstants.spacing24), ElevatedButton.icon( onPressed: widget.onRefresh, - icon: Icon( - Icons.refresh_rounded, - color: colorScheme.onPrimary, - ), + icon: Icon(Icons.refresh_rounded, color: colorScheme.onPrimary), label: Text( 'Retry', style: TextStyle( @@ -365,10 +367,7 @@ class _EntityListState extends State with TickerProviderStateMixin { const SizedBox(height: UIConstants.spacing24), ElevatedButton.icon( onPressed: widget.onAddNew, - icon: Icon( - Icons.add_rounded, - color: colorScheme.onPrimary, - ), + icon: Icon(Icons.add_rounded, color: colorScheme.onPrimary), label: Text( 'Add ${widget.title}', style: TextStyle( @@ -454,10 +453,7 @@ class _EntityListState extends State with TickerProviderStateMixin { gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [ - colorScheme.surface, - colorScheme.surface.withOpacity(0.95), - ], + colors: [colorScheme.surface, colorScheme.surface.withOpacity(0.95)], ), borderRadius: BorderRadius.circular(UIConstants.radius20), border: Border.all(