From bc02a06d5612d3517a4e48778d2cde7fde19eb57 Mon Sep 17 00:00:00 2001 From: Gaurav Kumar Date: Wed, 10 Sep 2025 10:57:03 +0530 Subject: [PATCH] dattype --- .../fields/autocomplete_dropdown_field.dart | 205 +++++++ .../autocomplete_multiselect_field.dart | 186 ++++++ .../shared/fields/calculated_field.dart | 158 +++++ .../shared/fields/data_grid_field.dart | 132 +++++ .../shared/fields/dropdown_field.dart | 102 ++-- .../shared/fields/dynamic_dropdown_field.dart | 223 +++++++ .../dynamic_multiselect_dropdown_field.dart | 253 ++++++++ .../fields/entity_create_with_uploads.dart | 57 ++ .../shared/fields/one_to_many_field.dart | 208 +++++++ .../fields/one_to_one_relation_field.dart | 322 ++++++++++ .../fields/static_multiselect_field.dart | 90 +++ .../fields/value_list_picker_field.dart | 160 +++++ .../BuilderField/shared/ui/entity_form.dart | 560 +++++++++++++++++- base_project/lib/resources/api_constants.dart | 3 +- 14 files changed, 2580 insertions(+), 79 deletions(-) create mode 100644 base_project/lib/BuilderField/shared/fields/autocomplete_dropdown_field.dart create mode 100644 base_project/lib/BuilderField/shared/fields/autocomplete_multiselect_field.dart create mode 100644 base_project/lib/BuilderField/shared/fields/calculated_field.dart create mode 100644 base_project/lib/BuilderField/shared/fields/data_grid_field.dart create mode 100644 base_project/lib/BuilderField/shared/fields/dynamic_dropdown_field.dart create mode 100644 base_project/lib/BuilderField/shared/fields/dynamic_multiselect_dropdown_field.dart create mode 100644 base_project/lib/BuilderField/shared/fields/entity_create_with_uploads.dart create mode 100644 base_project/lib/BuilderField/shared/fields/one_to_many_field.dart create mode 100644 base_project/lib/BuilderField/shared/fields/one_to_one_relation_field.dart create mode 100644 base_project/lib/BuilderField/shared/fields/static_multiselect_field.dart create mode 100644 base_project/lib/BuilderField/shared/fields/value_list_picker_field.dart diff --git a/base_project/lib/BuilderField/shared/fields/autocomplete_dropdown_field.dart b/base_project/lib/BuilderField/shared/fields/autocomplete_dropdown_field.dart new file mode 100644 index 0000000..921b097 --- /dev/null +++ b/base_project/lib/BuilderField/shared/fields/autocomplete_dropdown_field.dart @@ -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>> 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? 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>>( + future: optionsLoader(), + builder: (context, snapshot) { + final List> 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 displayOptions = options + .map((e) => e[displayKey]) + .where((e) => e != null) + .map((e) => e.toString()); + + return Autocomplete( + optionsBuilder: (TextEditingValue tev) { + if (tev.text.isEmpty) return const Iterable.empty(); + return displayOptions.where( + (opt) => opt.toLowerCase().contains(tev.text.toLowerCase())); + }, + onSelected: (String selection) { + // 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)), + ], + ), + ); + } +} diff --git a/base_project/lib/BuilderField/shared/fields/autocomplete_multiselect_field.dart b/base_project/lib/BuilderField/shared/fields/autocomplete_multiselect_field.dart new file mode 100644 index 0000000..e622c3b --- /dev/null +++ b/base_project/lib/BuilderField/shared/fields/autocomplete_multiselect_field.dart @@ -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> 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? get customProperties => const { + 'isMultiSelect': true, + }; + + @override + Widget buildField({ + required TextEditingController controller, + required ColorScheme colorScheme, + VoidCallback? onChanged, + }) { + return FutureBuilder>( + future: optionsLoader(), + builder: (context, snapshot) { + final options = snapshot.data ?? const []; + final Set selected = controller.text.isEmpty + ? {} + : controller.text + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toSet(); + + void toggleSelection(String value) { + if (selected.contains(value)) { + selected.remove(value); + } else { + selected.add(value); + } + controller.text = selected.join(','); + onChanged?.call(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Autocomplete( + optionsBuilder: (TextEditingValue tev) { + if (tev.text.isEmpty) return const Iterable.empty(); + return options.where((opt) => + opt.toLowerCase().contains(tev.text.toLowerCase())); + }, + onSelected: (String selection) => toggleSelection(selection), + fieldViewBuilder: + (context, textController, focusNode, onFieldSubmitted) { + return ModernTextField( + label: label, + hint: hint, + controller: textController, + focusNode: focusNode, + onSubmitted: (_) { + final v = textController.text.trim(); + if (v.isNotEmpty) { + toggleSelection(v); + textController.clear(); + } + }, + suffixIcon: textController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + textController.clear(); + onChanged?.call(); + }, + ) + : const Icon(Icons.search), + onChanged: (_) => onChanged?.call(), + ); + }, + optionsViewBuilder: (context, onSelected, optionsIt) { + final list = optionsIt.toList(); + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 6, + color: colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: colorScheme.outline.withOpacity(0.15)), + ), + child: ConstrainedBox( + constraints: + const BoxConstraints(maxHeight: 280, minWidth: 240), + child: list.isEmpty + ? Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.info_outline, + color: colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + 'No results', + style: TextStyle( + color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + ) + : ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: list.length, + separatorBuilder: (_, __) => Divider( + height: 1, + color: colorScheme.outline.withOpacity(0.08)), + itemBuilder: (context, index) { + final opt = list[index]; + return ListTile( + title: Text(opt), + onTap: () { + onSelected(opt); + }, + ); + }, + ), + ), + ), + ); + }, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: selected + .map((value) => Chip( + label: Text(value), + onDeleted: () => toggleSelection(value), + )) + .toList(), + ), + if (snapshot.connectionState == ConnectionState.waiting) + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: LinearProgressIndicator(minHeight: 2), + ), + if (validator != null) + Builder( + builder: (context) { + final error = validator!(controller.text); + return error != null + ? Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(error, + style: TextStyle( + color: colorScheme.error, fontSize: 12)), + ) + : const SizedBox.shrink(); + }, + ) + ], + ); + }, + ); + } +} diff --git a/base_project/lib/BuilderField/shared/fields/calculated_field.dart b/base_project/lib/BuilderField/shared/fields/calculated_field.dart new file mode 100644 index 0000000..b107d6c --- /dev/null +++ b/base_project/lib/BuilderField/shared/fields/calculated_field.dart @@ -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 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? 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 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 _sources = const []; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final scope = EntityFormScope.of(context); + if (scope != null) { + _sources = widget.sourceKeys + .map((k) => scope.controllers[k]) + .whereType() + .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), + ); + } +} diff --git a/base_project/lib/BuilderField/shared/fields/data_grid_field.dart b/base_project/lib/BuilderField/shared/fields/data_grid_field.dart new file mode 100644 index 0000000..8f08573 --- /dev/null +++ b/base_project/lib/BuilderField/shared/fields/data_grid_field.dart @@ -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>> 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? 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>> Function() dataLoader; + + const _DataGridFieldWidget({ + required this.colorScheme, + required this.dataLoader, + }); + + @override + State<_DataGridFieldWidget> createState() => _DataGridFieldWidgetState(); +} + +class _DataGridFieldWidgetState extends State<_DataGridFieldWidget> { + late Future>> _future; + + @override + void initState() { + super.initState(); + _future = widget.dataLoader(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = widget.colorScheme; + return FutureBuilder>>( + 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 >[]; + 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(), + ), + ); + }, + ); + } +} diff --git a/base_project/lib/BuilderField/shared/fields/dropdown_field.dart b/base_project/lib/BuilderField/shared/fields/dropdown_field.dart index 10a9498..18096a2 100644 --- a/base_project/lib/BuilderField/shared/fields/dropdown_field.dart +++ b/base_project/lib/BuilderField/shared/fields/dropdown_field.dart @@ -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> 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> 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 ?? ''; + }, + ); + } +} diff --git a/base_project/lib/BuilderField/shared/fields/dynamic_dropdown_field.dart b/base_project/lib/BuilderField/shared/fields/dynamic_dropdown_field.dart new file mode 100644 index 0000000..02d0ff5 --- /dev/null +++ b/base_project/lib/BuilderField/shared/fields/dynamic_dropdown_field.dart @@ -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>> 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>> 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> _options = const []; + String _uiLabel = ''; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _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> 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), + ); + } +} diff --git a/base_project/lib/BuilderField/shared/fields/dynamic_multiselect_dropdown_field.dart b/base_project/lib/BuilderField/shared/fields/dynamic_multiselect_dropdown_field.dart new file mode 100644 index 0000000..f0c2699 --- /dev/null +++ b/base_project/lib/BuilderField/shared/fields/dynamic_multiselect_dropdown_field.dart @@ -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> 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> 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 _options = const []; + late final Set _selected; + bool _loading = true; + + @override + void initState() { + super.initState(); + _selected = widget.controller.text.isEmpty + ? {} + : widget.controller.text + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toSet(); + _load(); + } + + Future _load() async { + try { + final opts = await widget.optionsLoader(); + setState(() { + _options = opts; + _loading = false; + }); + } catch (_) { + setState(() => _loading = false); + } + } + + Future _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 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(), + ), + ], + ); + } +} diff --git a/base_project/lib/BuilderField/shared/fields/entity_create_with_uploads.dart b/base_project/lib/BuilderField/shared/fields/entity_create_with_uploads.dart new file mode 100644 index 0000000..eaf423d --- /dev/null +++ b/base_project/lib/BuilderField/shared/fields/entity_create_with_uploads.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import '../ui/entity_screens.dart'; +import '../utils/entity_field_store.dart'; + +typedef UploadHandler = Future Function( + String entityId, String entityName, String fileName, dynamic bytes); +typedef CreateAndReturnId = Future Function(Map 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 + final CreateAndReturnId createAndReturnId; + final Map + 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 _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>(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(); + }, + ); + } +} diff --git a/base_project/lib/BuilderField/shared/fields/one_to_many_field.dart b/base_project/lib/BuilderField/shared/fields/one_to_many_field.dart new file mode 100644 index 0000000..75717d8 --- /dev/null +++ b/base_project/lib/BuilderField/shared/fields/one_to_many_field.dart @@ -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 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? 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 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> _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? prefill}) { + final row = {}; + 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 = >[]; + for (final row in _rows) { + final map = {}; + 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(); + }, + ) + ], + ), + ), + ); + } +} diff --git a/base_project/lib/BuilderField/shared/fields/one_to_one_relation_field.dart b/base_project/lib/BuilderField/shared/fields/one_to_one_relation_field.dart new file mode 100644 index 0000000..9de3f90 --- /dev/null +++ b/base_project/lib/BuilderField/shared/fields/one_to_one_relation_field.dart @@ -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 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) { + 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? 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((f) { + final String type = f['type']?.toString() ?? 'text'; + final String flabel = f['label']?.toString() ?? ''; + final String fhint = f['hint']?.toString() ?? ''; + final String path = f['path']?.toString() ?? ''; + final bool requiredField = f['required'] == true; + final List>? options = + (f['options'] as List?)?.cast>(); + final String valueKey = f['valueKey']?.toString() ?? 'id'; + final String displayKey = f['displayKey']?.toString() ?? 'name'; + final int? maxLength = + f['maxLength'] is int ? f['maxLength'] as int : null; + final int? decimalPlaces = + f['decimalPlaces'] is int ? f['decimalPlaces'] as int : null; + final double? min = + (f['min'] is num) ? (f['min'] as num).toDouble() : null; + final double? max = + (f['max'] is num) ? (f['max'] as num).toDouble() : null; + + // 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) { + 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 current = {}; + if (controller.text.isNotEmpty) { + try { + final decoded = json.decode(controller.text); + if (decoded is Map) 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); + } +} diff --git a/base_project/lib/BuilderField/shared/fields/static_multiselect_field.dart b/base_project/lib/BuilderField/shared/fields/static_multiselect_field.dart new file mode 100644 index 0000000..7fe20c5 --- /dev/null +++ b/base_project/lib/BuilderField/shared/fields/static_multiselect_field.dart @@ -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 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 selected = controller.text.isEmpty + ? {} + : 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(); + }, + ) + ], + ); + } +} diff --git a/base_project/lib/BuilderField/shared/fields/value_list_picker_field.dart b/base_project/lib/BuilderField/shared/fields/value_list_picker_field.dart new file mode 100644 index 0000000..8981a73 --- /dev/null +++ b/base_project/lib/BuilderField/shared/fields/value_list_picker_field.dart @@ -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>> Function() optionsLoader; + final Map 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? 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>> Function() optionsLoader; + final Map 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 _open() async { + setState(() => _loading = true); + List> 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, + ) + ], + ); + } +} diff --git a/base_project/lib/BuilderField/shared/ui/entity_form.dart b/base_project/lib/BuilderField/shared/ui/entity_form.dart index ec8c32e..08c4341 100644 --- a/base_project/lib/BuilderField/shared/ui/entity_form.dart +++ b/base_project/lib/BuilderField/shared/ui/entity_form.dart @@ -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 fields; +// final Map? initialData; +// final Function(Map) 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 createState() => _EntityFormState(); +// } + +// class _EntityFormState extends State { +// final _formKey = GlobalKey(); +// final Map _controllers = {}; +// final Map _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( +// 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 passwordByGroup = {}; +// final Map 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 = {}; +// 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 fields; +// final Map? initialData; +// final Function(Map) 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 createState() => _EntityFormState(); +// } + +// class _EntityFormState extends State { +// final _formKey = GlobalKey(); +// final Map _controllers = {}; +// final Map _fieldByKey = {}; +// late final Map _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? paths = props['paths'] as List?; +// String initialText = +// widget.initialData?[field.fieldKey]?.toString() ?? ''; +// if (assignByJsonPaths && paths != null) { +// final Map 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( +// 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 passwordByGroup = {}; +// final Map 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 = {}; +// 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? 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? source, String path) { +// if (source == null) return null; +// final segments = path.split('.'); +// dynamic current = source; +// for (final segment in segments) { +// if (current is Map && current.containsKey(segment)) { +// current = current[segment]; +// } else { +// return null; +// } +// } +// return current; +// } + +// void _assignValueByPath( +// Map target, String path, dynamic value) { +// final segments = path.split('.'); +// Map 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) { +// current[seg] = {}; +// } +// current = current[seg] as Map; +// } +// } +// } + +// String _encodeMap(Map map) { +// return const JsonEncoder().convert(map); +// } + +// Map? _tryDecodeMap(String value) { +// try { +// final decoded = const JsonDecoder().convert(value); +// if (decoded is Map) 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 { final _formKey = GlobalKey(); final Map _controllers = {}; final Map _fieldByKey = {}; + late final Map _initialData; @override void initState() { @@ -39,10 +428,26 @@ class _EntityFormState extends State { } 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? paths = props['paths'] as List?; + String initialText = + widget.initialData?[field.fieldKey]?.toString() ?? ''; + if (assignByJsonPaths && paths != null) { + final Map 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 { 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 { 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? 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? source, String path) { + if (source == null) return null; + final segments = path.split('.'); + dynamic current = source; + for (final segment in segments) { + if (current is Map && current.containsKey(segment)) { + current = current[segment]; + } else { + return null; + } + } + return current; + } + + void _assignValueByPath( + Map target, String path, dynamic value) { + final segments = path.split('.'); + Map 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) { + current[seg] = {}; + } + current = current[seg] as Map; + } + } + } + + String _encodeMap(Map map) { + return const JsonEncoder().convert(map); + } + + Map? _tryDecodeMap(String value) { + try { + final decoded = const JsonDecoder().convert(value); + if (decoded is Map) 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 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(); + } + + @override + bool updateShouldNotify(covariant EntityFormScope oldWidget) { + return oldWidget.controllers != controllers; + } } diff --git a/base_project/lib/resources/api_constants.dart b/base_project/lib/resources/api_constants.dart index a71bcd0..c965839 100644 --- a/base_project/lib/resources/api_constants.dart +++ b/base_project/lib/resources/api_constants.dart @@ -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'; }