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