one to many
This commit is contained in:
		
							parent
							
								
									6ace7ced7b
								
							
						
					
					
						commit
						f3acdf65c0
					
				| @ -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(); | ||||
|             }, | ||||
|           ) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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); | ||||
|     } | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user