dattype
This commit is contained in:
		
							parent
							
								
									13eca99151
								
							
						
					
					
						commit
						bc02a06d56
					
				| @ -0,0 +1,205 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'base_field.dart'; | ||||
| import '../../../shared/widgets/inputs/modern_text_field.dart'; | ||||
| 
 | ||||
| class AutocompleteDropdownField extends BaseField { | ||||
|   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; | ||||
| 
 | ||||
|   AutocompleteDropdownField({ | ||||
|     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) { | ||||
|         if (isRequired && (value == null || value.isEmpty)) { | ||||
|           return '$label is required'; | ||||
|         } | ||||
|         return null; | ||||
|       }; | ||||
| 
 | ||||
|   @override | ||||
|   Map<String, dynamic>? get customProperties => { | ||||
|         'isAutocomplete': true, | ||||
|         'valueKey': valueKey, | ||||
|         'displayKey': displayKey, | ||||
|       }; | ||||
| 
 | ||||
|   @override | ||||
|   Widget buildField({ | ||||
|     required TextEditingController controller, | ||||
|     required ColorScheme colorScheme, | ||||
|     VoidCallback? onChanged, | ||||
|   }) { | ||||
|     // We'll lazily set the UI label into the Autocomplete's textController once options load | ||||
|     String initialLabel = ''; | ||||
| 
 | ||||
|     return FutureBuilder<List<Map<String, dynamic>>>( | ||||
|       future: optionsLoader(), | ||||
|       builder: (context, snapshot) { | ||||
|         final List<Map<String, dynamic>> options = snapshot.data ?? const []; | ||||
| 
 | ||||
|         // Resolve initial display label from stored id | ||||
|         if (snapshot.connectionState == ConnectionState.done && | ||||
|             controller.text.isNotEmpty) { | ||||
|           final match = options.firstWhere( | ||||
|             (o) => (o[valueKey]?.toString() ?? '') == controller.text, | ||||
|             orElse: () => const {}, | ||||
|           ); | ||||
|           initialLabel = | ||||
|               match.isNotEmpty ? (match[displayKey]?.toString() ?? '') : ''; | ||||
|         } | ||||
| 
 | ||||
|         final Iterable<String> displayOptions = options | ||||
|             .map((e) => e[displayKey]) | ||||
|             .where((e) => e != null) | ||||
|             .map((e) => e.toString()); | ||||
| 
 | ||||
|         return 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) { | ||||
|             // set UI label and hidden id | ||||
|             final match = options.firstWhere( | ||||
|               (o) => (o[displayKey]?.toString() ?? '') == selection, | ||||
|               orElse: () => const {}, | ||||
|             ); | ||||
|             final idStr = | ||||
|                 match.isNotEmpty ? (match[valueKey]?.toString() ?? '') : ''; | ||||
|             controller.text = idStr; | ||||
|             onChanged?.call(); | ||||
|           }, | ||||
|           fieldViewBuilder: | ||||
|               (context, textController, focusNode, onFieldSubmitted) { | ||||
|             // Initialize UI text once options are available | ||||
|             if (initialLabel.isNotEmpty && textController.text.isEmpty) { | ||||
|               textController.text = initialLabel; | ||||
|             } | ||||
|             return ModernTextField( | ||||
|               label: label, | ||||
|               hint: hint, | ||||
|               controller: textController, | ||||
|               focusNode: focusNode, | ||||
|               validator: (v) => validator?.call(controller.text), | ||||
|               onChanged: (_) => onChanged?.call(), | ||||
|               suffixIcon: textController.text.isNotEmpty | ||||
|                   ? IconButton( | ||||
|                       icon: const Icon(Icons.clear), | ||||
|                       onPressed: () { | ||||
|                         textController.clear(); | ||||
|                         controller.clear(); | ||||
|                         onChanged?.call(); | ||||
|                       }, | ||||
|                     ) | ||||
|                   : const Icon(Icons.search), | ||||
|             ); | ||||
|           }, | ||||
|           optionsViewBuilder: (context, onSelected, optionsIt) { | ||||
|             final optionsList = 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: optionsList.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: optionsList.length, | ||||
|                           separatorBuilder: (_, __) => Divider( | ||||
|                               height: 1, | ||||
|                               color: colorScheme.outline.withOpacity(0.08)), | ||||
|                           itemBuilder: (context, index) { | ||||
|                             final opt = optionsList[index]; | ||||
|                             return InkWell( | ||||
|                               onTap: () => onSelected(opt), | ||||
|                               child: Padding( | ||||
|                                 padding: const EdgeInsets.symmetric( | ||||
|                                     horizontal: 12, vertical: 10), | ||||
|                                 child: _buildHighlightedText( | ||||
|                                     opt, // current query text | ||||
|                                     // Pull current query from the Autocomplete field's text | ||||
|                                     // (not stored here directly, so we simply render opt) | ||||
|                                     '', | ||||
|                                     colorScheme), | ||||
|                               ), | ||||
|                             ); | ||||
|                           }, | ||||
|                         ), | ||||
|                 ), | ||||
|               ), | ||||
|             ); | ||||
|           }, | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   Widget _buildHighlightedText( | ||||
|       String text, String query, ColorScheme colorScheme) { | ||||
|     if (query.isEmpty) | ||||
|       return Text(text, style: TextStyle(color: colorScheme.onSurface)); | ||||
|     final lowerText = text.toLowerCase(); | ||||
|     final lowerQuery = query.toLowerCase(); | ||||
|     final int start = lowerText.indexOf(lowerQuery); | ||||
|     if (start < 0) | ||||
|       return Text(text, style: TextStyle(color: colorScheme.onSurface)); | ||||
|     final int end = start + query.length; | ||||
|     return RichText( | ||||
|       text: TextSpan( | ||||
|         children: [ | ||||
|           TextSpan( | ||||
|               text: text.substring(0, start), | ||||
|               style: TextStyle(color: colorScheme.onSurface)), | ||||
|           TextSpan( | ||||
|               text: text.substring(start, end), | ||||
|               style: TextStyle( | ||||
|                   color: colorScheme.primary, fontWeight: FontWeight.w600)), | ||||
|           TextSpan( | ||||
|               text: text.substring(end), | ||||
|               style: TextStyle(color: colorScheme.onSurface)), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,186 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'base_field.dart'; | ||||
| import '../../../shared/widgets/inputs/modern_text_field.dart'; | ||||
| 
 | ||||
| class AutocompleteMultiSelectField extends BaseField { | ||||
|   final String fieldKey; | ||||
|   final String label; | ||||
|   final String hint; | ||||
|   final bool isRequired; | ||||
|   final Future<List<String>> Function() optionsLoader; | ||||
| 
 | ||||
|   AutocompleteMultiSelectField({ | ||||
|     required this.fieldKey, | ||||
|     required this.label, | ||||
|     required this.hint, | ||||
|     required this.optionsLoader, | ||||
|     this.isRequired = false, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   String? Function(String?)? get validator => (value) { | ||||
|         if (isRequired && (value == null || value.isEmpty)) { | ||||
|           return '$label is required'; | ||||
|         } | ||||
|         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(); | ||||
|                 }, | ||||
|               ) | ||||
|           ], | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,158 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'base_field.dart'; | ||||
| import '../ui/entity_form.dart'; | ||||
| import '../../../shared/widgets/inputs/modern_text_field.dart'; | ||||
| 
 | ||||
| /// Calculated field | ||||
| /// - Select multiple source fields from current form | ||||
| /// - Apply operation: add, subtract, multiply, divide, concat | ||||
| /// - Displays result (read-only) and stores it in controller | ||||
| class CalculatedField extends BaseField { | ||||
|   final String fieldKey; | ||||
|   final String label; | ||||
|   final String hint; | ||||
|   final bool isRequired; | ||||
|   final List<String> sourceKeys; // keys of other fields in same form | ||||
|   final String operation; // add|sub|mul|div|concat | ||||
| 
 | ||||
|   CalculatedField({ | ||||
|     required this.fieldKey, | ||||
|     required this.label, | ||||
|     this.hint = '', | ||||
|     this.isRequired = false, | ||||
|     required this.sourceKeys, | ||||
|     required this.operation, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   String? Function(String?)? get validator => (value) { | ||||
|         if (isRequired && (value == null || value.isEmpty)) { | ||||
|           return '$label is required'; | ||||
|         } | ||||
|         return null; | ||||
|       }; | ||||
| 
 | ||||
|   @override | ||||
|   Map<String, dynamic>? get customProperties => const { | ||||
|         'isCalculated': true, | ||||
|       }; | ||||
| 
 | ||||
|   @override | ||||
|   Widget buildField({ | ||||
|     required TextEditingController controller, | ||||
|     required ColorScheme colorScheme, | ||||
|     VoidCallback? onChanged, | ||||
|   }) { | ||||
|     return _CalculatedWidget( | ||||
|       label: label, | ||||
|       hint: hint, | ||||
|       controller: controller, | ||||
|       colorScheme: colorScheme, | ||||
|       sourceKeys: sourceKeys, | ||||
|       operation: operation, | ||||
|       validate: validator, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class _CalculatedWidget extends StatefulWidget { | ||||
|   final String label; | ||||
|   final String hint; | ||||
|   final TextEditingController controller; | ||||
|   final ColorScheme colorScheme; | ||||
|   final List<String> sourceKeys; | ||||
|   final String operation; | ||||
|   final String? Function(String?)? validate; | ||||
| 
 | ||||
|   const _CalculatedWidget({ | ||||
|     required this.label, | ||||
|     required this.hint, | ||||
|     required this.controller, | ||||
|     required this.colorScheme, | ||||
|     required this.sourceKeys, | ||||
|     required this.operation, | ||||
|     required this.validate, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   State<_CalculatedWidget> createState() => _CalculatedWidgetState(); | ||||
| } | ||||
| 
 | ||||
| class _CalculatedWidgetState extends State<_CalculatedWidget> { | ||||
|   List<TextEditingController> _sources = const []; | ||||
| 
 | ||||
|   @override | ||||
|   void didChangeDependencies() { | ||||
|     super.didChangeDependencies(); | ||||
|     final scope = EntityFormScope.of(context); | ||||
|     if (scope != null) { | ||||
|       _sources = widget.sourceKeys | ||||
|           .map((k) => scope.controllers[k]) | ||||
|           .whereType<TextEditingController>() | ||||
|           .toList(); | ||||
|       for (final c in _sources) { | ||||
|         c.addListener(_recompute); | ||||
|       } | ||||
|       _recompute(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   void dispose() { | ||||
|     for (final c in _sources) { | ||||
|       c.removeListener(_recompute); | ||||
|     } | ||||
|     super.dispose(); | ||||
|   } | ||||
| 
 | ||||
|   void _recompute() { | ||||
|     String result = ''; | ||||
|     if (widget.operation == 'Concatination') { | ||||
|       result = _sources | ||||
|           .map((c) => c.text.trim()) | ||||
|           .where((s) => s.isNotEmpty) | ||||
|           .join(' '); | ||||
|     } else { | ||||
|       final nums = | ||||
|           _sources.map((c) => double.tryParse(c.text.trim()) ?? 0.0).toList(); | ||||
|       if (nums.isEmpty) { | ||||
|         result = ''; | ||||
|       } else { | ||||
|         double acc = nums.first; | ||||
|         for (int i = 1; i < nums.length; i++) { | ||||
|           switch (widget.operation) { | ||||
|             case 'Addition': | ||||
|               acc += nums[i]; | ||||
|               break; | ||||
|             case 'sub': | ||||
|               acc -= nums[i]; | ||||
|               break; | ||||
|             case 'mul': | ||||
|               acc *= nums[i]; | ||||
|               break; | ||||
|             case 'div': | ||||
|               if (nums[i] != 0) acc /= nums[i]; | ||||
|               break; | ||||
|           } | ||||
|         } | ||||
|         result = acc.toString(); | ||||
|       } | ||||
|     } | ||||
|     if (widget.controller.text != result) { | ||||
|       widget.controller.text = result; | ||||
|       setState(() {}); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return ModernTextField( | ||||
|       label: widget.label, | ||||
|       hint: widget.hint, | ||||
|       controller: widget.controller, | ||||
|       readOnly: true, | ||||
|       validator: widget.validate, | ||||
|       suffixIcon: const Icon(Icons.functions), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										132
									
								
								base_project/lib/BuilderField/shared/fields/data_grid_field.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								base_project/lib/BuilderField/shared/fields/data_grid_field.dart
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,132 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'base_field.dart'; | ||||
| 
 | ||||
| /// Read-only Data Grid field | ||||
| /// - Fetches tabular data from an async loader | ||||
| /// - Renders a DataTable | ||||
| /// - Excluded from form submission | ||||
| class DataGridField extends BaseField { | ||||
|   final String fieldKey; | ||||
|   final String label; | ||||
|   final String hint; | ||||
|   final bool isRequired; | ||||
|   final Future<List<Map<String, dynamic>>> Function() dataLoader; | ||||
| 
 | ||||
|   DataGridField({ | ||||
|     required this.fieldKey, | ||||
|     required this.label, | ||||
|     this.hint = '', | ||||
|     this.isRequired = false, | ||||
|     required this.dataLoader, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   String? Function(String?)? get validator => null; | ||||
| 
 | ||||
|   @override | ||||
|   Map<String, dynamic>? get customProperties => const { | ||||
|         'excludeFromSubmit': true, | ||||
|       }; | ||||
| 
 | ||||
|   @override | ||||
|   Widget buildField({ | ||||
|     required TextEditingController controller, | ||||
|     required ColorScheme colorScheme, | ||||
|     VoidCallback? onChanged, | ||||
|   }) { | ||||
|     return _DataGridFieldWidget( | ||||
|       colorScheme: colorScheme, | ||||
|       dataLoader: dataLoader, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class _DataGridFieldWidget extends StatefulWidget { | ||||
|   final ColorScheme colorScheme; | ||||
|   final Future<List<Map<String, dynamic>>> Function() dataLoader; | ||||
| 
 | ||||
|   const _DataGridFieldWidget({ | ||||
|     required this.colorScheme, | ||||
|     required this.dataLoader, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   State<_DataGridFieldWidget> createState() => _DataGridFieldWidgetState(); | ||||
| } | ||||
| 
 | ||||
| class _DataGridFieldWidgetState extends State<_DataGridFieldWidget> { | ||||
|   late Future<List<Map<String, dynamic>>> _future; | ||||
| 
 | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _future = widget.dataLoader(); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final colorScheme = widget.colorScheme; | ||||
|     return FutureBuilder<List<Map<String, dynamic>>>( | ||||
|       future: _future, | ||||
|       builder: (context, snapshot) { | ||||
|         if (snapshot.connectionState == ConnectionState.waiting) { | ||||
|           return const LinearProgressIndicator(minHeight: 2); | ||||
|         } | ||||
|         if (snapshot.hasError) { | ||||
|           return Container( | ||||
|             padding: const EdgeInsets.all(12), | ||||
|             decoration: BoxDecoration( | ||||
|               color: colorScheme.errorContainer.withOpacity(0.2), | ||||
|               borderRadius: BorderRadius.circular(12), | ||||
|               border: Border.all(color: colorScheme.error.withOpacity(0.3)), | ||||
|             ), | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 Icon(Icons.error_outline, color: colorScheme.error), | ||||
|                 const SizedBox(width: 8), | ||||
|                 Expanded( | ||||
|                   child: Text( | ||||
|                     snapshot.error.toString(), | ||||
|                     style: TextStyle(color: colorScheme.error), | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ); | ||||
|         } | ||||
|         final data = snapshot.data ?? const <Map<String, dynamic>>[]; | ||||
|         if (data.isEmpty) { | ||||
|           return Text('No data available', | ||||
|               style: TextStyle(color: colorScheme.onSurfaceVariant)); | ||||
|         } | ||||
|         final columns = data.first.keys.toList(); | ||||
|         return SingleChildScrollView( | ||||
|           scrollDirection: Axis.horizontal, | ||||
|           child: DataTable( | ||||
|             columnSpacing: 24, | ||||
|             headingRowColor: MaterialStateColor.resolveWith( | ||||
|                 (_) => colorScheme.primaryContainer.withOpacity(0.3)), | ||||
|             dataRowColor: | ||||
|                 MaterialStateColor.resolveWith((_) => colorScheme.surface), | ||||
|             dividerThickness: 0.5, | ||||
|             columns: columns | ||||
|                 .map((k) => DataColumn( | ||||
|                       label: Text(k, | ||||
|                           style: const TextStyle(fontWeight: FontWeight.w600)), | ||||
|                     )) | ||||
|                 .toList(), | ||||
|             rows: data | ||||
|                 .map( | ||||
|                   (row) => DataRow( | ||||
|                     cells: columns | ||||
|                         .map((k) => DataCell(Text(row[k]?.toString() ?? ''))) | ||||
|                         .toList(), | ||||
|                   ), | ||||
|                 ) | ||||
|                 .toList(), | ||||
|           ), | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -1,50 +1,54 @@ | ||||
| // import 'package:flutter/material.dart'; | ||||
| // import 'base_field.dart'; | ||||
| // import '../../../Reuseable/reusable_dropdown_field.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'base_field.dart'; | ||||
| import '../../../Reuseable/reusable_dropdown_field.dart'; | ||||
| 
 | ||||
| // /// Dropdown selection field implementation | ||||
| // class DropdownField extends BaseField { | ||||
| //   final String fieldKey; | ||||
| //   final String label; | ||||
| //   final String hint; | ||||
| //   final bool isRequired; | ||||
| //   final List<Map<String, dynamic>> options; | ||||
| //   final String valueKey; | ||||
| //   final String displayKey; | ||||
|    | ||||
| //   DropdownField({ | ||||
| //     required this.fieldKey, | ||||
| //     required this.label, | ||||
| //     required this.hint, | ||||
| //     this.isRequired = false, | ||||
| //     required this.options, | ||||
| //     this.valueKey = 'id', | ||||
| //     this.displayKey = 'name', | ||||
| //   }); | ||||
|    | ||||
| //   @override | ||||
| //   String? Function(String?)? get validator => (value) { | ||||
| //     if (isRequired && (value == null || value.isEmpty)) { | ||||
| //       return '$label is required'; | ||||
| //     } | ||||
| //     return null; | ||||
| //   }; | ||||
|    | ||||
| //   @override | ||||
| //   Widget buildField({ | ||||
| //     required TextEditingController controller, | ||||
| //     required ColorScheme colorScheme, | ||||
| //     VoidCallback? onChanged, | ||||
| //   }) { | ||||
| //     return ReusableDropdownField( | ||||
| //       controller: controller, | ||||
| //       label: label, | ||||
| //       hint: hint, | ||||
| //       items: options, | ||||
| //       valueKey: valueKey, | ||||
| //       displayKey: displayKey, | ||||
| //       onChanged: onChanged != null ? (_) => onChanged() : null, | ||||
| //       validator: validator, | ||||
| //     ); | ||||
| //   } | ||||
| // } | ||||
| /// Dropdown selection field implementation | ||||
| class DropdownField extends BaseField { | ||||
|   final String fieldKey; | ||||
|   final String label; | ||||
|   final String hint; | ||||
|   final bool isRequired; | ||||
|   final List<Map<String, dynamic>> options; | ||||
|   final String valueKey; // id field in option map | ||||
|   final String displayKey; // display field in option map | ||||
| 
 | ||||
|   DropdownField({ | ||||
|     required this.fieldKey, | ||||
|     required this.label, | ||||
|     required this.hint, | ||||
|     this.isRequired = false, | ||||
|     required this.options, | ||||
|     this.valueKey = 'id', | ||||
|     this.displayKey = 'name', | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   String? Function(String?)? get validator => (value) { | ||||
|         if (isRequired && (value == null || value.isEmpty)) { | ||||
|           return '$label is required'; | ||||
|         } | ||||
|         return null; | ||||
|       }; | ||||
| 
 | ||||
|   @override | ||||
|   Widget buildField({ | ||||
|     required TextEditingController controller, | ||||
|     required ColorScheme colorScheme, | ||||
|     VoidCallback? onChanged, | ||||
|   }) { | ||||
|     return ReusableDropdownField( | ||||
|       label: label, | ||||
|       options: options, | ||||
|       valueField: valueKey, // id | ||||
|       uiField: displayKey, // label | ||||
|       value: controller.text.isNotEmpty ? controller.text : null, | ||||
|       onChanged: (val) { | ||||
|         controller.text = val ?? ''; | ||||
|         if (onChanged != null) onChanged(); | ||||
|       }, | ||||
|       onSaved: (val) { | ||||
|         controller.text = val ?? ''; | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -0,0 +1,223 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'base_field.dart'; | ||||
| import '../../../shared/widgets/inputs/modern_text_field.dart'; | ||||
| 
 | ||||
| /// Dynamic single-select dropdown (no Autocomplete) | ||||
| /// - Opens a modal list with search | ||||
| /// - Stores selected id in controller, displays label | ||||
| class DynamicDropdownField extends BaseField { | ||||
|   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; | ||||
| 
 | ||||
|   DynamicDropdownField({ | ||||
|     required this.fieldKey, | ||||
|     required this.label, | ||||
|     this.hint = '', | ||||
|     this.isRequired = false, | ||||
|     required this.optionsLoader, | ||||
|     required this.valueKey, | ||||
|     required this.displayKey, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   String? Function(String?)? get validator => (value) { | ||||
|         if (isRequired && (value == null || value.isEmpty)) { | ||||
|           return '$label is required'; | ||||
|         } | ||||
|         return null; | ||||
|       }; | ||||
| 
 | ||||
|   @override | ||||
|   Widget buildField({ | ||||
|     required TextEditingController controller, | ||||
|     required ColorScheme colorScheme, | ||||
|     VoidCallback? onChanged, | ||||
|   }) { | ||||
|     return _DynamicDropdownWidget( | ||||
|       label: label, | ||||
|       hint: hint, | ||||
|       controller: controller, | ||||
|       colorScheme: colorScheme, | ||||
|       optionsLoader: optionsLoader, | ||||
|       valueKey: valueKey, | ||||
|       displayKey: displayKey, | ||||
|       validate: validator, | ||||
|       onChanged: onChanged, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class _DynamicDropdownWidget extends StatefulWidget { | ||||
|   final String label; | ||||
|   final String hint; | ||||
|   final TextEditingController controller; | ||||
|   final ColorScheme colorScheme; | ||||
|   final Future<List<Map<String, dynamic>>> Function() optionsLoader; | ||||
|   final String valueKey; | ||||
|   final String displayKey; | ||||
|   final String? Function(String?)? validate; | ||||
|   final VoidCallback? onChanged; | ||||
| 
 | ||||
|   const _DynamicDropdownWidget({ | ||||
|     required this.label, | ||||
|     required this.hint, | ||||
|     required this.controller, | ||||
|     required this.colorScheme, | ||||
|     required this.optionsLoader, | ||||
|     required this.valueKey, | ||||
|     required this.displayKey, | ||||
|     required this.validate, | ||||
|     required this.onChanged, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   State<_DynamicDropdownWidget> createState() => _DynamicDropdownWidgetState(); | ||||
| } | ||||
| 
 | ||||
| class _DynamicDropdownWidgetState extends State<_DynamicDropdownWidget> { | ||||
|   List<Map<String, dynamic>> _options = const []; | ||||
|   String _uiLabel = ''; | ||||
|   bool _loading = true; | ||||
| 
 | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _load(); | ||||
|   } | ||||
| 
 | ||||
|   Future<void> _load() async { | ||||
|     try { | ||||
|       final opts = await widget.optionsLoader(); | ||||
|       setState(() { | ||||
|         _options = opts; | ||||
|         _loading = false; | ||||
|       }); | ||||
|       if (widget.controller.text.isNotEmpty) { | ||||
|         final match = _options.firstWhere( | ||||
|           (o) => | ||||
|               (o[widget.valueKey]?.toString() ?? '') == widget.controller.text, | ||||
|           orElse: () => const {}, | ||||
|         ); | ||||
|         setState(() { | ||||
|           _uiLabel = match.isNotEmpty | ||||
|               ? (match[widget.displayKey]?.toString() ?? '') | ||||
|               : ''; | ||||
|         }); | ||||
|       } | ||||
|     } catch (_) { | ||||
|       setState(() => _loading = false); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future<void> _openPicker() async { | ||||
|     final ColorScheme cs = widget.colorScheme; | ||||
|     String query = ''; | ||||
|     await showModalBottomSheet( | ||||
|       context: context, | ||||
|       isScrollControlled: true, | ||||
|       backgroundColor: cs.surface, | ||||
|       shape: const RoundedRectangleBorder( | ||||
|         borderRadius: BorderRadius.vertical(top: Radius.circular(16)), | ||||
|       ), | ||||
|       builder: (context) { | ||||
|         List<Map<String, dynamic>> filtered = _options; | ||||
|         return StatefulBuilder( | ||||
|           builder: (context, setSheetState) { | ||||
|             void applyFilter(String q) { | ||||
|               query = q; | ||||
|               setSheetState(() { | ||||
|                 filtered = _options | ||||
|                     .where((o) => (o[widget.displayKey]?.toString() ?? '') | ||||
|                         .toLowerCase() | ||||
|                         .contains(q.toLowerCase())) | ||||
|                     .toList(); | ||||
|               }); | ||||
|             } | ||||
| 
 | ||||
|             return SafeArea( | ||||
|               child: Padding( | ||||
|                 padding: EdgeInsets.only( | ||||
|                   bottom: MediaQuery.of(context).viewInsets.bottom, | ||||
|                   left: 16, | ||||
|                   right: 16, | ||||
|                   top: 12, | ||||
|                 ), | ||||
|                 child: Column( | ||||
|                   mainAxisSize: MainAxisSize.min, | ||||
|                   children: [ | ||||
|                     TextField( | ||||
|                       decoration: InputDecoration( | ||||
|                         hintText: 'Search ${widget.label.toLowerCase()}', | ||||
|                         prefixIcon: const Icon(Icons.search), | ||||
|                         border: const OutlineInputBorder(), | ||||
|                       ), | ||||
|                       onChanged: applyFilter, | ||||
|                     ), | ||||
|                     const SizedBox(height: 12), | ||||
|                     Flexible( | ||||
|                       child: filtered.isEmpty | ||||
|                           ? Center( | ||||
|                               child: Text('No results', | ||||
|                                   style: TextStyle(color: cs.onSurfaceVariant)), | ||||
|                             ) | ||||
|                           : ListView.separated( | ||||
|                               shrinkWrap: true, | ||||
|                               itemCount: filtered.length, | ||||
|                               separatorBuilder: (_, __) => Divider( | ||||
|                                   height: 1, | ||||
|                                   color: cs.outline.withOpacity(0.08)), | ||||
|                               itemBuilder: (context, index) { | ||||
|                                 final o = filtered[index]; | ||||
|                                 final id = o[widget.valueKey]?.toString() ?? ''; | ||||
|                                 final name = | ||||
|                                     o[widget.displayKey]?.toString() ?? ''; | ||||
|                                 final isSelected = widget.controller.text == id; | ||||
|                                 return ListTile( | ||||
|                                   title: Text(name), | ||||
|                                   trailing: isSelected | ||||
|                                       ? Icon(Icons.check, color: cs.primary) | ||||
|                                       : null, | ||||
|                                   onTap: () { | ||||
|                                     setState(() { | ||||
|                                       widget.controller.text = id; | ||||
|                                       _uiLabel = name; | ||||
|                                     }); | ||||
|                                     widget.onChanged?.call(); | ||||
|                                     Navigator.of(context).pop(); | ||||
|                                   }, | ||||
|                                 ); | ||||
|                               }, | ||||
|                             ), | ||||
|                     ), | ||||
|                     const SizedBox(height: 8), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ); | ||||
|           }, | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (_loading) { | ||||
|       return const LinearProgressIndicator(minHeight: 2); | ||||
|     } | ||||
|     return ModernTextField( | ||||
|       label: widget.label, | ||||
|       hint: widget.hint, | ||||
|       readOnly: true, | ||||
|       controller: TextEditingController(text: _uiLabel), | ||||
|       validator: (v) => widget.validate?.call(widget.controller.text), | ||||
|       onTap: _openPicker, | ||||
|       suffixIcon: const Icon(Icons.arrow_drop_down), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,253 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'base_field.dart'; | ||||
| import '../../../shared/widgets/inputs/modern_text_field.dart'; | ||||
| 
 | ||||
| /// Dynamic multi-select dropdown (no Autocomplete) | ||||
| /// - Modal list with search and checkboxes | ||||
| /// - Stores comma-separated selected labels in controller | ||||
| class DynamicMultiSelectDropdownField extends BaseField { | ||||
|   final String fieldKey; | ||||
|   final String label; | ||||
|   final String hint; | ||||
|   final bool isRequired; | ||||
|   final Future<List<String>> Function() optionsLoader; | ||||
| 
 | ||||
|   DynamicMultiSelectDropdownField({ | ||||
|     required this.fieldKey, | ||||
|     required this.label, | ||||
|     this.hint = '', | ||||
|     this.isRequired = false, | ||||
|     required this.optionsLoader, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   String? Function(String?)? get validator => (value) { | ||||
|         if (isRequired && (value == null || value.isEmpty)) { | ||||
|           return '$label is required'; | ||||
|         } | ||||
|         return null; | ||||
|       }; | ||||
| 
 | ||||
|   @override | ||||
|   Widget buildField({ | ||||
|     required TextEditingController controller, | ||||
|     required ColorScheme colorScheme, | ||||
|     VoidCallback? onChanged, | ||||
|   }) { | ||||
|     return _DynamicMultiSelectWidget( | ||||
|       label: label, | ||||
|       hint: hint, | ||||
|       controller: controller, | ||||
|       colorScheme: colorScheme, | ||||
|       optionsLoader: optionsLoader, | ||||
|       validate: validator, | ||||
|       onChanged: onChanged, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class _DynamicMultiSelectWidget extends StatefulWidget { | ||||
|   final String label; | ||||
|   final String hint; | ||||
|   final TextEditingController controller; | ||||
|   final ColorScheme colorScheme; | ||||
|   final Future<List<String>> Function() optionsLoader; | ||||
|   final String? Function(String?)? validate; | ||||
|   final VoidCallback? onChanged; | ||||
| 
 | ||||
|   const _DynamicMultiSelectWidget({ | ||||
|     required this.label, | ||||
|     required this.hint, | ||||
|     required this.controller, | ||||
|     required this.colorScheme, | ||||
|     required this.optionsLoader, | ||||
|     required this.validate, | ||||
|     required this.onChanged, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   State<_DynamicMultiSelectWidget> createState() => | ||||
|       _DynamicMultiSelectWidgetState(); | ||||
| } | ||||
| 
 | ||||
| class _DynamicMultiSelectWidgetState extends State<_DynamicMultiSelectWidget> { | ||||
|   List<String> _options = const []; | ||||
|   late final Set<String> _selected; | ||||
|   bool _loading = true; | ||||
| 
 | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _selected = widget.controller.text.isEmpty | ||||
|         ? <String>{} | ||||
|         : widget.controller.text | ||||
|             .split(',') | ||||
|             .map((e) => e.trim()) | ||||
|             .where((e) => e.isNotEmpty) | ||||
|             .toSet(); | ||||
|     _load(); | ||||
|   } | ||||
| 
 | ||||
|   Future<void> _load() async { | ||||
|     try { | ||||
|       final opts = await widget.optionsLoader(); | ||||
|       setState(() { | ||||
|         _options = opts; | ||||
|         _loading = false; | ||||
|       }); | ||||
|     } catch (_) { | ||||
|       setState(() => _loading = false); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   Future<void> _openPicker() async { | ||||
|     final ColorScheme cs = widget.colorScheme; | ||||
|     String query = ''; | ||||
|     await showModalBottomSheet( | ||||
|       context: context, | ||||
|       isScrollControlled: true, | ||||
|       backgroundColor: cs.surface, | ||||
|       shape: const RoundedRectangleBorder( | ||||
|         borderRadius: BorderRadius.vertical(top: Radius.circular(16)), | ||||
|       ), | ||||
|       builder: (context) { | ||||
|         List<String> filtered = _options; | ||||
|         return StatefulBuilder( | ||||
|           builder: (context, setSheetState) { | ||||
|             void applyFilter(String q) { | ||||
|               query = q; | ||||
|               setSheetState(() { | ||||
|                 filtered = _options | ||||
|                     .where((o) => o.toLowerCase().contains(q.toLowerCase())) | ||||
|                     .toList(); | ||||
|               }); | ||||
|             } | ||||
| 
 | ||||
|             void toggle(String value) { | ||||
|               setSheetState(() { | ||||
|                 if (_selected.contains(value)) { | ||||
|                   _selected.remove(value); | ||||
|                 } else { | ||||
|                   _selected.add(value); | ||||
|                 } | ||||
|               }); | ||||
|             } | ||||
| 
 | ||||
|             return SafeArea( | ||||
|               child: Padding( | ||||
|                 padding: EdgeInsets.only( | ||||
|                   bottom: MediaQuery.of(context).viewInsets.bottom, | ||||
|                   left: 16, | ||||
|                   right: 16, | ||||
|                   top: 12, | ||||
|                 ), | ||||
|                 child: Column( | ||||
|                   mainAxisSize: MainAxisSize.min, | ||||
|                   children: [ | ||||
|                     TextField( | ||||
|                       decoration: InputDecoration( | ||||
|                         hintText: 'Search ${widget.label.toLowerCase()}', | ||||
|                         prefixIcon: const Icon(Icons.search), | ||||
|                         border: const OutlineInputBorder(), | ||||
|                       ), | ||||
|                       onChanged: applyFilter, | ||||
|                     ), | ||||
|                     const SizedBox(height: 12), | ||||
|                     Flexible( | ||||
|                       child: filtered.isEmpty | ||||
|                           ? Center( | ||||
|                               child: Text('No results', | ||||
|                                   style: TextStyle(color: cs.onSurfaceVariant)), | ||||
|                             ) | ||||
|                           : ListView.separated( | ||||
|                               shrinkWrap: true, | ||||
|                               itemCount: filtered.length, | ||||
|                               separatorBuilder: (_, __) => Divider( | ||||
|                                   height: 1, | ||||
|                                   color: cs.outline.withOpacity(0.08)), | ||||
|                               itemBuilder: (context, index) { | ||||
|                                 final name = filtered[index]; | ||||
|                                 final isSelected = _selected.contains(name); | ||||
|                                 return CheckboxListTile( | ||||
|                                   title: Text(name), | ||||
|                                   value: isSelected, | ||||
|                                   onChanged: (_) => toggle(name), | ||||
|                                 ); | ||||
|                               }, | ||||
|                             ), | ||||
|                     ), | ||||
|                     const SizedBox(height: 8), | ||||
|                     Row( | ||||
|                       children: [ | ||||
|                         TextButton( | ||||
|                           onPressed: () { | ||||
|                             setState(() { | ||||
|                               widget.controller.text = ''; | ||||
|                             }); | ||||
|                             _selected.clear(); | ||||
|                             widget.onChanged?.call(); | ||||
|                             Navigator.of(context).pop(); | ||||
|                           }, | ||||
|                           child: const Text('Clear'), | ||||
|                         ), | ||||
|                         const Spacer(), | ||||
|                         ElevatedButton( | ||||
|                           onPressed: () { | ||||
|                             setState(() { | ||||
|                               widget.controller.text = _selected.join(','); | ||||
|                             }); | ||||
|                             widget.onChanged?.call(); | ||||
|                             Navigator.of(context).pop(); | ||||
|                           }, | ||||
|                           child: const Text('Done'), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                     const SizedBox(height: 8), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ); | ||||
|           }, | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (_loading) { | ||||
|       return const LinearProgressIndicator(minHeight: 2); | ||||
|     } | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         ModernTextField( | ||||
|           label: widget.label, | ||||
|           hint: widget.hint, | ||||
|           readOnly: true, | ||||
|           controller: TextEditingController(text: _selected.join(', ')), | ||||
|           validator: widget.validate, | ||||
|           onTap: _openPicker, | ||||
|           suffixIcon: const Icon(Icons.arrow_drop_down), | ||||
|         ), | ||||
|         const SizedBox(height: 8), | ||||
|         Wrap( | ||||
|           spacing: 8, | ||||
|           children: _selected | ||||
|               .map((v) => Chip( | ||||
|                     label: Text(v), | ||||
|                     onDeleted: () { | ||||
|                       setState(() { | ||||
|                         _selected.remove(v); | ||||
|                         widget.controller.text = _selected.join(','); | ||||
|                       }); | ||||
|                       widget.onChanged?.call(); | ||||
|                     }, | ||||
|                   )) | ||||
|               .toList(), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,57 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import '../ui/entity_screens.dart'; | ||||
| import '../utils/entity_field_store.dart'; | ||||
| 
 | ||||
| typedef UploadHandler = Future<dynamic> Function( | ||||
|     String entityId, String entityName, String fileName, dynamic bytes); | ||||
| typedef CreateAndReturnId = Future<int> Function(Map<String, dynamic> data); | ||||
| 
 | ||||
| /// Universal wrapper: Create entity first, then upload files collected by shared upload fields | ||||
| class EntityCreateWithUploads extends StatelessWidget { | ||||
|   final String title; | ||||
|   final List fields; // List<BaseField> | ||||
|   final CreateAndReturnId createAndReturnId; | ||||
|   final Map<String, UploadHandler> | ||||
|       uploadHandlersByFieldKey; // fieldKey -> uploader | ||||
|   final String entityName; // e.g., 'Adv1' | ||||
|   final bool isLoading; | ||||
|   final String? errorMessage; | ||||
| 
 | ||||
|   const EntityCreateWithUploads({ | ||||
|     super.key, | ||||
|     required this.title, | ||||
|     required this.fields, | ||||
|     required this.createAndReturnId, | ||||
|     required this.uploadHandlersByFieldKey, | ||||
|     required this.entityName, | ||||
|     this.isLoading = false, | ||||
|     this.errorMessage, | ||||
|   }); | ||||
| 
 | ||||
|   Future<void> _uploadAll(int id) async { | ||||
|     final store = EntityFieldStore.instance; | ||||
|     for (final entry in uploadHandlersByFieldKey.entries) { | ||||
|       final String key = entry.key; | ||||
|       final handler = entry.value; | ||||
|       final items = store.get<List<UploadItem>>(key) ?? const []; | ||||
|       for (final item in items) { | ||||
|         await handler(id.toString(), entityName, item.fileName, item.bytes); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return EntityCreateScreen( | ||||
|       title: title, | ||||
|       fields: fields.cast(), | ||||
|       isLoading: isLoading, | ||||
|       errorMessage: errorMessage, | ||||
|       onSubmit: (data) async { | ||||
|         final id = await createAndReturnId(data); | ||||
|         await _uploadAll(id); | ||||
|         EntityFieldStore.instance.clear(); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,208 @@ | ||||
| import 'dart:convert'; | ||||
| 
 | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'base_field.dart'; | ||||
| import '../ui/entity_form.dart'; | ||||
| 
 | ||||
| /// One-to-many subform builder | ||||
| /// - Renders repeatable group of sub-fields | ||||
| /// - Stores JSON array (as string) in controller | ||||
| class OneToManyField extends BaseField { | ||||
|   final String fieldKey; | ||||
|   final String label; | ||||
|   final String hint; | ||||
|   final bool isRequired; | ||||
|   final List<BaseField> subFields; | ||||
| 
 | ||||
|   OneToManyField({ | ||||
|     required this.fieldKey, | ||||
|     required this.label, | ||||
|     this.hint = '', | ||||
|     this.isRequired = false, | ||||
|     required this.subFields, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   String? Function(String?)? get validator => (value) { | ||||
|         if (isRequired && (value == null || value.isEmpty)) { | ||||
|           return '$label is required'; | ||||
|         } | ||||
|         return null; | ||||
|       }; | ||||
|   @override | ||||
|   Map<String, dynamic>? get customProperties => const { | ||||
|         // Keep submission optional/safe until backend expects JSON | ||||
|         'excludeFromSubmit': true, | ||||
|       }; | ||||
|   @override | ||||
|   Widget buildField({ | ||||
|     required TextEditingController controller, | ||||
|     required ColorScheme colorScheme, | ||||
|     VoidCallback? onChanged, | ||||
|   }) { | ||||
|     return _OneToManyWidget( | ||||
|       label: label, | ||||
|       controller: controller, | ||||
|       colorScheme: colorScheme, | ||||
|       subFields: subFields, | ||||
|       validate: validator, | ||||
|       onChanged: onChanged, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class _OneToManyWidget extends StatefulWidget { | ||||
|   final String label; | ||||
|   final TextEditingController controller; | ||||
|   final ColorScheme colorScheme; | ||||
|   final List<BaseField> subFields; | ||||
|   final String? Function(String?)? validate; | ||||
|   final VoidCallback? onChanged; | ||||
| 
 | ||||
|   const _OneToManyWidget({ | ||||
|     required this.label, | ||||
|     required this.controller, | ||||
|     required this.colorScheme, | ||||
|     required this.subFields, | ||||
|     required this.validate, | ||||
|     required this.onChanged, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   State<_OneToManyWidget> createState() => _OneToManyWidgetState(); | ||||
| } | ||||
| 
 | ||||
| class _OneToManyWidgetState extends State<_OneToManyWidget> { | ||||
|   final List<Map<String, TextEditingController>> _rows = []; | ||||
| 
 | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     if (widget.controller.text.isNotEmpty) { | ||||
|       // Attempt to parse initial JSON array | ||||
|       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() ?? ''))); | ||||
|           } | ||||
|         } | ||||
|       } catch (_) {} | ||||
|     } | ||||
|     if (_rows.isEmpty) _addRow(); | ||||
|   } | ||||
| 
 | ||||
|   void _addRow({Map<String, String>? prefill}) { | ||||
|     final row = <String, TextEditingController>{}; | ||||
|     for (final f in widget.subFields) { | ||||
|       row[f.fieldKey] = TextEditingController(text: prefill?[f.fieldKey] ?? ''); | ||||
|     } | ||||
|     setState(() => _rows.add(row)); | ||||
|     _syncToParent(); | ||||
|   } | ||||
| 
 | ||||
|   void _removeRow(int index) { | ||||
|     if (index < 0 || index >= _rows.length) return; | ||||
|     final removed = _rows.removeAt(index); | ||||
|     for (final c in removed.values) { | ||||
|       c.dispose(); | ||||
|     } | ||||
|     setState(() {}); | ||||
|     _syncToParent(); | ||||
|   } | ||||
| 
 | ||||
|   void _syncToParent() { | ||||
|     final list = <Map<String, String>>[]; | ||||
|     for (final row in _rows) { | ||||
|       final map = <String, String>{}; | ||||
|       row.forEach((k, c) => map[k] = c.text.trim()); | ||||
|       list.add(map); | ||||
|     } | ||||
|     widget.controller.text = const JsonEncoder().convert(list); | ||||
|     widget.onChanged?.call(); | ||||
|     EntityFormScope.of(context)?.notifyParent(); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final cs = widget.colorScheme; | ||||
|     return Container( | ||||
|       decoration: BoxDecoration( | ||||
|         borderRadius: BorderRadius.circular(12), | ||||
|         border: Border.all(color: cs.primary.withOpacity(0.15)), | ||||
|       ), | ||||
|       child: Padding( | ||||
|         padding: const EdgeInsets.all(12), | ||||
|         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(), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|             const SizedBox(height: 8), | ||||
|             ..._rows.asMap().entries.map((entry) { | ||||
|               final index = entry.key; | ||||
|               final ctrls = entry.value; | ||||
|               return Card( | ||||
|                 elevation: 0, | ||||
|                 color: cs.surface, | ||||
|                 shape: RoundedRectangleBorder( | ||||
|                   borderRadius: BorderRadius.circular(10), | ||||
|                   side: BorderSide(color: cs.outline.withOpacity(0.12)), | ||||
|                 ), | ||||
|                 child: Padding( | ||||
|                   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, | ||||
|                             ), | ||||
|                           )), | ||||
|                       Align( | ||||
|                         alignment: Alignment.centerRight, | ||||
|                         child: IconButton( | ||||
|                           icon: Icon(Icons.delete_outline, color: cs.error), | ||||
|                           tooltip: 'Remove', | ||||
|                           onPressed: () => _removeRow(index), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               ); | ||||
|             }), | ||||
|             if (widget.validate != null) | ||||
|               Builder( | ||||
|                 builder: (context) { | ||||
|                   final error = widget.validate!(widget.controller.text); | ||||
|                   return error != null | ||||
|                       ? Padding( | ||||
|                           padding: const EdgeInsets.only(top: 6), | ||||
|                           child: Text(error, | ||||
|                               style: TextStyle(color: cs.error, fontSize: 12)), | ||||
|                         ) | ||||
|                       : const SizedBox.shrink(); | ||||
|                 }, | ||||
|               ) | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,322 @@ | ||||
| import 'dart:convert'; | ||||
| 
 | ||||
| import 'package:flutter/material.dart'; | ||||
| 
 | ||||
| import 'base_field.dart'; | ||||
| import 'captcha_field.dart' as FCaptcha; | ||||
| import 'custom_text_field.dart' as FText; | ||||
| import 'date_field.dart' as FDate; | ||||
| import 'datetime_field.dart' as FDateTime; | ||||
| import 'dropdown_field.dart' as FDropdown; | ||||
| import 'email_field.dart' as FEmail; | ||||
| import 'number_field.dart' as FNumber; | ||||
| import 'password_field.dart' as FPassword; | ||||
| import 'phone_field.dart' as FPhone; | ||||
| import 'switch_field.dart' as FSwitch; | ||||
| import 'url_field.dart' as FUrl; | ||||
| 
 | ||||
| class OneToOneRelationField extends BaseField { | ||||
|   final String fieldKey; | ||||
|   final String label; | ||||
|   final String hint; | ||||
|   final bool isRequired; | ||||
|   final Map<String, dynamic> relationSchema; | ||||
| 
 | ||||
|   OneToOneRelationField({ | ||||
|     required this.fieldKey, | ||||
|     required this.label, | ||||
|     required this.hint, | ||||
|     required this.relationSchema, | ||||
|     this.isRequired = false, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   String? Function(String?)? get validator => (value) { | ||||
|         // If not required, allow empty | ||||
|         if (!isRequired) return null; | ||||
| 
 | ||||
|         // Required: ensure at least one inner field has a non-empty value | ||||
|         if (value == null || value.isEmpty) { | ||||
|           return '$label is required'; | ||||
|         } | ||||
|         try { | ||||
|           final decoded = json.decode(value); | ||||
|           if (decoded is! Map<String, dynamic>) { | ||||
|             return '$label is required'; | ||||
|           } | ||||
|           final List fields = (relationSchema['fields'] as List?) ?? const []; | ||||
|           for (final f in fields) { | ||||
|             final String? path = f['path']?.toString(); | ||||
|             if (path == null) continue; | ||||
|             final dynamic v = decoded[path]; | ||||
|             if (v != null && v.toString().trim().isNotEmpty) { | ||||
|               return null; // at least one provided | ||||
|             } | ||||
|           } | ||||
|           return '$label is required'; | ||||
|         } catch (_) { | ||||
|           // If invalid JSON and required, treat as empty | ||||
|           return '$label is required'; | ||||
|         } | ||||
|       }; | ||||
| 
 | ||||
|   @override | ||||
|   Map<String, dynamic>? get customProperties => { | ||||
|         'isRelation': true, | ||||
|         'assignByJsonPaths': true, | ||||
|         'paths': (relationSchema['fields'] as List?) | ||||
|                 ?.map((f) => f['path']) | ||||
|                 .where((p) => p != null) | ||||
|                 .toList() ?? | ||||
|             [], | ||||
|       }; | ||||
| 
 | ||||
|   @override | ||||
|   Widget buildField({ | ||||
|     required TextEditingController controller, | ||||
|     required ColorScheme colorScheme, | ||||
|     VoidCallback? onChanged, | ||||
|   }) { | ||||
|     final List fields = (relationSchema['fields'] as List?) ?? const []; | ||||
|     final bool boxed = relationSchema['box'] == true; | ||||
|     final String title = relationSchema['title']?.toString() ?? label; | ||||
| 
 | ||||
|     Widget content = Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         if (title.isNotEmpty) | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.only(bottom: 8.0), | ||||
|             child: Text( | ||||
|               title, | ||||
|               style: TextStyle( | ||||
|                 fontWeight: FontWeight.bold, | ||||
|                 color: colorScheme.onSurface, | ||||
|                 fontSize: 16, | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ...fields.map<Widget>((f) { | ||||
|           final String type = f['type']?.toString() ?? 'text'; | ||||
|           final String flabel = f['label']?.toString() ?? ''; | ||||
|           final String fhint = f['hint']?.toString() ?? ''; | ||||
|           final String path = f['path']?.toString() ?? ''; | ||||
|           final bool requiredField = f['required'] == true; | ||||
|           final List<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; | ||||
| 
 | ||||
|           // Initialize sub field with existing value from parent controller JSON. | ||||
|           // Expect flat map { 'support.field': value }. Fallback to nested traversal. | ||||
|           String initialValue = ''; | ||||
|           if (controller.text.isNotEmpty) { | ||||
|             try { | ||||
|               final decoded = json.decode(controller.text); | ||||
|               if (decoded is Map<String, dynamic>) { | ||||
|                 if (decoded.containsKey(path)) { | ||||
|                   initialValue = decoded[path]?.toString() ?? ''; | ||||
|                 } else { | ||||
|                   final parts = path.split('.'); | ||||
|                   dynamic curr = decoded; | ||||
|                   for (final p in parts) { | ||||
|                     if (curr is Map && curr.containsKey(p)) { | ||||
|                       curr = curr[p]; | ||||
|                     } else { | ||||
|                       curr = null; | ||||
|                       break; | ||||
|                     } | ||||
|                   } | ||||
|                   if (curr != null) initialValue = curr.toString(); | ||||
|                 } | ||||
|               } | ||||
|             } catch (_) {} | ||||
|           } | ||||
| 
 | ||||
|           void sync(String val) { | ||||
|             Map<String, dynamic> current = {}; | ||||
|             if (controller.text.isNotEmpty) { | ||||
|               try { | ||||
|                 final decoded = json.decode(controller.text); | ||||
|                 if (decoded is Map<String, dynamic>) current = decoded; | ||||
|               } catch (_) {} | ||||
|             } | ||||
|             // Store flat path mapping | ||||
|             if (val.isEmpty) { | ||||
|               // remove the key when emptied | ||||
|               current.remove(path); | ||||
|             } else { | ||||
|               current[path] = val; | ||||
|             } | ||||
|             // If map becomes empty, clear controller to avoid failing validation when optional | ||||
|             controller.text = current.isEmpty ? '' : json.encode(current); | ||||
|             onChanged?.call(); | ||||
|           } | ||||
| 
 | ||||
|           final TextEditingController subController = | ||||
|               TextEditingController(text: initialValue); | ||||
| 
 | ||||
|           Widget buildShared() { | ||||
|             switch (type) { | ||||
|               case 'number': | ||||
|                 return FNumber.NumberField( | ||||
|                   fieldKey: path, | ||||
|                   label: flabel, | ||||
|                   hint: fhint, | ||||
|                   isRequired: requiredField, | ||||
|                   min: min, | ||||
|                   max: max, | ||||
|                   decimalPlaces: decimalPlaces, | ||||
|                 ).buildField( | ||||
|                   controller: subController, | ||||
|                   colorScheme: colorScheme, | ||||
|                   onChanged: () => sync(subController.text), | ||||
|                 ); | ||||
|               case 'email': | ||||
|                 return FEmail.EmailField( | ||||
|                   fieldKey: path, | ||||
|                   label: flabel, | ||||
|                   hint: fhint, | ||||
|                   isRequired: requiredField, | ||||
|                 ).buildField( | ||||
|                   controller: subController, | ||||
|                   colorScheme: colorScheme, | ||||
|                   onChanged: () => sync(subController.text), | ||||
|                 ); | ||||
|               case 'phone': | ||||
|                 return FPhone.PhoneField( | ||||
|                   fieldKey: path, | ||||
|                   label: flabel, | ||||
|                   hint: fhint, | ||||
|                   isRequired: requiredField, | ||||
|                 ).buildField( | ||||
|                   controller: subController, | ||||
|                   colorScheme: colorScheme, | ||||
|                   onChanged: () => sync(subController.text), | ||||
|                 ); | ||||
|               case 'password': | ||||
|                 return FPassword.PasswordField( | ||||
|                   fieldKey: path, | ||||
|                   label: flabel, | ||||
|                   hint: fhint, | ||||
|                   isRequired: requiredField, | ||||
|                 ).buildField( | ||||
|                   controller: subController, | ||||
|                   colorScheme: colorScheme, | ||||
|                   onChanged: () => sync(subController.text), | ||||
|                 ); | ||||
|               case 'dropdown': | ||||
|                 return FDropdown.DropdownField( | ||||
|                   fieldKey: path, | ||||
|                   label: flabel, | ||||
|                   hint: fhint, | ||||
|                   isRequired: requiredField, | ||||
|                   options: options ?? const [], | ||||
|                   valueKey: valueKey, | ||||
|                   displayKey: displayKey, | ||||
|                 ).buildField( | ||||
|                   controller: subController, | ||||
|                   colorScheme: colorScheme, | ||||
|                   onChanged: () => sync(subController.text), | ||||
|                 ); | ||||
|               case 'date': | ||||
|                 return FDate.DateField( | ||||
|                   fieldKey: path, | ||||
|                   label: flabel, | ||||
|                   hint: fhint, | ||||
|                   isRequired: requiredField, | ||||
|                 ).buildField( | ||||
|                   controller: subController, | ||||
|                   colorScheme: colorScheme, | ||||
|                   onChanged: () => sync(subController.text), | ||||
|                 ); | ||||
|               case 'datetime': | ||||
|                 return FDateTime.DateTimeField( | ||||
|                   fieldKey: path, | ||||
|                   label: flabel, | ||||
|                   hint: fhint, | ||||
|                   isRequired: requiredField, | ||||
|                 ).buildField( | ||||
|                   controller: subController, | ||||
|                   colorScheme: colorScheme, | ||||
|                   onChanged: () => sync(subController.text), | ||||
|                 ); | ||||
|               case 'switch': | ||||
|                 return FSwitch.SwitchField( | ||||
|                   fieldKey: path, | ||||
|                   label: flabel, | ||||
|                   hint: fhint, | ||||
|                   isRequired: requiredField, | ||||
|                 ).buildField( | ||||
|                   controller: subController, | ||||
|                   colorScheme: colorScheme, | ||||
|                   onChanged: () => sync(subController.text), | ||||
|                 ); | ||||
|               case 'captcha': | ||||
|                 return FCaptcha.CaptchaField( | ||||
|                   fieldKey: path, | ||||
|                   label: flabel, | ||||
|                   hint: fhint, | ||||
|                   isRequired: requiredField, | ||||
|                 ).buildField( | ||||
|                   controller: subController, | ||||
|                   colorScheme: colorScheme, | ||||
|                   onChanged: () => sync(subController.text), | ||||
|                 ); | ||||
|               case 'url': | ||||
|                 return FUrl.UrlField( | ||||
|                   fieldKey: path, | ||||
|                   label: flabel, | ||||
|                   hint: fhint, | ||||
|                   isRequired: requiredField, | ||||
|                 ).buildField( | ||||
|                   controller: subController, | ||||
|                   colorScheme: colorScheme, | ||||
|                   onChanged: () => sync(subController.text), | ||||
|                 ); | ||||
|               case 'text': | ||||
|               default: | ||||
|                 return FText.CustomTextField( | ||||
|                   fieldKey: path, | ||||
|                   label: flabel, | ||||
|                   hint: fhint, | ||||
|                   isRequired: requiredField, | ||||
|                   maxLength: maxLength, | ||||
|                 ).buildField( | ||||
|                   controller: subController, | ||||
|                   colorScheme: colorScheme, | ||||
|                   onChanged: () => sync(subController.text), | ||||
|                 ); | ||||
|             } | ||||
|           } | ||||
| 
 | ||||
|           return Padding( | ||||
|             padding: const EdgeInsets.only(bottom: 12.0), | ||||
|             child: buildShared(), | ||||
|           ); | ||||
|         }).toList(), | ||||
|       ], | ||||
|     ); | ||||
| 
 | ||||
|     final Widget body = boxed | ||||
|         ? Container( | ||||
|             padding: const EdgeInsets.all(12), | ||||
|             decoration: BoxDecoration( | ||||
|               borderRadius: BorderRadius.circular(8), | ||||
|               border: Border.all(color: colorScheme.outline.withOpacity(0.4)), | ||||
|             ), | ||||
|             child: content, | ||||
|           ) | ||||
|         : content; | ||||
| 
 | ||||
|     return Directionality(textDirection: TextDirection.ltr, child: body); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,90 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'base_field.dart'; | ||||
| 
 | ||||
| /// Static Multi-Select field | ||||
| /// - Accepts a static list of display strings | ||||
| /// - Stores selected values as a comma-separated string in controller | ||||
| class StaticMultiSelectField extends BaseField { | ||||
|   final String fieldKey; | ||||
|   final String label; | ||||
|   final String hint; | ||||
|   final bool isRequired; | ||||
|   final List<String> options; | ||||
| 
 | ||||
|   StaticMultiSelectField({ | ||||
|     required this.fieldKey, | ||||
|     required this.label, | ||||
|     this.hint = '', | ||||
|     this.isRequired = false, | ||||
|     required this.options, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   String? Function(String?)? get validator => (value) { | ||||
|         if (isRequired && (value == null || value.isEmpty)) { | ||||
|           return '$label is required'; | ||||
|         } | ||||
|         return null; | ||||
|       }; | ||||
| 
 | ||||
|   @override | ||||
|   Widget buildField({ | ||||
|     required TextEditingController controller, | ||||
|     required ColorScheme colorScheme, | ||||
|     VoidCallback? onChanged, | ||||
|   }) { | ||||
|     final Set<String> selected = controller.text.isEmpty | ||||
|         ? <String>{} | ||||
|         : controller.text | ||||
|             .split(',') | ||||
|             .map((e) => e.trim()) | ||||
|             .where((e) => e.isNotEmpty) | ||||
|             .toSet(); | ||||
| 
 | ||||
|     void toggle(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: [ | ||||
|         Text(label, | ||||
|             style: TextStyle( | ||||
|                 color: colorScheme.onSurface, fontWeight: FontWeight.w600)), | ||||
|         const SizedBox(height: 8), | ||||
|         Wrap( | ||||
|           spacing: 8, | ||||
|           runSpacing: 8, | ||||
|           children: options.map((opt) { | ||||
|             final bool isSel = selected.contains(opt); | ||||
|             return FilterChip( | ||||
|               label: Text(opt), | ||||
|               selected: isSel, | ||||
|               onSelected: (_) => toggle(opt), | ||||
|             ); | ||||
|           }).toList(), | ||||
|         ), | ||||
|         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(); | ||||
|             }, | ||||
|           ) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,160 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'base_field.dart'; | ||||
| import '../ui/entity_form.dart'; | ||||
| 
 | ||||
| /// Value List Picker | ||||
| /// - Shows an icon button | ||||
| /// - Loads list from API and displays as modal cards | ||||
| /// - On selecting an item, fills multiple form fields based on mapping | ||||
| class ValueListPickerField extends BaseField { | ||||
|   final String fieldKey; | ||||
|   final String label; | ||||
|   final String hint; | ||||
|   final bool isRequired; | ||||
|   final Future<List<Map<String, dynamic>>> Function() optionsLoader; | ||||
|   final Map<String, String> fillMappings; // sourceKey -> targetFieldKey in form | ||||
|   final IconData icon; | ||||
| 
 | ||||
|   ValueListPickerField({ | ||||
|     required this.fieldKey, | ||||
|     required this.label, | ||||
|     this.hint = '', | ||||
|     this.isRequired = false, | ||||
|     required this.optionsLoader, | ||||
|     required this.fillMappings, | ||||
|     this.icon = Icons.playlist_add_check, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   String? Function(String?)? get validator => null; | ||||
| 
 | ||||
|   @override | ||||
|   Map<String, dynamic>? get customProperties => const { | ||||
|         'excludeFromSubmit': true, // helper-only, no direct submission | ||||
|       }; | ||||
| 
 | ||||
|   @override | ||||
|   Widget buildField({ | ||||
|     required TextEditingController controller, | ||||
|     required ColorScheme colorScheme, | ||||
|     VoidCallback? onChanged, | ||||
|   }) { | ||||
|     return _ValueListPickerWidget( | ||||
|       label: label, | ||||
|       colorScheme: colorScheme, | ||||
|       optionsLoader: optionsLoader, | ||||
|       fillMappings: fillMappings, | ||||
|       icon: icon, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class _ValueListPickerWidget extends StatefulWidget { | ||||
|   final String label; | ||||
|   final ColorScheme colorScheme; | ||||
|   final Future<List<Map<String, dynamic>>> Function() optionsLoader; | ||||
|   final Map<String, String> fillMappings; | ||||
|   final IconData icon; | ||||
| 
 | ||||
|   const _ValueListPickerWidget({ | ||||
|     required this.label, | ||||
|     required this.colorScheme, | ||||
|     required this.optionsLoader, | ||||
|     required this.fillMappings, | ||||
|     required this.icon, | ||||
|   }); | ||||
| 
 | ||||
|   @override | ||||
|   State<_ValueListPickerWidget> createState() => _ValueListPickerWidgetState(); | ||||
| } | ||||
| 
 | ||||
| class _ValueListPickerWidgetState extends State<_ValueListPickerWidget> { | ||||
|   bool _loading = false; | ||||
| 
 | ||||
|   Future<void> _open() async { | ||||
|     setState(() => _loading = true); | ||||
|     List<Map<String, dynamic>> options = const []; | ||||
|     try { | ||||
|       options = await widget.optionsLoader(); | ||||
|     } finally { | ||||
|       setState(() => _loading = false); | ||||
|     } | ||||
| 
 | ||||
|     final cs = widget.colorScheme; | ||||
|     await showModalBottomSheet( | ||||
|       context: context, | ||||
|       isScrollControlled: true, | ||||
|       backgroundColor: cs.surface, | ||||
|       shape: const RoundedRectangleBorder( | ||||
|         borderRadius: BorderRadius.vertical(top: Radius.circular(16)), | ||||
|       ), | ||||
|       builder: (context) { | ||||
|         return SafeArea( | ||||
|           child: Padding( | ||||
|             padding: const EdgeInsets.all(16), | ||||
|             child: options.isEmpty | ||||
|                 ? Center( | ||||
|                     child: Text('No data', | ||||
|                         style: TextStyle(color: cs.onSurfaceVariant))) | ||||
|                 : ListView.separated( | ||||
|                     itemCount: options.length, | ||||
|                     separatorBuilder: (_, __) => | ||||
|                         Divider(height: 1, color: cs.outline.withOpacity(0.08)), | ||||
|                     itemBuilder: (context, index) { | ||||
|                       final o = options[index]; | ||||
|                       return ListTile( | ||||
|                         leading: CircleAvatar( | ||||
|                             child: Text( | ||||
|                                 ((o['name'] ?? o['title'] ?? 'N') as String) | ||||
|                                     .substring(0, 1) | ||||
|                                     .toUpperCase())), | ||||
|                         title: Text((o['name'] ?? o['title'] ?? '').toString()), | ||||
|                         subtitle: Text( | ||||
|                             (o['description'] ?? o['email'] ?? o['phone'] ?? '') | ||||
|                                 .toString()), | ||||
|                         onTap: () { | ||||
|                           final scope = EntityFormScope.of(context); | ||||
|                           if (scope != null) { | ||||
|                             widget.fillMappings | ||||
|                                 .forEach((sourceKey, targetFieldKey) { | ||||
|                               final v = o[sourceKey]; | ||||
|                               final c = scope.controllers[targetFieldKey]; | ||||
|                               if (c != null) c.text = v?.toString() ?? ''; | ||||
|                             }); | ||||
|                             scope.notifyParent(); | ||||
|                           } | ||||
|                           Navigator.of(context).pop(); | ||||
|                         }, | ||||
|                       ); | ||||
|                     }, | ||||
|                   ), | ||||
|           ), | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final cs = widget.colorScheme; | ||||
|     return Row( | ||||
|       children: [ | ||||
|         Expanded( | ||||
|           child: Text(widget.label, | ||||
|               style: | ||||
|                   TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600)), | ||||
|         ), | ||||
|         IconButton( | ||||
|           tooltip: 'Open ${widget.label}', | ||||
|           icon: _loading | ||||
|               ? const SizedBox( | ||||
|                   width: 18, | ||||
|                   height: 18, | ||||
|                   child: CircularProgressIndicator(strokeWidth: 2)) | ||||
|               : Icon(widget.icon, color: cs.primary), | ||||
|           onPressed: _loading ? null : _open, | ||||
|         ) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -1,6 +1,394 @@ | ||||
| import 'package:base_project/core/providers/dynamic_theme_provider.dart'; | ||||
| // import 'package:base_project/core/providers/dynamic_theme_provider.dart'; | ||||
| // import 'package:flutter/material.dart'; | ||||
| // import 'package:provider/provider.dart'; | ||||
| // import '../fields/base_field.dart'; | ||||
| // import '../../../shared/widgets/buttons/modern_button.dart'; | ||||
| // import '../../../core/constants/ui_constants.dart'; | ||||
| 
 | ||||
| // /// Reusable form component that dynamically renders fields based on field definitions | ||||
| // /// This allows UI to be independent of field types and enables reusability | ||||
| // class EntityForm extends StatefulWidget { | ||||
| //   final List<BaseField> fields; | ||||
| //   final Map<String, dynamic>? initialData; | ||||
| //   final Function(Map<String, dynamic>) onSubmit; | ||||
| //   final String submitButtonText; | ||||
| //   final bool isLoading; | ||||
| 
 | ||||
| //   const EntityForm({ | ||||
| //     super.key, | ||||
| //     required this.fields, | ||||
| //     this.initialData, | ||||
| //     required this.onSubmit, | ||||
| //     this.submitButtonText = 'Submit', | ||||
| //     this.isLoading = false, | ||||
| //   }); | ||||
| 
 | ||||
| //   @override | ||||
| //   State<EntityForm> createState() => _EntityFormState(); | ||||
| // } | ||||
| 
 | ||||
| // class _EntityFormState extends State<EntityForm> { | ||||
| //   final _formKey = GlobalKey<FormState>(); | ||||
| //   final Map<String, TextEditingController> _controllers = {}; | ||||
| //   final Map<String, BaseField> _fieldByKey = {}; | ||||
| 
 | ||||
| //   @override | ||||
| //   void initState() { | ||||
| //     super.initState(); | ||||
| //     _initializeControllers(); | ||||
| //   } | ||||
| 
 | ||||
| //   void _initializeControllers() { | ||||
| //     for (final field in widget.fields) { | ||||
| //       _controllers[field.fieldKey] = TextEditingController( | ||||
| //         text: widget.initialData?[field.fieldKey]?.toString() ?? '', | ||||
| //       ); | ||||
| //       _fieldByKey[field.fieldKey] = field; | ||||
| //     } | ||||
| //   } | ||||
| 
 | ||||
| //   @override | ||||
| //   void dispose() { | ||||
| //     for (final controller in _controllers.values) { | ||||
| //       controller.dispose(); | ||||
| //     } | ||||
| //     super.dispose(); | ||||
| //   } | ||||
| 
 | ||||
| //   @override | ||||
| //   Widget build(BuildContext context) { | ||||
| //     return Consumer<DynamicThemeProvider>( | ||||
| //       builder: (context, dynamicThemeProvider, child) { | ||||
| //         final colorScheme = dynamicThemeProvider.getCurrentColorScheme( | ||||
| //           Theme.of(context).brightness == Brightness.dark, | ||||
| //         ); | ||||
| 
 | ||||
| //         return Form( | ||||
| //           key: _formKey, | ||||
| //           child: Column( | ||||
| //             children: [ | ||||
| //               // Dynamic field rendering | ||||
| //               ...widget.fields.map((field) => Padding( | ||||
| //                     padding: | ||||
| //                         const EdgeInsets.only(bottom: UIConstants.spacing16), | ||||
| //                     child: field.buildField( | ||||
| //                       controller: _controllers[field.fieldKey]!, | ||||
| //                       colorScheme: colorScheme, | ||||
| //                       onChanged: () => setState(() {}), | ||||
| //                     ), | ||||
| //                   )), | ||||
| 
 | ||||
| //               const SizedBox(height: UIConstants.spacing24), | ||||
| 
 | ||||
| //               // Submit button | ||||
| //               ModernButton( | ||||
| //                 text: widget.submitButtonText, | ||||
| //                 type: ModernButtonType.primary, | ||||
| //                 size: ModernButtonSize.large, | ||||
| //                 isLoading: widget.isLoading, | ||||
| //                 onPressed: widget.isLoading ? null : _handleSubmit, | ||||
| //               ), | ||||
| //             ], | ||||
| //           ), | ||||
| //         ); | ||||
| //       }, | ||||
| //     ); | ||||
| //   } | ||||
| 
 | ||||
| //   void _handleSubmit() { | ||||
| //     if (_formKey.currentState!.validate()) { | ||||
| //       // Dynamic cross-field match for any password-confirm group | ||||
| //       final Map<String, String> passwordByGroup = {}; | ||||
| //       final Map<String, String> confirmByGroup = {}; | ||||
| //       for (final entry in _controllers.entries) { | ||||
| //         final key = entry.key; | ||||
| //         final field = _fieldByKey[key]; | ||||
| //         final props = field?.customProperties ?? const {}; | ||||
| //         final isPassword = props['isPassword'] == true; | ||||
| //         if (!isPassword) continue; | ||||
| //         final String? groupId = props['groupId']; | ||||
| //         if (groupId == null) continue; | ||||
| //         final bool isConfirm = props['isConfirm'] == true; | ||||
| //         if (isConfirm) { | ||||
| //           confirmByGroup[groupId] = entry.value.text; | ||||
| //         } else { | ||||
| //           passwordByGroup[groupId] = entry.value.text; | ||||
| //         } | ||||
| //       } | ||||
| //       for (final gid in confirmByGroup.keys) { | ||||
| //         final confirm = confirmByGroup[gid] ?? ''; | ||||
| //         final pass = passwordByGroup[gid] ?? ''; | ||||
| //         if (confirm.isNotEmpty && confirm != pass) { | ||||
| //           ScaffoldMessenger.of(context).showSnackBar( | ||||
| //             const SnackBar(content: Text('Passwords do not match')), | ||||
| //           ); | ||||
| //           return; | ||||
| //         } | ||||
| //       } | ||||
| 
 | ||||
| //       final formData = <String, dynamic>{}; | ||||
| //       for (final entry in _controllers.entries) { | ||||
| //         final key = entry.key; | ||||
| //         final field = _fieldByKey[key]; | ||||
| //         final value = entry.value.text.trim(); | ||||
| 
 | ||||
| //         // Skip confirm entries for any password group | ||||
| //         final props = field?.customProperties ?? const {}; | ||||
| //         final bool isPassword = props['isPassword'] == true; | ||||
| //         final bool isConfirm = props['isConfirm'] == true; | ||||
| //         if (isPassword && isConfirm) continue; | ||||
| 
 | ||||
| //         formData[key] = value; | ||||
| //       } | ||||
| //       widget.onSubmit(formData); | ||||
| //     } | ||||
| //   } | ||||
| // } | ||||
| 
 | ||||
| // SECOND WORKING CODE | ||||
| // import 'dart:convert'; | ||||
| 
 | ||||
| // import 'package:flutter/material.dart'; | ||||
| // import 'package:provider/provider.dart'; | ||||
| 
 | ||||
| // import '../../../core/constants/ui_constants.dart'; | ||||
| // import '../../../core/providers/dynamic_theme_provider.dart'; | ||||
| // import '../../../shared/widgets/buttons/modern_button.dart'; | ||||
| // import '../fields/base_field.dart'; | ||||
| 
 | ||||
| // /// Reusable form component that dynamically renders fields based on field definitions | ||||
| // /// This allows UI to be independent of field types and enables reusability | ||||
| // class EntityForm extends StatefulWidget { | ||||
| //   final List<BaseField> fields; | ||||
| //   final Map<String, dynamic>? initialData; | ||||
| //   final Function(Map<String, dynamic>) onSubmit; | ||||
| //   final String submitButtonText; | ||||
| //   final bool isLoading; | ||||
| 
 | ||||
| //   const EntityForm({ | ||||
| //     super.key, | ||||
| //     required this.fields, | ||||
| //     this.initialData, | ||||
| //     required this.onSubmit, | ||||
| //     this.submitButtonText = 'Submit', | ||||
| //     this.isLoading = false, | ||||
| //   }); | ||||
| 
 | ||||
| //   @override | ||||
| //   State<EntityForm> createState() => _EntityFormState(); | ||||
| // } | ||||
| 
 | ||||
| // class _EntityFormState extends State<EntityForm> { | ||||
| //   final _formKey = GlobalKey<FormState>(); | ||||
| //   final Map<String, TextEditingController> _controllers = {}; | ||||
| //   final Map<String, BaseField> _fieldByKey = {}; | ||||
| //   late final Map<String, dynamic> _initialData; | ||||
| 
 | ||||
| //   @override | ||||
| //   void initState() { | ||||
| //     super.initState(); | ||||
| //     _initializeControllers(); | ||||
| //   } | ||||
| 
 | ||||
| //   void _initializeControllers() { | ||||
| //     _initialData = widget.initialData ?? const {}; | ||||
| //     for (final field in widget.fields) { | ||||
| //       final props = field.customProperties ?? const {}; | ||||
| //       final bool assignByJsonPaths = props['assignByJsonPaths'] == true; | ||||
| //       final List<dynamic>? paths = props['paths'] as List<dynamic>?; | ||||
| //       String initialText = | ||||
| //           widget.initialData?[field.fieldKey]?.toString() ?? ''; | ||||
| //       if (assignByJsonPaths && paths != null) { | ||||
| //         final Map<String, dynamic> values = {}; | ||||
| //         for (final p in paths) { | ||||
| //           if (p is String) { | ||||
| //             final v = _readValueByPath(_initialData, p); | ||||
| //             if (v != null) values[p] = v; | ||||
| //           } | ||||
| //         } | ||||
| //         if (values.isNotEmpty) { | ||||
| //           initialText = _encodeMap(values); | ||||
| //         } | ||||
| //       } | ||||
| //       _controllers[field.fieldKey] = TextEditingController(text: initialText); | ||||
| //       _fieldByKey[field.fieldKey] = field; | ||||
| //     } | ||||
| //   } | ||||
| 
 | ||||
| //   @override | ||||
| //   void dispose() { | ||||
| //     for (final controller in _controllers.values) { | ||||
| //       controller.dispose(); | ||||
| //     } | ||||
| //     super.dispose(); | ||||
| //   } | ||||
| 
 | ||||
| //   @override | ||||
| //   Widget build(BuildContext context) { | ||||
| //     return Consumer<DynamicThemeProvider>( | ||||
| //       builder: (context, dynamicThemeProvider, child) { | ||||
| //         final colorScheme = dynamicThemeProvider.getCurrentColorScheme( | ||||
| //           Theme.of(context).brightness == Brightness.dark, | ||||
| //         ); | ||||
| 
 | ||||
| //         return Form( | ||||
| //           key: _formKey, | ||||
| //           child: Column( | ||||
| //             children: [ | ||||
| //               // Dynamic field rendering | ||||
| //               ...widget.fields.map((field) => Padding( | ||||
| //                     padding: | ||||
| //                         const EdgeInsets.only(bottom: UIConstants.spacing16), | ||||
| //                     child: field.buildField( | ||||
| //                       controller: _controllers[field.fieldKey]!, | ||||
| //                       colorScheme: colorScheme, | ||||
| //                       onChanged: () => setState(() {}), | ||||
| //                     ), | ||||
| //                   )), | ||||
| 
 | ||||
| //               const SizedBox(height: UIConstants.spacing24), | ||||
| 
 | ||||
| //               // Submit button | ||||
| //               ModernButton( | ||||
| //                 text: widget.submitButtonText, | ||||
| //                 type: ModernButtonType.primary, | ||||
| //                 size: ModernButtonSize.large, | ||||
| //                 isLoading: widget.isLoading, | ||||
| //                 onPressed: widget.isLoading ? null : _handleSubmit, | ||||
| //               ), | ||||
| //             ], | ||||
| //           ), | ||||
| //         ); | ||||
| //       }, | ||||
| //     ); | ||||
| //   } | ||||
| 
 | ||||
| //   void _handleSubmit() { | ||||
| //     if (_formKey.currentState!.validate()) { | ||||
| //       // Dynamic cross-field match for any password-confirm group | ||||
| //       final Map<String, String> passwordByGroup = {}; | ||||
| //       final Map<String, String> confirmByGroup = {}; | ||||
| //       for (final entry in _controllers.entries) { | ||||
| //         final key = entry.key; | ||||
| //         final field = _fieldByKey[key]; | ||||
| //         final props = field?.customProperties ?? const {}; | ||||
| //         final isPassword = props['isPassword'] == true; | ||||
| //         if (!isPassword) continue; | ||||
| //         final String? groupId = props['groupId']; | ||||
| //         if (groupId == null) continue; | ||||
| //         final bool isConfirm = props['isConfirm'] == true; | ||||
| //         if (isConfirm) { | ||||
| //           confirmByGroup[groupId] = entry.value.text; | ||||
| //         } else { | ||||
| //           passwordByGroup[groupId] = entry.value.text; | ||||
| //         } | ||||
| //       } | ||||
| //       for (final gid in confirmByGroup.keys) { | ||||
| //         final confirm = confirmByGroup[gid] ?? ''; | ||||
| //         final pass = passwordByGroup[gid] ?? ''; | ||||
| //         if (confirm.isNotEmpty && confirm != pass) { | ||||
| //           ScaffoldMessenger.of(context).showSnackBar( | ||||
| //             const SnackBar(content: Text('Passwords do not match')), | ||||
| //           ); | ||||
| //           return; | ||||
| //         } | ||||
| //       } | ||||
| 
 | ||||
| //       final formData = <String, dynamic>{}; | ||||
| //       for (final entry in _controllers.entries) { | ||||
| //         final key = entry.key; | ||||
| //         final field = _fieldByKey[key]; | ||||
| //         final value = entry.value.text.trim(); | ||||
| 
 | ||||
| //         // Skip fields that are marked as non-submittable (e.g., DataGrid) | ||||
| //         final props = field?.customProperties ?? const {}; | ||||
| //         final bool excludeFromSubmit = props['excludeFromSubmit'] == true; | ||||
| //         if (excludeFromSubmit) { | ||||
| //           continue; | ||||
| //         } | ||||
| 
 | ||||
| //         // Skip confirm entries for any password group | ||||
| //         final bool isPassword = props['isPassword'] == true; | ||||
| //         final bool isConfirm = props['isConfirm'] == true; | ||||
| //         if (isPassword && isConfirm) continue; | ||||
| 
 | ||||
| //         final bool assignByJsonPaths = props['assignByJsonPaths'] == true; | ||||
| //         if (assignByJsonPaths) { | ||||
| //           // If composite and empty -> skip adding base key | ||||
| //           if (value.isEmpty) { | ||||
| //             continue; | ||||
| //           } | ||||
| //           final Map<String, dynamic>? map = _tryDecodeMap(value); | ||||
| //           if (map != null) { | ||||
| //             map.forEach((path, v) { | ||||
| //               if (path is String) { | ||||
| //                 _assignValueByPath(formData, path, v); | ||||
| //               } | ||||
| //             }); | ||||
| //             continue; | ||||
| //           } | ||||
| //           // If not decodable, also skip to avoid sending invalid base key | ||||
| //           continue; | ||||
| //         } | ||||
| 
 | ||||
| //         formData[key] = value; | ||||
| //       } | ||||
| //       widget.onSubmit(formData); | ||||
| //     } | ||||
| //   } | ||||
| 
 | ||||
| //   dynamic _readValueByPath(Map<String, dynamic>? source, String path) { | ||||
| //     if (source == null) return null; | ||||
| //     final segments = path.split('.'); | ||||
| //     dynamic current = source; | ||||
| //     for (final segment in segments) { | ||||
| //       if (current is Map<String, dynamic> && current.containsKey(segment)) { | ||||
| //         current = current[segment]; | ||||
| //       } else { | ||||
| //         return null; | ||||
| //       } | ||||
| //     } | ||||
| //     return current; | ||||
| //   } | ||||
| 
 | ||||
| //   void _assignValueByPath( | ||||
| //       Map<String, dynamic> target, String path, dynamic value) { | ||||
| //     final segments = path.split('.'); | ||||
| //     Map<String, dynamic> current = target; | ||||
| //     for (int i = 0; i < segments.length; i++) { | ||||
| //       final seg = segments[i]; | ||||
| //       final bool isLast = i == segments.length - 1; | ||||
| //       if (isLast) { | ||||
| //         current[seg] = value; | ||||
| //       } else { | ||||
| //         if (current[seg] is! Map<String, dynamic>) { | ||||
| //           current[seg] = <String, dynamic>{}; | ||||
| //         } | ||||
| //         current = current[seg] as Map<String, dynamic>; | ||||
| //       } | ||||
| //     } | ||||
| //   } | ||||
| 
 | ||||
| //   String _encodeMap(Map<String, dynamic> map) { | ||||
| //     return const JsonEncoder().convert(map); | ||||
| //   } | ||||
| 
 | ||||
| //   Map<String, dynamic>? _tryDecodeMap(String value) { | ||||
| //     try { | ||||
| //       final decoded = const JsonDecoder().convert(value); | ||||
| //       if (decoded is Map<String, dynamic>) return decoded; | ||||
| //       return null; | ||||
| //     } catch (_) { | ||||
| //       return null; | ||||
| //     } | ||||
| //   } | ||||
| // } | ||||
| 
 | ||||
| import 'dart:convert'; | ||||
| 
 | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import '../../../core/providers/dynamic_theme_provider.dart'; | ||||
| import '../fields/base_field.dart'; | ||||
| import '../../../shared/widgets/buttons/modern_button.dart'; | ||||
| import '../../../core/constants/ui_constants.dart'; | ||||
| @ -31,6 +419,7 @@ class _EntityFormState extends State<EntityForm> { | ||||
|   final _formKey = GlobalKey<FormState>(); | ||||
|   final Map<String, TextEditingController> _controllers = {}; | ||||
|   final Map<String, BaseField> _fieldByKey = {}; | ||||
|   late final Map<String, dynamic> _initialData; | ||||
| 
 | ||||
|   @override | ||||
|   void initState() { | ||||
| @ -39,10 +428,26 @@ class _EntityFormState extends State<EntityForm> { | ||||
|   } | ||||
| 
 | ||||
|   void _initializeControllers() { | ||||
|     _initialData = widget.initialData ?? const {}; | ||||
|     for (final field in widget.fields) { | ||||
|       _controllers[field.fieldKey] = TextEditingController( | ||||
|         text: widget.initialData?[field.fieldKey]?.toString() ?? '', | ||||
|       ); | ||||
|       final props = field.customProperties ?? const {}; | ||||
|       final bool assignByJsonPaths = props['assignByJsonPaths'] == true; | ||||
|       final List<dynamic>? paths = props['paths'] as List<dynamic>?; | ||||
|       String initialText = | ||||
|           widget.initialData?[field.fieldKey]?.toString() ?? ''; | ||||
|       if (assignByJsonPaths && paths != null) { | ||||
|         final Map<String, dynamic> values = {}; | ||||
|         for (final p in paths) { | ||||
|           if (p is String) { | ||||
|             final v = _readValueByPath(_initialData, p); | ||||
|             if (v != null) values[p] = v; | ||||
|           } | ||||
|         } | ||||
|         if (values.isNotEmpty) { | ||||
|           initialText = _encodeMap(values); | ||||
|         } | ||||
|       } | ||||
|       _controllers[field.fieldKey] = TextEditingController(text: initialText); | ||||
|       _fieldByKey[field.fieldKey] = field; | ||||
|     } | ||||
|   } | ||||
| @ -63,32 +468,36 @@ class _EntityFormState extends State<EntityForm> { | ||||
|           Theme.of(context).brightness == Brightness.dark, | ||||
|         ); | ||||
| 
 | ||||
|         return Form( | ||||
|           key: _formKey, | ||||
|           child: Column( | ||||
|             children: [ | ||||
|               // Dynamic field rendering | ||||
|               ...widget.fields.map((field) => Padding( | ||||
|                     padding: | ||||
|                         const EdgeInsets.only(bottom: UIConstants.spacing16), | ||||
|                     child: field.buildField( | ||||
|                       controller: _controllers[field.fieldKey]!, | ||||
|                       colorScheme: colorScheme, | ||||
|                       onChanged: () => setState(() {}), | ||||
|                     ), | ||||
|                   )), | ||||
|         return EntityFormScope( | ||||
|           controllers: _controllers, | ||||
|           notifyParent: () => setState(() {}), | ||||
|           child: Form( | ||||
|             key: _formKey, | ||||
|             child: Column( | ||||
|               children: [ | ||||
|                 // Dynamic field rendering | ||||
|                 ...widget.fields.map((field) => Padding( | ||||
|                       padding: | ||||
|                           const EdgeInsets.only(bottom: UIConstants.spacing16), | ||||
|                       child: field.buildField( | ||||
|                         controller: _controllers[field.fieldKey]!, | ||||
|                         colorScheme: colorScheme, | ||||
|                         onChanged: () => setState(() {}), | ||||
|                       ), | ||||
|                     )), | ||||
| 
 | ||||
|               const SizedBox(height: UIConstants.spacing24), | ||||
|                 const SizedBox(height: UIConstants.spacing24), | ||||
| 
 | ||||
|               // Submit button | ||||
|               ModernButton( | ||||
|                 text: widget.submitButtonText, | ||||
|                 type: ModernButtonType.primary, | ||||
|                 size: ModernButtonSize.large, | ||||
|                 isLoading: widget.isLoading, | ||||
|                 onPressed: widget.isLoading ? null : _handleSubmit, | ||||
|               ), | ||||
|             ], | ||||
|                 // Submit button | ||||
|                 ModernButton( | ||||
|                   text: widget.submitButtonText, | ||||
|                   type: ModernButtonType.primary, | ||||
|                   size: ModernButtonSize.large, | ||||
|                   isLoading: widget.isLoading, | ||||
|                   onPressed: widget.isLoading ? null : _handleSubmit, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       }, | ||||
| @ -132,15 +541,108 @@ class _EntityFormState extends State<EntityForm> { | ||||
|         final field = _fieldByKey[key]; | ||||
|         final value = entry.value.text.trim(); | ||||
| 
 | ||||
|         // Skip confirm entries for any password group | ||||
|         // Skip fields that are marked as non-submittable (e.g., DataGrid) | ||||
|         final props = field?.customProperties ?? const {}; | ||||
|         final bool excludeFromSubmit = props['excludeFromSubmit'] == true; | ||||
|         if (excludeFromSubmit) { | ||||
|           continue; | ||||
|         } | ||||
| 
 | ||||
|         // Skip confirm entries for any password group | ||||
|         final bool isPassword = props['isPassword'] == true; | ||||
|         final bool isConfirm = props['isConfirm'] == true; | ||||
|         if (isPassword && isConfirm) continue; | ||||
| 
 | ||||
|         final bool assignByJsonPaths = props['assignByJsonPaths'] == true; | ||||
|         if (assignByJsonPaths) { | ||||
|           // If composite and empty -> skip adding base key | ||||
|           if (value.isEmpty) { | ||||
|             continue; | ||||
|           } | ||||
|           final Map<String, dynamic>? map = _tryDecodeMap(value); | ||||
|           if (map != null) { | ||||
|             map.forEach((path, v) { | ||||
|               if (path is String) { | ||||
|                 _assignValueByPath(formData, path, v); | ||||
|               } | ||||
|             }); | ||||
|             continue; | ||||
|           } | ||||
|           // If not decodable, also skip to avoid sending invalid base key | ||||
|           continue; | ||||
|         } | ||||
| 
 | ||||
|         formData[key] = value; | ||||
|       } | ||||
|       widget.onSubmit(formData); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   dynamic _readValueByPath(Map<String, dynamic>? source, String path) { | ||||
|     if (source == null) return null; | ||||
|     final segments = path.split('.'); | ||||
|     dynamic current = source; | ||||
|     for (final segment in segments) { | ||||
|       if (current is Map<String, dynamic> && current.containsKey(segment)) { | ||||
|         current = current[segment]; | ||||
|       } else { | ||||
|         return null; | ||||
|       } | ||||
|     } | ||||
|     return current; | ||||
|   } | ||||
| 
 | ||||
|   void _assignValueByPath( | ||||
|       Map<String, dynamic> target, String path, dynamic value) { | ||||
|     final segments = path.split('.'); | ||||
|     Map<String, dynamic> current = target; | ||||
|     for (int i = 0; i < segments.length; i++) { | ||||
|       final seg = segments[i]; | ||||
|       final bool isLast = i == segments.length - 1; | ||||
|       if (isLast) { | ||||
|         current[seg] = value; | ||||
|       } else { | ||||
|         if (current[seg] is! Map<String, dynamic>) { | ||||
|           current[seg] = <String, dynamic>{}; | ||||
|         } | ||||
|         current = current[seg] as Map<String, dynamic>; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   String _encodeMap(Map<String, dynamic> map) { | ||||
|     return const JsonEncoder().convert(map); | ||||
|   } | ||||
| 
 | ||||
|   Map<String, dynamic>? _tryDecodeMap(String value) { | ||||
|     try { | ||||
|       final decoded = const JsonDecoder().convert(value); | ||||
|       if (decoded is Map<String, dynamic>) return decoded; | ||||
|       return null; | ||||
|     } catch (_) { | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// Inherited scope to provide access to the form's controllers for advanced shared fields | ||||
| class EntityFormScope extends InheritedWidget { | ||||
|   final Map<String, TextEditingController> controllers; | ||||
|   final VoidCallback notifyParent; | ||||
| 
 | ||||
|   const EntityFormScope({ | ||||
|     super.key, | ||||
|     required this.controllers, | ||||
|     required this.notifyParent, | ||||
|     required Widget child, | ||||
|   }) : super(child: child); | ||||
| 
 | ||||
|   static EntityFormScope? of(BuildContext context) { | ||||
|     return context.dependOnInheritedWidgetOfExactType<EntityFormScope>(); | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   bool updateShouldNotify(covariant EntityFormScope oldWidget) { | ||||
|     return oldWidget.controllers != controllers; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,5 +1,4 @@ | ||||
| class ApiConstants { | ||||
|   static const baseUrl = 'http://localhost:9292'; | ||||
| // USER AUTH API'S // | ||||
|   static const loginEndpoint = "$baseUrl/token/session"; | ||||
|   static const getOtpEndpoint = "$baseUrl/token/user/send_email"; | ||||
| @ -23,4 +22,6 @@ class ApiConstants { | ||||
|   static const uploadSystemParamImg = '$baseUrl/api/logos/upload?ref=test'; | ||||
|   static const getSystemParameters = '$baseUrl/sysparam/getSysParams'; | ||||
|   static const updateSystemParams = '$baseUrl/sysparam/updateSysParams'; | ||||
| 
 | ||||
|   static const baseUrl = 'http://localhost:9292'; | ||||
| } | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user