one to many

This commit is contained in:
string 2025-09-22 09:05:32 +05:30
parent 6ace7ced7b
commit f3acdf65c0
3 changed files with 714 additions and 191 deletions

View File

@ -7,15 +7,20 @@ class AutocompleteMultiSelectField extends BaseField {
final String label;
final String hint;
final bool isRequired;
final Future<List<String>> Function() optionsLoader;
final Future<List<Map<String, dynamic>>> 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<String, dynamic>? get customProperties => const {
'isMultiSelect': true,
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
return FutureBuilder<List<String>>(
future: optionsLoader(),
builder: (context, snapshot) {
final options = snapshot.data ?? const <String>[];
final Set<String> selected = controller.text.isEmpty
? <String>{}
: 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<String>(
optionsBuilder: (TextEditingValue tev) {
if (tev.text.isEmpty) return const Iterable<String>.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<List<Map<String, dynamic>>> 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<Map<String, dynamic>>? _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<String, dynamic>? get customProperties => {
'isMultiSelect': true,
'valueKey': widget.valueKey,
'displayKey': widget.displayKey,
};
Future<void> _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<Map<String, dynamic>> options = _cachedOptions ?? const [];
// Get display options for autocomplete
final Iterable<String> 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<String> selectedIds = widget.controller.text.isEmpty
? <String>{}
: widget.controller.text
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toSet();
// Get selected display values for chips
final Set<String> 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<String>(
optionsBuilder: (TextEditingValue tev) {
if (tev.text.isEmpty) return const Iterable<String>.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();
},
)
],
);
}
}

View File

@ -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<BaseField> subFields;
final List<BaseField>? subFields;
final List<Map<String, dynamic>>? 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<String, dynamic>? get customProperties => const {
// Keep submission optional/safe until backend expects JSON
'excludeFromSubmit': true,
Map<String, dynamic>? 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<BaseField> subFields;
final List<BaseField>? subFields;
final List<Map<String, dynamic>>? 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 = <String, String>{};
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<String> 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 = <String, String>{};
// Split by comma to get key-value pairs
List<String> pairs = obj.split(', ');
for (String pair in pairs) {
if (pair.contains(':')) {
List<String> 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<String, String>? prefill}) {
final row = <String, TextEditingController>{};
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<Widget> _buildFields(
Map<String, TextEditingController> 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<String, dynamic> f,
Map<String, TextEditingController> 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<Map<String, dynamic>>? options =
(f['options'] as List?)?.cast<Map<String, dynamic>>();
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) {

View File

@ -605,7 +605,19 @@ class _EntityFormState extends State<EntityForm> {
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);
}