diff --git a/base_project/lib/BuilderField/shared/fields/autocomplete_multiselect_field.dart b/base_project/lib/BuilderField/shared/fields/autocomplete_multiselect_field.dart index e622c3b..e5b1e8e 100644 --- a/base_project/lib/BuilderField/shared/fields/autocomplete_multiselect_field.dart +++ b/base_project/lib/BuilderField/shared/fields/autocomplete_multiselect_field.dart @@ -7,15 +7,20 @@ class AutocompleteMultiSelectField extends BaseField { final String label; final String hint; final bool isRequired; - final Future> Function() optionsLoader; + final Future>> Function() optionsLoader; + final String valueKey; + final String displayKey; AutocompleteMultiSelectField({ required this.fieldKey, required this.label, required this.hint, required this.optionsLoader, + required this.valueKey, + required this.displayKey, this.isRequired = false, - }); + }) : assert(valueKey != ''), + assert(displayKey != ''); @override String? Function(String?)? get validator => (value) { @@ -25,162 +30,293 @@ class AutocompleteMultiSelectField extends BaseField { return null; }; - @override - Map? get customProperties => const { - 'isMultiSelect': true, - }; - @override Widget buildField({ required TextEditingController controller, required ColorScheme colorScheme, VoidCallback? onChanged, }) { - return FutureBuilder>( - future: optionsLoader(), - builder: (context, snapshot) { - final options = snapshot.data ?? const []; - final Set selected = controller.text.isEmpty - ? {} - : controller.text - .split(',') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toSet(); - - void toggleSelection(String value) { - if (selected.contains(value)) { - selected.remove(value); - } else { - selected.add(value); - } - controller.text = selected.join(','); - onChanged?.call(); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Autocomplete( - optionsBuilder: (TextEditingValue tev) { - if (tev.text.isEmpty) return const Iterable.empty(); - return options.where((opt) => - opt.toLowerCase().contains(tev.text.toLowerCase())); - }, - onSelected: (String selection) => toggleSelection(selection), - fieldViewBuilder: - (context, textController, focusNode, onFieldSubmitted) { - return ModernTextField( - label: label, - hint: hint, - controller: textController, - focusNode: focusNode, - onSubmitted: (_) { - final v = textController.text.trim(); - if (v.isNotEmpty) { - toggleSelection(v); - textController.clear(); - } - }, - suffixIcon: textController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - textController.clear(); - onChanged?.call(); - }, - ) - : const Icon(Icons.search), - onChanged: (_) => onChanged?.call(), - ); - }, - optionsViewBuilder: (context, onSelected, optionsIt) { - final list = optionsIt.toList(); - return Align( - alignment: Alignment.topLeft, - child: Material( - elevation: 6, - color: colorScheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: colorScheme.outline.withOpacity(0.15)), - ), - child: ConstrainedBox( - constraints: - const BoxConstraints(maxHeight: 280, minWidth: 240), - child: list.isEmpty - ? Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.info_outline, - color: colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - 'No results', - style: TextStyle( - color: colorScheme.onSurfaceVariant), - ), - ), - ], - ), - ) - : ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: list.length, - separatorBuilder: (_, __) => Divider( - height: 1, - color: colorScheme.outline.withOpacity(0.08)), - itemBuilder: (context, index) { - final opt = list[index]; - return ListTile( - title: Text(opt), - onTap: () { - onSelected(opt); - }, - ); - }, - ), - ), - ), - ); - }, - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: selected - .map((value) => Chip( - label: Text(value), - onDeleted: () => toggleSelection(value), - )) - .toList(), - ), - if (snapshot.connectionState == ConnectionState.waiting) - const Padding( - padding: EdgeInsets.only(top: 8.0), - child: LinearProgressIndicator(minHeight: 2), - ), - if (validator != null) - Builder( - builder: (context) { - final error = validator!(controller.text); - return error != null - ? Padding( - padding: const EdgeInsets.only(top: 6), - child: Text(error, - style: TextStyle( - color: colorScheme.error, fontSize: 12)), - ) - : const SizedBox.shrink(); - }, - ) - ], - ); - }, + return _AutocompleteMultiSelectWidget( + fieldKey: fieldKey, + label: label, + hint: hint, + isRequired: isRequired, + optionsLoader: optionsLoader, + valueKey: valueKey, + displayKey: displayKey, + controller: controller, + colorScheme: colorScheme, + onChanged: onChanged, + validator: validator, + ); + } +} + +class _AutocompleteMultiSelectWidget extends StatefulWidget { + final String fieldKey; + final String label; + final String hint; + final bool isRequired; + final Future>> Function() optionsLoader; + final String valueKey; + final String displayKey; + final TextEditingController controller; + final ColorScheme colorScheme; + final VoidCallback? onChanged; + final String? Function(String?)? validator; + + const _AutocompleteMultiSelectWidget({ + required this.fieldKey, + required this.label, + required this.hint, + required this.isRequired, + required this.optionsLoader, + required this.valueKey, + required this.displayKey, + required this.controller, + required this.colorScheme, + required this.onChanged, + required this.validator, + }); + + @override + State<_AutocompleteMultiSelectWidget> createState() => + _AutocompleteMultiSelectWidgetState(); +} + +class _AutocompleteMultiSelectWidgetState + extends State<_AutocompleteMultiSelectWidget> { + List>? _cachedOptions; + bool _isLoading = false; + bool _hasLoaded = false; + + @override + String? Function(String?)? get validator => (value) { + if (widget.isRequired && (value == null || value.isEmpty)) { + return '${widget.label} is required'; + } + return null; + }; + + @override + Map? get customProperties => { + 'isMultiSelect': true, + 'valueKey': widget.valueKey, + 'displayKey': widget.displayKey, + }; + + Future _loadOptions() async { + if (_hasLoaded || _isLoading) return; + + setState(() { + _isLoading = true; + }); + + try { + final options = await widget.optionsLoader(); + if (mounted) { + setState(() { + _cachedOptions = options; + _isLoading = false; + _hasLoaded = true; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + // Load options when widget is first built + if (!_hasLoaded && !_isLoading) { + _loadOptions(); + } + + final List> options = _cachedOptions ?? const []; + + // Get display options for autocomplete + final Iterable displayOptions = options + .map((e) => e[widget.displayKey]) + .where((e) => e != null) + .map((e) => e.toString()); + + // Parse selected values (stored as comma-separated IDs) + final Set selectedIds = widget.controller.text.isEmpty + ? {} + : widget.controller.text + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toSet(); + + // Get selected display values for chips + final Set selectedDisplays = selectedIds + .map((id) { + final match = options.firstWhere( + (o) => (o[widget.valueKey]?.toString() ?? '') == id, + orElse: () => const {}, + ); + return match.isNotEmpty + ? (match[widget.displayKey]?.toString() ?? '') + : ''; + }) + .where((display) => display.isNotEmpty) + .toSet(); + + void toggleSelection(String displayValue) { + // Find the corresponding ID for this display value + final match = options.firstWhere( + (o) => (o[widget.displayKey]?.toString() ?? '') == displayValue, + orElse: () => const {}, + ); + + if (match.isNotEmpty) { + final id = match[widget.valueKey]?.toString() ?? ''; + if (selectedIds.contains(id)) { + selectedIds.remove(id); + } else { + selectedIds.add(id); + } + widget.controller.text = selectedIds.join(','); + widget.onChanged?.call(); + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Autocomplete( + optionsBuilder: (TextEditingValue tev) { + if (tev.text.isEmpty) return const Iterable.empty(); + return displayOptions.where( + (opt) => opt.toLowerCase().contains(tev.text.toLowerCase())); + }, + onSelected: (String selection) => toggleSelection(selection), + fieldViewBuilder: + (context, textController, focusNode, onFieldSubmitted) { + return ModernTextField( + label: widget.label, + hint: widget.hint, + controller: textController, + focusNode: focusNode, + onSubmitted: (_) { + final v = textController.text.trim(); + if (v.isNotEmpty) { + // Check if the entered text matches any display option + final match = displayOptions.firstWhere( + (opt) => opt.toLowerCase() == v.toLowerCase(), + orElse: () => '', + ); + if (match.isNotEmpty) { + toggleSelection(match); + textController.clear(); + } + } + }, + suffixIcon: textController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + textController.clear(); + widget.onChanged?.call(); + }, + ) + : const Icon(Icons.search), + onChanged: (_) => widget.onChanged?.call(), + ); + }, + optionsViewBuilder: (context, onSelected, optionsIt) { + final list = optionsIt.toList(); + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 6, + color: widget.colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: widget.colorScheme.outline.withOpacity(0.15)), + ), + child: ConstrainedBox( + constraints: + const BoxConstraints(maxHeight: 280, minWidth: 240), + child: list.isEmpty + ? Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.info_outline, + color: widget.colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + 'No results', + style: TextStyle( + color: + widget.colorScheme.onSurfaceVariant), + ), + ), + ], + ), + ) + : ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: list.length, + separatorBuilder: (_, __) => Divider( + height: 1, + color: + widget.colorScheme.outline.withOpacity(0.08)), + itemBuilder: (context, index) { + final opt = list[index]; + return ListTile( + title: Text(opt), + onTap: () { + onSelected(opt); + }, + ); + }, + ), + ), + ), + ); + }, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: selectedDisplays + .map((displayValue) => Chip( + label: Text(displayValue), + onDeleted: () => toggleSelection(displayValue), + )) + .toList(), + ), + if (_isLoading) + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: LinearProgressIndicator(minHeight: 2), + ), + if (widget.validator != null) + Builder( + builder: (context) { + final error = widget.validator!(widget.controller.text); + return error != null + ? Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(error, + style: TextStyle( + color: widget.colorScheme.error, fontSize: 12)), + ) + : const SizedBox.shrink(); + }, + ) + ], ); } } diff --git a/base_project/lib/BuilderField/shared/fields/one_to_many_field.dart b/base_project/lib/BuilderField/shared/fields/one_to_many_field.dart index 75717d8..5e05bba 100644 --- a/base_project/lib/BuilderField/shared/fields/one_to_many_field.dart +++ b/base_project/lib/BuilderField/shared/fields/one_to_many_field.dart @@ -1,8 +1,21 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'base_field.dart'; import '../ui/entity_form.dart'; +import 'dart:convert'; +import 'custom_text_field.dart' as FText; +import 'number_field.dart' as FNumber; +import 'email_field.dart' as FEmail; +import 'phone_field.dart' as FPhone; +import 'password_field.dart' as FPassword; +import 'dropdown_field.dart' as FDropdown; +import 'date_field.dart' as FDate; +import 'datetime_field.dart' as FDateTime; +import 'switch_field.dart' as FSwitch; +import 'checkbox_field.dart' as FCheckbox; +import 'captcha_field.dart' as FCaptcha; +import 'url_field.dart' as FUrl; +import 'autocomplete_dropdown_field.dart' as FAutoDropdown; +import 'autocomplete_multiselect_field.dart' as FAutoMultiSelect; /// One-to-many subform builder /// - Renders repeatable group of sub-fields @@ -12,15 +25,18 @@ class OneToManyField extends BaseField { final String label; final String hint; final bool isRequired; - final List subFields; + final List? subFields; + final List>? fieldSchema; OneToManyField({ required this.fieldKey, required this.label, this.hint = '', this.isRequired = false, - required this.subFields, - }); + this.subFields, + this.fieldSchema, + }) : assert(subFields != null || fieldSchema != null, + 'Either subFields or fieldSchema must be provided'); @override String? Function(String?)? get validator => (value) { @@ -29,11 +45,14 @@ class OneToManyField extends BaseField { } return null; }; + @override - Map? get customProperties => const { - // Keep submission optional/safe until backend expects JSON - 'excludeFromSubmit': true, + Map? get customProperties => { + 'isOneToMany': true, + 'excludeFromSubmit': false, + 'parseAsJson': true, // Parse JSON string to object before submission }; + @override Widget buildField({ required TextEditingController controller, @@ -45,6 +64,7 @@ class OneToManyField extends BaseField { controller: controller, colorScheme: colorScheme, subFields: subFields, + fieldSchema: fieldSchema, validate: validator, onChanged: onChanged, ); @@ -55,7 +75,8 @@ class _OneToManyWidget extends StatefulWidget { final String label; final TextEditingController controller; final ColorScheme colorScheme; - final List subFields; + final List? subFields; + final List>? fieldSchema; final String? Function(String?)? validate; final VoidCallback? onChanged; @@ -63,7 +84,8 @@ class _OneToManyWidget extends StatefulWidget { required this.label, required this.controller, required this.colorScheme, - required this.subFields, + this.subFields, + this.fieldSchema, required this.validate, required this.onChanged, }); @@ -78,27 +100,128 @@ class _OneToManyWidgetState extends State<_OneToManyWidget> { @override void initState() { super.initState(); + // Use WidgetsBinding to defer the initialization after build + WidgetsBinding.instance.addPostFrameCallback((_) { + _initializeRows(); + }); + } + + void _initializeRows() { if (widget.controller.text.isNotEmpty) { - // Attempt to parse initial JSON array + // Try to parse as JSON first try { final decoded = const JsonDecoder().convert(widget.controller.text); if (decoded is List) { for (final item in decoded) { - _addRow( - prefill: (item as Map).map( - (k, v) => MapEntry(k.toString(), v?.toString() ?? ''))); + if (item is Map) { + final prefillData = {}; + item.forEach((key, value) { + prefillData[key.toString()] = value?.toString() ?? ''; + }); + _addRow(prefill: prefillData); + } } } - } catch (_) {} + } catch (e) { + // If JSON parsing fails, the data might already be in object format + // This happens when data comes from backend API response + print('JSON parsing failed, trying to handle as object: $e'); + + // Try to handle the data as if it's already parsed + try { + // Check if controller text looks like a list representation + final text = widget.controller.text.trim(); + if (text.startsWith('[') && text.endsWith(']')) { + // This might be a string representation of a list + // Try to extract the data specifically + _handleBackendDataFormat(text); + } + } catch (e2) { + print('Error handling backend data format: $e2'); + } + } } + // Always add at least one empty row if no data exists if (_rows.isEmpty) _addRow(); } + void _handleBackendDataFormat(String data) { + // Handle backend data format: [{id: 21, name: g1, description: gdesc}, {id: 22, name: g2, description: g2desc}] + try { + // Remove outer brackets and split by }, { + String cleanData = data.trim(); + if (cleanData.startsWith('[') && cleanData.endsWith(']')) { + cleanData = cleanData.substring(1, cleanData.length - 1); + } + + // Split by }, { to get individual objects + List objects = []; + if (cleanData.contains('}, {')) { + objects = cleanData.split('}, {'); + // Fix the first and last objects + if (objects.isNotEmpty) { + objects[0] = objects[0].replaceFirst('{', ''); + objects[objects.length - 1] = + objects[objects.length - 1].replaceFirst('}', ''); + } + } else if (cleanData.startsWith('{') && cleanData.endsWith('}')) { + // Single object + objects = [cleanData.substring(1, cleanData.length - 1)]; + } + + // Parse each object + for (String obj in objects) { + final prefillData = {}; + + // Split by comma to get key-value pairs + List pairs = obj.split(', '); + for (String pair in pairs) { + if (pair.contains(':')) { + List keyValue = pair.split(': '); + if (keyValue.length == 2) { + String key = keyValue[0].trim(); + String value = keyValue[1].trim(); + + // Remove null values + if (value != 'null') { + prefillData[key] = value; + } + } + } + } + + // Only add row if we have meaningful data + if (prefillData.isNotEmpty) { + _addRow(prefill: prefillData); + } + } + + print('Successfully parsed ${objects.length} objects from backend data'); + } catch (e) { + print('Error parsing backend data format: $e'); + // Fallback: add empty row + } + } + void _addRow({Map? prefill}) { final row = {}; - for (final f in widget.subFields) { - row[f.fieldKey] = TextEditingController(text: prefill?[f.fieldKey] ?? ''); + + if (widget.subFields != null) { + // Legacy mode: using subFields + for (final f in widget.subFields!) { + row[f.fieldKey] = + TextEditingController(text: prefill?[f.fieldKey] ?? ''); + } + } else if (widget.fieldSchema != null) { + // Schema mode: using fieldSchema + for (final f in widget.fieldSchema!) { + final String path = f['path']?.toString() ?? ''; + if (path.isNotEmpty) { + row[path] = TextEditingController(text: prefill?[path] ?? ''); + } + } } + setState(() => _rows.add(row)); _syncToParent(); } @@ -125,6 +248,260 @@ class _OneToManyWidgetState extends State<_OneToManyWidget> { EntityFormScope.of(context)?.notifyParent(); } + List _buildFields( + Map ctrls, ColorScheme cs) { + if (widget.subFields != null) { + // Legacy mode: using subFields + return widget.subFields! + .map((f) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (f.label != null && f.label!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + f.label!, + style: TextStyle( + fontWeight: FontWeight.w600, + color: cs.onSurface, + fontSize: 14, + ), + ), + ), + f.buildField( + controller: ctrls[f.fieldKey]!, + colorScheme: cs, + onChanged: _syncToParent, + ), + ], + ), + )) + .toList(); + } else if (widget.fieldSchema != null) { + // Schema mode: using fieldSchema + return widget.fieldSchema! + .map((f) => _buildSchemaField(f, ctrls, cs)) + .toList(); + } + return []; + } + + Widget _buildSchemaField(Map f, + Map ctrls, ColorScheme cs) { + 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; + + final TextEditingController subController = ctrls[path]!; + + Widget buildShared() { + switch (type) { + case 'text': + return FText.CustomTextField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + maxLength: maxLength, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + case 'number': + return FNumber.NumberField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + min: min, + max: max, + decimalPlaces: decimalPlaces, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + case 'email': + return FEmail.EmailField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + case 'phone': + return FPhone.PhoneField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + case 'password': + return FPassword.PasswordField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + case 'dropdown': + return FDropdown.DropdownField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + options: options ?? const [], + valueKey: valueKey, + displayKey: displayKey, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + case 'date': + return FDate.DateField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + case 'datetime': + return FDateTime.DateTimeField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + case 'switch': + return FSwitch.SwitchField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + case 'checkbox': + return FCheckbox.CheckboxField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + case 'url': + return FUrl.UrlField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + case 'autocomplete_dropdown': + return FAutoDropdown.AutocompleteDropdownField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + optionsLoader: () async => options ?? [], + valueKey: valueKey, + displayKey: displayKey, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + case 'autocomplete_multiselect': + return FAutoMultiSelect.AutocompleteMultiSelectField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + optionsLoader: () async => options ?? [], + valueKey: valueKey, + displayKey: displayKey, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + default: + return FText.CustomTextField( + fieldKey: path, + label: flabel, + hint: fhint, + isRequired: requiredField, + ).buildField( + controller: subController, + colorScheme: cs, + onChanged: _syncToParent, + ); + } + } + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (flabel.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + flabel, + style: TextStyle( + fontWeight: FontWeight.w600, + color: cs.onSurface, + fontSize: 14, + ), + ), + ), + buildShared(), + ], + ), + ); + } + @override Widget build(BuildContext context) { final cs = widget.colorScheme; @@ -138,19 +515,9 @@ class _OneToManyWidgetState extends State<_OneToManyWidget> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Text(widget.label, - style: TextStyle( - color: cs.onSurface, fontWeight: FontWeight.w700)), - const Spacer(), - IconButton( - icon: Icon(Icons.add_circle, color: cs.primary), - tooltip: 'Add', - onPressed: () => _addRow(), - ), - ], - ), + Text(widget.label, + style: TextStyle( + color: cs.onSurface, fontWeight: FontWeight.w700)), const SizedBox(height: 8), ..._rows.asMap().entries.map((entry) { final index = entry.key; @@ -166,14 +533,7 @@ class _OneToManyWidgetState extends State<_OneToManyWidget> { padding: const EdgeInsets.all(12), child: Column( children: [ - ...widget.subFields.map((f) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: f.buildField( - controller: ctrls[f.fieldKey]!, - colorScheme: cs, - onChanged: _syncToParent, - ), - )), + ..._buildFields(ctrls, cs), Align( alignment: Alignment.centerRight, child: IconButton( @@ -187,6 +547,21 @@ class _OneToManyWidgetState extends State<_OneToManyWidget> { ), ); }), + const SizedBox(height: 12), + // Add button at the bottom + Center( + child: ElevatedButton.icon( + onPressed: () => _addRow(), + icon: Icon(Icons.add, color: cs.onPrimary), + label: Text('Add ${widget.label}', + style: TextStyle(color: cs.onPrimary)), + style: ElevatedButton.styleFrom( + backgroundColor: cs.primary, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ), if (widget.validate != null) Builder( builder: (context) { diff --git a/base_project/lib/BuilderField/shared/ui/entity_form.dart b/base_project/lib/BuilderField/shared/ui/entity_form.dart index cde25e6..b3abf82 100644 --- a/base_project/lib/BuilderField/shared/ui/entity_form.dart +++ b/base_project/lib/BuilderField/shared/ui/entity_form.dart @@ -605,7 +605,19 @@ class _EntityFormState extends State { continue; } - formData[key] = value; + // Handle JSON parsing for OneToMany fields + final bool parseAsJson = props['parseAsJson'] == true; + if (parseAsJson && value.isNotEmpty) { + try { + final decoded = json.decode(value); + formData[key] = decoded; + } catch (e) { + // If JSON parsing fails, send as string + formData[key] = value; + } + } else { + formData[key] = value; + } } widget.onSubmit(formData); }