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