[FEAT] new Form Helper!
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
library astromic_helpers;
|
||||
library;
|
||||
|
||||
export 'src/form/form_helper.astromic.dart';
|
||||
export 'src/loading/loading_helper.astromic.dart';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export 'package:form_controller/form_controller.dart';
|
||||
export 'src/controller.dart';
|
||||
export 'src/form_field.dart';
|
||||
export 'src/form_value_wrapper.dart';
|
||||
export 'src/form_group_wrapper.dart';
|
||||
export 'src/enums/enums.exports.dart';
|
||||
export 'src/models/models.exports.dart';
|
||||
export './src/models/models.exports.dart';
|
||||
export './src/helpers/form_group_helper.dart';
|
||||
export './src/widgets/widget.exports.dart';
|
||||
|
||||
@@ -1,708 +1,233 @@
|
||||
//s1 Imports
|
||||
//s2 Core Package Imports
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
//s2 1st-party Package Imports
|
||||
import 'package:form_controller/form_controller.dart';
|
||||
//s2 3rd-party Package Imports
|
||||
//s2 Dependancies Imports
|
||||
//s3 Routes
|
||||
//s3 Services
|
||||
//s3 Models & Widgets
|
||||
import 'enums/enums.exports.dart';
|
||||
import 'models/form_group_instance.model.dart';
|
||||
import 'models/form_group_structure.model.dart';
|
||||
import 'models/form_group_value.model.dart';
|
||||
import 'models/initial_form_group_values.model.dart';
|
||||
import 'models/initial_values.model.dart';
|
||||
//s1 Exports
|
||||
import './models/models.exports.dart';
|
||||
|
||||
/// A specialized form controller to handle form states,
|
||||
class AstromicFormController extends FormController {
|
||||
AstromicFormController({
|
||||
AstromicFormInitialValues? initialValues,
|
||||
this.errorStream,
|
||||
}) : super(null) {
|
||||
if (initialValues != null) {
|
||||
// Take in the initials and proceed to fill:
|
||||
//1. Field Values
|
||||
if (initialValues.fieldValues != null && initialValues.fieldValues!.isNotEmpty) {
|
||||
for (MapEntry<String, String?> fieldValueEntry in initialValues.fieldValues!.entries) {
|
||||
String k = fieldValueEntry.key;
|
||||
String v = fieldValueEntry.value ?? '';
|
||||
bool isObs = initialValues.fieldObscurityValues != null && initialValues.fieldObscurityValues!.containsKey(k) && initialValues.fieldObscurityValues![k]!;
|
||||
//
|
||||
controller(k, initialText: v, isObscure: isObs);
|
||||
}
|
||||
}
|
||||
//2. Hosted Values
|
||||
if (initialValues.hostedValues != null && initialValues.hostedValues!.isNotEmpty) {
|
||||
for (MapEntry<String, (dynamic, bool)> hostedValueEntry in initialValues.hostedValues!.entries) {
|
||||
String k = hostedValueEntry.key;
|
||||
dynamic v = hostedValueEntry.value.$1;
|
||||
dynamic isReq = hostedValueEntry.value.$2;
|
||||
//
|
||||
setValue(k, v, isRequired: isReq);
|
||||
}
|
||||
}
|
||||
//3. Form Group Values
|
||||
if (initialValues.groupValues != null && initialValues.groupValues!.isNotEmpty) {
|
||||
_initializeFormGroups(initialValues.groupValues!);
|
||||
}
|
||||
/// The central brain of the Astromic Form System.
|
||||
///
|
||||
/// Manages a registry of reactive [AstromicFieldNode]s, handles dynamic group creation via UUIDs,
|
||||
/// and provides a robust validation mechanism covering both visible and hidden fields.
|
||||
class AstromicFormController {
|
||||
/// The global key attached to the [Form] widget for integrated Flutter validation.
|
||||
final GlobalKey<FormState> key = GlobalKey<FormState>();
|
||||
|
||||
/// Registry containing all active [AstromicFieldNode]s indexed by their unique identifiers.
|
||||
// ignore: strict_raw_type
|
||||
final Map<String, AstromicFieldNode> _nodes = {};
|
||||
|
||||
/// Registry containing all registered [AstromicFormGroupDefinition] schemas.
|
||||
final Map<String, AstromicFormGroupDefinition> _groupDefs = {};
|
||||
|
||||
/// Accessor for the map of group definitions currently registered in the controller.
|
||||
Map<String, AstromicFormGroupDefinition> get groupDefs => _groupDefs;
|
||||
|
||||
final StreamController<List<(String code, String? message)>> _errorStreamController = StreamController.broadcast();
|
||||
|
||||
/// A stream that emits a list of error codes and messages for external handling.
|
||||
Stream<List<(String code, String? message)>> get errorStream => _errorStreamController.stream;
|
||||
|
||||
/// Registers a [AstromicFormGroupDefinition] to the controller to enable dynamic instance handling.
|
||||
void registerGroup(AstromicFormGroupDefinition def) {
|
||||
if (!_groupDefs.containsKey(def.id)) {
|
||||
_groupDefs[def.id] = def;
|
||||
}
|
||||
}
|
||||
|
||||
//SECTION - Overrides
|
||||
@override
|
||||
TextEditingController controller(String id, {String? initialText, bool isObscure = false}) {
|
||||
TextEditingController ret = super.controller(id, initialText: initialText, isObscure: isObscure);
|
||||
//
|
||||
if (getState(id) == null) {
|
||||
fieldStates.addEntries(<MapEntry<String, AstromicFieldState>>[MapEntry<String, AstromicFieldState>(id, AstromicFieldState.idle)]);
|
||||
fieldMessages.addEntries(<MapEntry<String, String?>>[MapEntry<String, String?>(id, null)]);
|
||||
/// Retrieves a specific [AstromicFieldNode] by its unique [id], creating it if an [initialValue] is provided.
|
||||
AstromicFieldNode<T> node<T>(String id, {T? initialValue}) {
|
||||
if (_nodes.containsKey(id)) {
|
||||
return (_nodes[id] as dynamic) as AstromicFieldNode<T>;
|
||||
}
|
||||
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
|
||||
if (ret.text.isEmpty) {
|
||||
ret.text = initialText ?? '';
|
||||
}
|
||||
});
|
||||
return ret;
|
||||
|
||||
if (initialValue != null || _isNullable<T>()) {
|
||||
final newNode = AstromicFieldNode<T>(
|
||||
initialValue as T,
|
||||
formatter: (v) => v?.toString() ?? '',
|
||||
parser: (v) => (T == String ? v : null) as T?,
|
||||
);
|
||||
_nodes[id] = newNode;
|
||||
return newNode;
|
||||
}
|
||||
|
||||
T? defaultValue;
|
||||
if (T == String || T == _typeOf<String?>()) {
|
||||
defaultValue = '' as T;
|
||||
} else if (T == int || T == _typeOf<int?>()) {
|
||||
defaultValue = 0 as T;
|
||||
} else if (T == bool || T == _typeOf<bool?>()) {
|
||||
defaultValue = false as T;
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
final newNode = AstromicFieldNode<T>(
|
||||
defaultValue,
|
||||
formatter: (v) => v.toString(),
|
||||
parser: (v) => null,
|
||||
);
|
||||
_nodes[id] = newNode;
|
||||
return newNode;
|
||||
}
|
||||
|
||||
throw Exception("Node $id not found and cannot be lazy-inited for type $T. Provide an initialValue.");
|
||||
}
|
||||
|
||||
@override
|
||||
void removeController(String id) {
|
||||
super.removeController(id);
|
||||
Type _typeOf<T>() => T;
|
||||
|
||||
// Remove the field state
|
||||
if (fieldStates.containsKey(id)) {
|
||||
fieldStates.remove(id);
|
||||
}
|
||||
bool _isNullable<T>() => null is T;
|
||||
|
||||
// Remove the field message
|
||||
if (fieldMessages.containsKey(id)) {
|
||||
fieldMessages.remove(id);
|
||||
}
|
||||
}
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Field State
|
||||
//S1 - State Variables
|
||||
final Map<String, AstromicFieldState> fieldStates = <String, AstromicFieldState>{};
|
||||
final Map<String, String?> fieldMessages = <String, String?>{};
|
||||
//S1 - Streams
|
||||
static final StreamController<(String, AstromicFieldState)> _stateStreamController = StreamController<(String id, AstromicFieldState)>.broadcast();
|
||||
final Stream<(String id, AstromicFieldState)> stateStream = _stateStreamController.stream;
|
||||
final Stream<List<(String internalCode, String? message)>>? errorStream;
|
||||
//S1 - Methods
|
||||
|
||||
/// Get the field state and message of a specific field using it's ID.
|
||||
(AstromicFieldState, String? message)? getState(String fieldId) => (fieldStates.containsKey(fieldId)) ? (fieldStates[fieldId]!, fieldMessages[fieldId]) : null;
|
||||
|
||||
/// Set the field state and message of a specific field using it's ID.
|
||||
void setState(String fieldId, AstromicFieldState state, {String? message}) {
|
||||
if (!fieldStates.containsKey(fieldId)) {
|
||||
throw Exception('The state of the field ID $fieldId does not exist.');
|
||||
}
|
||||
fieldStates[fieldId] = state;
|
||||
fieldMessages[fieldId] = message;
|
||||
_stateStreamController.add((fieldId, state));
|
||||
/// Retrieves the [TextEditingController] associated with a specific field ID.
|
||||
TextEditingController controller(String id) {
|
||||
return node<dynamic>(id).syncedController;
|
||||
}
|
||||
|
||||
/// Reset the state of a specific field using it's ID.
|
||||
void resetState(String fieldId) => setState(fieldId, AstromicFieldState.idle);
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Hosted Values
|
||||
//S1 - State Variables
|
||||
final Map<String, (dynamic value, bool isRequired)> _hostedValues = <String, (dynamic, bool)>{};
|
||||
|
||||
//S1 - Streams
|
||||
static final StreamController<(String, bool)> _hostedValueValidationStreamController = StreamController<(String id, bool)>.broadcast();
|
||||
final Stream<(String id, bool isValidationErrored)> hostedValueValidationStream = _hostedValueValidationStreamController.stream;
|
||||
|
||||
//S1 - Methods
|
||||
|
||||
/// Get all the hosted values IDs.
|
||||
Map<String, (dynamic, bool)> get allValues => _hostedValues;
|
||||
|
||||
/// Get the value of a hosted state variable using it's ID.
|
||||
T? getValue<T>(String id) {
|
||||
if (!_hostedValues.containsKey(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_hostedValues[id]?.$1 is T?) {
|
||||
return _hostedValues[id]?.$1;
|
||||
} else {
|
||||
throw Exception('Value found but is not of the type $T, it\'s of type ${_hostedValues[id]?.$1.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the value of a hosted state variable using it's ID.
|
||||
void setValue<T>(String id, T? data, {bool isRequired = false}) {
|
||||
if (!_hostedValues.keys.toList().contains(id)) {
|
||||
return _hostedValues.addEntries(<MapEntry<String, (T?, bool)>>[MapEntry<String, (T?, bool)>(id, (null, isRequired))]);
|
||||
}
|
||||
//
|
||||
else {
|
||||
bool isReq = _hostedValues[id]!.$2;
|
||||
_hostedValues[id] = (data, isReq);
|
||||
_hostedValueValidationStreamController.add((id, false));
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the value of a hosted state variable using it's ID.
|
||||
void removeValue(String id) {
|
||||
if (_hostedValues.keys.toList().contains(id)) {
|
||||
_hostedValues.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate hosted values.
|
||||
bool validateValues(List<String> valueIDs) {
|
||||
for (String hostedValueID in valueIDs) {
|
||||
if (_hostedValues.containsKey(hostedValueID) && _hostedValues[hostedValueID]!.$2 && _hostedValues[hostedValueID]!.$1 == null) {
|
||||
// Validation Error!
|
||||
_hostedValueValidationStreamController.add((hostedValueID, true));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Form Groups
|
||||
//S1 - State Variables
|
||||
final List<FormGroupStructure> _formGroups = <FormGroupStructure>[];
|
||||
//S1 - Methods
|
||||
|
||||
/// Does the controller has a field group with this ID.
|
||||
bool hasFieldGroup(String formGroupID) => getGroupStructure(formGroupID) != null;
|
||||
|
||||
/// Get the structure of this groupId
|
||||
FormGroupStructure? getGroupStructure(String groupID) {
|
||||
// Get the full path of the group
|
||||
final String? fullPath = getFullPathOfGroup(groupID, formGroups: _formGroups);
|
||||
|
||||
// If no path is found, return null
|
||||
if (fullPath == null) return null;
|
||||
|
||||
// Split the path into segments (using the standard separator)
|
||||
final List<String> pathSegments = fullPath.split('->');
|
||||
|
||||
// We start with the root group (the first segment in the path)
|
||||
FormGroupStructure? currentGroup = _formGroups.where((FormGroupStructure group) => group.id == pathSegments.first).firstOrNull;
|
||||
|
||||
// If the root group is not found, return null
|
||||
if (currentGroup == null) return null;
|
||||
|
||||
// Traverse through the path segments to find the group or subgroup
|
||||
for (int i = 1; i < pathSegments.length; i++) {
|
||||
final String segment = pathSegments[i];
|
||||
|
||||
// Search for the subgroup within the current group
|
||||
final (int, FormGroupStructure)? subGroup = currentGroup?.subGroups?.where(((int, FormGroupStructure) subGroup) => subGroup.$2.id == segment).firstOrNull;
|
||||
|
||||
// If a subgroup is found, update currentGroup to the subgroup
|
||||
if (subGroup != null) {
|
||||
currentGroup = subGroup.$2;
|
||||
/// Programmatically sets the [value] for a field, creating the node if it does not exist.
|
||||
void set<T>(String id, T value) {
|
||||
if (_nodes.containsKey(id)) {
|
||||
final node = _nodes[id]!;
|
||||
if (node is AstromicFieldNode<T>) {
|
||||
node.value = value;
|
||||
} else {
|
||||
// If no subgroup is found at this level, return null
|
||||
return null;
|
||||
throw Exception("Type Mismatch: Node '$id' is type ${node.runtimeType}, but tried to set $T");
|
||||
}
|
||||
} else {
|
||||
_nodes[id] = AstromicFieldNode<T>(
|
||||
value,
|
||||
formatter: (v) => v.toString(),
|
||||
parser: (v) => null,
|
||||
);
|
||||
}
|
||||
|
||||
return currentGroup;
|
||||
}
|
||||
|
||||
/// returns the full path of this group ID.
|
||||
String? getFullPathOfGroup(String targetGroupID, {List<FormGroupStructure>? formGroups, String currentPath = '', String separator = '->'}) {
|
||||
// Loop through each FormGroupStructure
|
||||
for (final FormGroupStructure group in (formGroups ?? _formGroups)) {
|
||||
// If the group ID matches, return the current path
|
||||
if (group.id == targetGroupID) return '$currentPath${group.id}';
|
||||
/// Retrieves the current value of a field, leveraging lazy initialization for primitives.
|
||||
T get<T>(String id) {
|
||||
return node<T>(id).value;
|
||||
}
|
||||
|
||||
// Otherwise, check in its subgroups recursively
|
||||
for (final (int, FormGroupStructure) subGroup in group.subGroups ?? <(int, FormGroupStructure)>[]) {
|
||||
final String? subGroupPath = getFullPathOfGroup(targetGroupID, formGroups: <FormGroupStructure>[subGroup.$2], currentPath: '$currentPath${group.id}$separator');
|
||||
// Return the path if found
|
||||
if (subGroupPath != null) {
|
||||
return subGroupPath;
|
||||
}
|
||||
/// Safely attempts to retrieve a value, returning `null` if the node is missing or of a different type.
|
||||
T? tryGet<T>(String id) {
|
||||
if (_nodes.containsKey(id)) {
|
||||
final n = _nodes[id];
|
||||
if (n is AstromicFieldNode<T>) {
|
||||
return n.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Return an empty string if the group ID is not found
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get the count of instances of this group.
|
||||
int getInstanceCount(String targetGroupID, {Map<String, int>? parents}) {
|
||||
// Get the group structure
|
||||
FormGroupStructure? structure = getGroupStructure(targetGroupID);
|
||||
/// Retrieves the manifest [ValueNotifier] containing the list of UUIDs for a given group.
|
||||
ValueNotifier<List<String>> getManifest(String groupId, String? parentUuid) {
|
||||
final key = parentUuid == null ? '${groupId}_manifest' : '${_getParentKey(groupId, parentUuid)}_manifest';
|
||||
|
||||
if (structure != null) {
|
||||
// Get the prefix of the parents
|
||||
String? prefix;
|
||||
if (isASubGroup(targetGroupID) && parents != null) {
|
||||
prefix = parents.entries.map((MapEntry<String, int> parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-');
|
||||
}
|
||||
|
||||
// Get and process the regex pattern.
|
||||
late RegExp pattern;
|
||||
if (prefix != null) {
|
||||
// This is a subgroup
|
||||
pattern = RegExp(r'^' + ('$prefix-') + standeredGroupFormat(targetGroupID, r'[\d+]', structure.fields.first) + r'$');
|
||||
} else {
|
||||
pattern = RegExp(r'^' + standeredGroupFormat(targetGroupID, r'[\d+]', structure.fields.first) + r'$');
|
||||
}
|
||||
|
||||
// Return keys that match the pattern.
|
||||
return controllers.keys.where((String c) => pattern.hasMatch(c)).nonNulls.toList().length;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void _validateSubGroupsRecursively(FormGroupStructure groupStructure) {
|
||||
groupStructure.subGroups?.forEach(((int, FormGroupStructure) subGroupTuple) {
|
||||
final FormGroupStructure subGroup = subGroupTuple.$2;
|
||||
assert(subGroup.fields.isNotEmpty, '${subGroup.id}: Subgroup fields should NOT be empty.');
|
||||
|
||||
// Recursively validate subgroups of subgroups
|
||||
if (subGroup.subGroups != null) {
|
||||
_validateSubGroupsRecursively(subGroup);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _addGroupControllers(FormGroupStructure structure, int index, {String? parentPrefix}) {
|
||||
final String baseID = parentPrefix ?? structure.id;
|
||||
|
||||
for (final String fieldID in structure.fields) {
|
||||
final String fullID = standeredGroupFormat(baseID, index.toString(), fieldID);
|
||||
log('Trying to initialize and add $fullID');
|
||||
controller(fullID);
|
||||
}
|
||||
|
||||
for (final String valueID in structure.values ?? <String>[]) {
|
||||
final String fullID = standeredGroupFormat(baseID, index.toString(), valueID);
|
||||
log('Trying to initialize and add $fullID');
|
||||
controller(fullID);
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeGroupControllersRecursively(FormGroupStructure groupStructure, int initialCount, {String parentPrefix = ''}) {
|
||||
// Add main group fields/values
|
||||
for (int groupIndex = 0; groupIndex < initialCount; groupIndex++) {
|
||||
_addGroupControllers(groupStructure, groupIndex, parentPrefix: parentPrefix.isEmpty ? null : parentPrefix);
|
||||
|
||||
// Recursively handle subgroups
|
||||
if (groupStructure.subGroups != null && groupStructure.subGroups!.isNotEmpty) {
|
||||
for (final (int subgroupInitialCount, FormGroupStructure subGroup) in groupStructure.subGroups!) {
|
||||
final String subgroupPrefix = parentPrefix.isEmpty
|
||||
? standeredGroupFormat(groupStructure.id, groupIndex.toString(), subGroup.id)
|
||||
: standeredGroupFormat(parentPrefix, groupIndex.toString(), subGroup.id); // Add to parentPrefix only once
|
||||
|
||||
// Initialize subgroup controllers recursively
|
||||
_initializeGroupControllersRecursively(subGroup, subgroupInitialCount, parentPrefix: subgroupPrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively initialize controllers for the group and its subgroups
|
||||
void initializeFormGroup(FormGroupStructure groupStructure, {int initialCount = 1}) {
|
||||
assert(groupStructure.fields.isNotEmpty, '${groupStructure.id}: Group fields should NOT be empty.');
|
||||
|
||||
// Validate subgroups (if any)
|
||||
_validateSubGroupsRecursively(groupStructure);
|
||||
|
||||
// Add structure to registry
|
||||
_formGroups.add(groupStructure);
|
||||
|
||||
// Initialize the group instances
|
||||
_initializeGroupControllersRecursively(groupStructure, initialCount);
|
||||
}
|
||||
|
||||
/// Get the target formGroup Value.
|
||||
FormGroupValue? getFormGroupValue(String formGroupID, {Map<String, int>? parents}) {
|
||||
// Get the group structure with the ID
|
||||
FormGroupStructure? groupStructure = getGroupStructure(formGroupID);
|
||||
assert(groupStructure != null, 'The Group $formGroupID doesn\'t seem to be found, are you sure you initialized it?');
|
||||
|
||||
// Get the prefix for subgroups if exists
|
||||
String? prefix;
|
||||
if (isASubGroup(formGroupID) && parents != null) {
|
||||
prefix = parents.entries.map((MapEntry<String, int> parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-');
|
||||
}
|
||||
|
||||
// Get the current fields with this ID
|
||||
int instancesCount = getInstanceCount(formGroupID, parents: parents);
|
||||
|
||||
// get the fields IDs
|
||||
List<String> fieldsIDs = groupStructure!.fields.nonNulls.toList();
|
||||
|
||||
// get the values IDs
|
||||
List<String> valuesIDs = groupStructure.values?.nonNulls.toList() ?? <String>[];
|
||||
|
||||
List<FormGroupInstance> instances = <FormGroupInstance>[];
|
||||
for (int i = 0; i < instancesCount; i++) {
|
||||
// get the subGroups
|
||||
List<FormGroupValue> subValues = <FormGroupValue>[];
|
||||
if (groupStructure.subGroups != null && groupStructure.subGroups!.isNotEmpty) {
|
||||
subValues = groupStructure.subGroups!
|
||||
.map(
|
||||
((int, FormGroupStructure) s) => getFormGroupValue(
|
||||
s.$2.id,
|
||||
parents: <String, int>{
|
||||
if (parents != null) ...parents,
|
||||
formGroupID: i,
|
||||
},
|
||||
),
|
||||
)
|
||||
.nonNulls
|
||||
.toList();
|
||||
}
|
||||
String pr = prefix != null ? '$prefix-' : '';
|
||||
instances.add(
|
||||
FormGroupInstance(
|
||||
composedID: pr + standeredGroupFormat(formGroupID, i.toString(), ''),
|
||||
fields: Map<String, String>.fromEntries(fieldsIDs
|
||||
.map((String id) => MapEntry<String, String>(pr + standeredGroupFormat(formGroupID, i.toString(), id), tryValue(pr + standeredGroupFormat(formGroupID, i.toString(), id)) ?? ''))
|
||||
.toList()),
|
||||
values: valuesIDs.isNotEmpty
|
||||
? Map<String, dynamic>.fromEntries(valuesIDs
|
||||
.map((String id) => MapEntry<String, dynamic>(pr + standeredGroupFormat(formGroupID, i.toString(), id), getValue<dynamic>(pr + standeredGroupFormat(formGroupID, i.toString(), id))))
|
||||
.toList())
|
||||
: <String, dynamic>{},
|
||||
subGroups: subValues,
|
||||
),
|
||||
if (!_nodes.containsKey(key)) {
|
||||
_nodes[key] = AstromicFieldNode<List<String>>(
|
||||
[],
|
||||
formatter: (v) => v.length.toString(),
|
||||
parser: (v) => [],
|
||||
);
|
||||
}
|
||||
FormGroupValue groupValue = FormGroupValue(groupID: formGroupID, instancesCount: instancesCount, instances: instances);
|
||||
return groupValue;
|
||||
return _nodes[key] as ValueNotifier<List<String>>;
|
||||
}
|
||||
|
||||
/// Is this group a SubGroup?
|
||||
bool isASubGroup(String formGroupID) {
|
||||
List<String>? fullPath = getFullPathOfGroup(formGroupID)?.split('->');
|
||||
return fullPath != null && fullPath.length > 1 && fullPath.indexOf(formGroupID) != 0;
|
||||
/// Adds a new instance to a dynamic group, generating a UUID and initializing all schema fields.
|
||||
String addGroupInstance(String groupId, {String? parentUuid, Map<String, dynamic>? initialData}) {
|
||||
final def = _groupDefs[groupId];
|
||||
if (def == null) throw Exception("Group $groupId not registered.");
|
||||
|
||||
final newUuid = DateTime.now().microsecondsSinceEpoch.toString();
|
||||
|
||||
def.schema.forEach((fieldName, config) {
|
||||
final nodeKey = '${groupId}_${fieldName}_$newUuid';
|
||||
final startVal = initialData?[fieldName] ?? config.initialValue;
|
||||
config.register(nodeKey, startVal, _createTypedNode);
|
||||
});
|
||||
|
||||
for (var sub in def.subGroups) {
|
||||
final subKey = '${groupId}_${sub}_${newUuid}_manifest';
|
||||
_nodes[subKey] = AstromicFieldNode<List<String>>(
|
||||
[],
|
||||
formatter: (v) => v.length.toString(),
|
||||
parser: (v) => [],
|
||||
);
|
||||
}
|
||||
|
||||
final manifest = getManifest(groupId, parentUuid);
|
||||
manifest.value = [...manifest.value, newUuid];
|
||||
return newUuid;
|
||||
}
|
||||
|
||||
/// Add an instance to the group.
|
||||
String addInstanceToFormGroup(String formGroupID, {Map<String, int>? parents}) {
|
||||
// Get the group structure with the same ID
|
||||
FormGroupStructure? structure = getGroupStructure(formGroupID);
|
||||
assert(structure != null, 'The Group $formGroupID doesn\'t seem to be found, are you sure you initialized it?');
|
||||
|
||||
// Get the prefix for subgroups if exists
|
||||
String? prefix;
|
||||
if (isASubGroup(formGroupID) && parents != null) {
|
||||
prefix = parents.entries.map((MapEntry<String, int> parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-');
|
||||
}
|
||||
|
||||
// Get current instances of this group and the new index.
|
||||
int currentGroupInstances = getInstanceCount(formGroupID, parents: parents);
|
||||
int newGroupIndex = currentGroupInstances;
|
||||
|
||||
// Add the controllers and values.
|
||||
for (String fieldID in structure!.fields.nonNulls) {
|
||||
String p = (prefix != null ? '$prefix-' : '') + standeredGroupFormat(formGroupID, newGroupIndex.toString(), fieldID);
|
||||
controller(p.trim(), initialText: '');
|
||||
}
|
||||
if (structure.values != null && structure.values!.isNotEmpty) {
|
||||
for (String valueID in structure.values!.nonNulls) {
|
||||
String p = (prefix != null ? '$prefix-' : '') + standeredGroupFormat(formGroupID, newGroupIndex.toString(), valueID);
|
||||
controller(p.trim());
|
||||
}
|
||||
}
|
||||
String p = (prefix != null ? '$prefix-' : '') + standeredGroupFormat(formGroupID, newGroupIndex.toString(), '');
|
||||
return p.trim();
|
||||
/// Hides a group instance from the UI by removing its UUID from the manifest.
|
||||
void removeGroupInstance(String groupId, String uuid, {String? parentUuid}) {
|
||||
final manifest = getManifest(groupId, parentUuid);
|
||||
manifest.value = manifest.value.where((id) => id != uuid).toList();
|
||||
}
|
||||
|
||||
void _copyInstance(String groupID, int fromIndex, int toIndex, Map<String, int>? parents) {
|
||||
log('_copyInstance: $groupID from $fromIndex to $toIndex, parents=$parents');
|
||||
FormGroupStructure? structure = getGroupStructure(groupID);
|
||||
assert(structure != null);
|
||||
|
||||
String prefix = parents?.entries.map((MapEntry<String, int> e) => standeredGroupFormat(e.key, e.value.toString(), null)).join('-') ?? '';
|
||||
String composedID = prefix.isNotEmpty ? '$prefix-$groupID' : groupID;
|
||||
|
||||
// Copy fields
|
||||
for (String field in structure!.fields) {
|
||||
String fromID = standeredGroupFormat(composedID, fromIndex.toString(), field);
|
||||
String toID = standeredGroupFormat(composedID, toIndex.toString(), field);
|
||||
String val = tryValue(fromID) ?? '';
|
||||
log('Copying field $fromID ($val) to $toID');
|
||||
set(toID, val);
|
||||
}
|
||||
|
||||
// Copy values
|
||||
for (String value in structure.values ?? <String>[]) {
|
||||
String fromID = standeredGroupFormat(composedID, fromIndex.toString(), value);
|
||||
String toID = standeredGroupFormat(composedID, toIndex.toString(), value);
|
||||
dynamic val = getValue(fromID);
|
||||
log('Copying value $fromID ($val) to $toID');
|
||||
setValue(toID, val);
|
||||
set(toID, tryValue(fromID) ?? '');
|
||||
}
|
||||
|
||||
// Recursively copy subgroups
|
||||
if (structure.subGroups != null) {
|
||||
for ((int, FormGroupStructure) sg in structure.subGroups!) {
|
||||
int sgCount = getInstanceCount(sg.$2.id, parents: <String, int>{...?parents, groupID: fromIndex});
|
||||
log('Subgroup ${sg.$2.id} has $sgCount instances');
|
||||
for (int j = 0; j < sgCount; j++) {
|
||||
_copyInstance(sg.$2.id, j, j, <String, int>{...?parents, groupID: toIndex});
|
||||
}
|
||||
}
|
||||
}
|
||||
void _createTypedNode<T>(String key, AstromicFieldConfig<T> config, T initialValue) {
|
||||
_nodes[key] = AstromicFieldNode<T>(
|
||||
initialValue,
|
||||
formatter: config.formatter ?? (v) => v.toString(),
|
||||
parser: config.parser ?? (v) => (T == String ? v : null) as T,
|
||||
validators: config.validators ?? (config.validator != null ? [config.validator!] : []),
|
||||
);
|
||||
}
|
||||
|
||||
void _removeInstance(String groupID, int index, Map<String, int>? parents) {
|
||||
log('_removeInstance: $groupID index $index, parents=$parents');
|
||||
_checkSubgroups(groupID, parents: <String, int>{...?parents, groupID: index});
|
||||
FormGroupStructure? structure = getGroupStructure(groupID);
|
||||
String prefix = parents?.entries.map((MapEntry<String, int> e) => standeredGroupFormat(e.key, e.value.toString(), null)).join('-') ?? '';
|
||||
String composedID = prefix.isNotEmpty ? '$prefix-$groupID' : groupID;
|
||||
_removeGroupControllers(composedID, <int>[index], structure!.fields, structure.values ?? <String>[]);
|
||||
String _getParentKey(String childGroup, String parentUuid) {
|
||||
return '${childGroup}_$parentUuid';
|
||||
}
|
||||
|
||||
void _removeGroupControllers(String composedID, List<int> indecies, List<String> fields, List<String> values, {bool switchValuesFirst = false}) {
|
||||
log('_removeGroupControllers: composedID=$composedID, indecies=$indecies, switchValuesFirst=$switchValuesFirst');
|
||||
for (int i in indecies) {
|
||||
log('Processing index $i');
|
||||
for (String fieldID in fields) {
|
||||
String p = standeredGroupFormat(composedID, (switchValuesFirst ? (i + 1) : i).toString(), fieldID);
|
||||
String k = standeredGroupFormat(composedID, i.toString(), fieldID);
|
||||
log('Field: k=$k, p=$p');
|
||||
if (switchValuesFirst) {
|
||||
String val = tryValue(p) ?? '';
|
||||
log('Setting $k to $val from $p');
|
||||
set(k, val);
|
||||
}
|
||||
log('Removing controller $p');
|
||||
removeController(p);
|
||||
}
|
||||
for (String valueID in values) {
|
||||
String p = standeredGroupFormat(composedID, (switchValuesFirst ? (i + 1) : i).toString(), valueID);
|
||||
String k = standeredGroupFormat(composedID, i.toString(), valueID);
|
||||
log('Value: k=$k, p=$p');
|
||||
if (switchValuesFirst) {
|
||||
String val = tryValue(p) ?? '';
|
||||
log('Setting $k to $val from $p');
|
||||
set(k, val);
|
||||
dynamic hostedVal = getValue(p);
|
||||
log('Setting hosted value $k to $hostedVal from $p');
|
||||
setValue(k, hostedVal);
|
||||
}
|
||||
log('Removing hosted value $p');
|
||||
removeValue(p);
|
||||
set(p, '');
|
||||
log('Removing controller $p');
|
||||
removeController(p);
|
||||
}
|
||||
/// Performs a full validation of both the UI state and internal logical data nodes.
|
||||
bool validate() {
|
||||
final bool ui = key.currentState?.validate() ?? false;
|
||||
bool data = true;
|
||||
for (var n in _nodes.values) {
|
||||
if (n.validate() != null) data = false;
|
||||
}
|
||||
return ui && data;
|
||||
}
|
||||
|
||||
void _checkSubgroups(String groupID, {Map<String, int>? parents}) {
|
||||
log('_checkSubgroups: groupID=$groupID, parents=$parents');
|
||||
// Get the group structure and check it's subGroups
|
||||
FormGroupStructure? structure = getGroupStructure(groupID);
|
||||
if (structure!.subGroups != null && structure.subGroups!.isNotEmpty) {
|
||||
for ((int, FormGroupStructure) sg in structure.subGroups!) {
|
||||
log('Processing subgroup ${sg.$2.id}');
|
||||
// Get the SubGroup instances Count under this parent instance
|
||||
int subgroupCount = getInstanceCount(sg.$2.id, parents: parents);
|
||||
log('Subgroup count: $subgroupCount');
|
||||
String prefix = parents?.entries.map((MapEntry<String, int> parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-') ?? '';
|
||||
log('Prefix for subgroup: $prefix');
|
||||
/// Validates a specific list of field [ids] and triggers their visual error states.
|
||||
bool validateOnly(List<String> ids) {
|
||||
bool allValid = true;
|
||||
for (var id in ids) {
|
||||
final node = _nodes[id];
|
||||
if (node == null) continue;
|
||||
|
||||
// Recurse through subGroups to remove their sub-subgroups
|
||||
for (int sgInstance = 0; sgInstance < subgroupCount; sgInstance++) {
|
||||
log('Recursing for sgInstance $sgInstance');
|
||||
_checkSubgroups(
|
||||
sg.$2.id,
|
||||
parents: <String, int>{
|
||||
...?parents,
|
||||
sg.$2.id: sgInstance
|
||||
},
|
||||
);
|
||||
}
|
||||
// Remove controllers for these subgroups.
|
||||
String composedID = prefix.isNotEmpty ? '$prefix-${sg.$2.id}' : sg.$2.id;
|
||||
log('Removing controllers for $composedID, indices 0 to ${subgroupCount-1}');
|
||||
_removeGroupControllers(composedID, List<int>.generate(subgroupCount, (int ii) => ii), sg.$2.fields, sg.$2.values ?? <String>[]);
|
||||
}
|
||||
final error = node.validate();
|
||||
if (error != null) allValid = false;
|
||||
}
|
||||
return allValid;
|
||||
}
|
||||
|
||||
/// Remove an instance from the group
|
||||
void removeInstanceFromGroup(String targetGroupID, int indexToRemove, {Map<String, int>? parents}) {
|
||||
log('Removing instance $indexToRemove from group $targetGroupID with parents $parents');
|
||||
// Get the main targeted group's structure
|
||||
FormGroupStructure? targetedGroupStructure = getGroupStructure(targetGroupID);
|
||||
assert(targetedGroupStructure != null, 'The Group $targetGroupID doesn\'t seem to be found, are you sure you initialized it?');
|
||||
/// Exports the data of a specific group as a list of maps, including all nested subgroups.
|
||||
List<Map<String, dynamic>> getGroupData(String groupId, {String? parentUuid}) {
|
||||
final manifest = getManifest(groupId, parentUuid);
|
||||
final List<String> uuids = manifest.value;
|
||||
final def = _groupDefs[groupId]!;
|
||||
|
||||
// Get the main targeted group's count
|
||||
int targetedGroupCount = getInstanceCount(targetGroupID, parents: parents);
|
||||
log('Targeted group count: $targetedGroupCount');
|
||||
assert(indexToRemove < targetedGroupCount, 'The index to remove is larger than the whole instances count. ($indexToRemove , $targetedGroupCount)');
|
||||
return uuids.map((uuid) {
|
||||
final Map<String, dynamic> row = {};
|
||||
|
||||
if (indexToRemove == (targetedGroupCount - 1)) {
|
||||
log('Removing last item');
|
||||
// Last Item in the group, Remove directly.
|
||||
_removeInstance(targetGroupID, indexToRemove, parents);
|
||||
} else {
|
||||
log('Removing middle item, shifting');
|
||||
// Shift all subsequent instances down
|
||||
for (int i = indexToRemove; i < targetedGroupCount - 1; i++) {
|
||||
_copyInstance(targetGroupID, i + 1, i, parents);
|
||||
}
|
||||
// Then remove the last instance
|
||||
_removeInstance(targetGroupID, targetedGroupCount - 1, parents);
|
||||
}
|
||||
}
|
||||
|
||||
(List<String> fields, List<String> values) _checkSubgroupsToValidate(String groupID, {Map<String, int>? parents}) {
|
||||
FormGroupStructure? groupStructure = getGroupStructure(groupID);
|
||||
String? prefix;
|
||||
if (parents != null) {
|
||||
prefix = parents.entries.map((MapEntry<String, int> parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-');
|
||||
}
|
||||
//
|
||||
//
|
||||
List<String> fieldsToValidate = <String>[];
|
||||
List<String> valuesToValidate = <String>[];
|
||||
for ((int, FormGroupStructure) sg in (groupStructure?.subGroups ?? <(int, FormGroupStructure)>[])) {
|
||||
FormGroupStructure? subgroupStructure = getGroupStructure(sg.$2.id);
|
||||
int sgInstances = getInstanceCount(sg.$2.id, parents: <String, int>{...?parents});
|
||||
for (int i = 0; i < sgInstances; i++) {
|
||||
_checkSubgroupsToValidate(
|
||||
sg.$2.id,
|
||||
parents: <String, int>{
|
||||
...?parents,
|
||||
...<String, int>{sg.$2.id: i}
|
||||
},
|
||||
);
|
||||
for (String fieldID in subgroupStructure!.fields) {
|
||||
fieldsToValidate.add(standeredGroupFormat((prefix != null ? '$prefix-' : '') + sg.$2.id, i.toString(), fieldID));
|
||||
}
|
||||
for (String valueID in (subgroupStructure.values ?? <String>[])) {
|
||||
valuesToValidate.add(standeredGroupFormat((prefix != null ? '$prefix-' : '') + sg.$2.id, i.toString(), valueID));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (fieldsToValidate, valuesToValidate);
|
||||
}
|
||||
|
||||
/// Validate the group with ID.
|
||||
bool validateGroup(String groupID) {
|
||||
// Get tha main targeted group's structure
|
||||
FormGroupStructure? groupStructure = getGroupStructure(groupID);
|
||||
assert(groupStructure != null, 'The Group $groupID doesn\'t seem to be found, are you sure you initialized it?');
|
||||
|
||||
// Get current instances of this group and the new index.
|
||||
int currentGroupInstances = getInstanceCount(groupID);
|
||||
|
||||
List<String> fieldsToValidate = <String>[];
|
||||
List<String> valuesToValidate = <String>[];
|
||||
|
||||
for (int i = 0; i < currentGroupInstances; i++) {
|
||||
for (String fieldID in groupStructure!.fields) {
|
||||
fieldsToValidate.add(standeredGroupFormat(groupID, i.toString(), fieldID));
|
||||
}
|
||||
for (String valueID in (groupStructure.values ?? <String>[])) {
|
||||
valuesToValidate.add(standeredGroupFormat(groupID, i.toString(), valueID));
|
||||
for (var fieldName in def.schema.keys) {
|
||||
final fieldId = "${groupId}_${fieldName}_$uuid";
|
||||
row[fieldName] = get(fieldId);
|
||||
}
|
||||
|
||||
// Loop through the subgroups and get their fields and values
|
||||
(List<String> fields, List<String> values) sgRet = _checkSubgroupsToValidate(groupID, parents: <String, int>{groupID: i});
|
||||
fieldsToValidate.addAll(sgRet.$1);
|
||||
valuesToValidate.addAll(sgRet.$2);
|
||||
} //
|
||||
return validateOnly(fieldsToValidate) && validateValues(valuesToValidate);
|
||||
}
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Helper Methods
|
||||
String standeredGroupFormat(String groupID, String groupIndex, String? secondaryID) => '$groupID-#$groupIndex${secondaryID == null ? "" : "-$secondaryID"}';
|
||||
|
||||
// void _addInitialControllers(Map<String, (String, bool)>? initialValues) {
|
||||
// if (initialValues != null) {
|
||||
// // Add in the initial field states...
|
||||
// fieldStates.addEntries(initialValues.entries.map((MapEntry<String, (String, bool)> e) => MapEntry<String, AstromicFieldState>(
|
||||
// e.key, // controller ID
|
||||
// AstromicFieldState.idle, // Initial state of any new controller is Idle
|
||||
// )));
|
||||
|
||||
// // Add in the initial field messages...
|
||||
// fieldMessages.addEntries(initialValues.entries.toList().map((MapEntry<String, (String, bool)> e) => MapEntry<String, String?>(
|
||||
// e.key, // Controller ID
|
||||
// null, // The initial message it has which is Null
|
||||
// )));
|
||||
// }
|
||||
// }
|
||||
|
||||
// void _addInitialHostedValues(Map<String, (dynamic, bool)>? initialValues) {
|
||||
// if (initialValues != null) {
|
||||
// for (MapEntry<String, (dynamic, bool)> vEntry in initialValues.entries) {
|
||||
// setValue(vEntry.key, vEntry.value.$1, isRequired: vEntry.value.$2);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
_initializeFormGroups(Map<String, InitialFormGroupValue> initVals, {Map<String, int>? parents}) {
|
||||
log('Now we have groups, Initialize them with the parent: $parents');
|
||||
for (MapEntry<String, InitialFormGroupValue> groupValueEntry in initVals.entries) {
|
||||
String groupID = groupValueEntry.key;
|
||||
InitialFormGroupValue groupValue = groupValueEntry.value;
|
||||
|
||||
for (int i = 0; i < groupValue.instancesCount; i++) {
|
||||
int currentGroupIndex = i;
|
||||
// Check for the subgroups
|
||||
if (groupValueEntry.value.subGroups != null && groupValueEntry.value.subGroups!.isNotEmpty) {
|
||||
// There are subgroups.
|
||||
_initializeFormGroups(groupValueEntry.value.subGroups!, parents: <String, int>{...?parents, groupID: currentGroupIndex});
|
||||
}
|
||||
|
||||
String? prefix = parents?.entries.map((MapEntry<String, int> parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-');
|
||||
log('Got the prefix, it\'s $prefix');
|
||||
|
||||
// Fields
|
||||
if (groupValue.fieldValues != null) {
|
||||
for (MapEntry<String, List<String?>> fve in groupValue.fieldValues!.entries) {
|
||||
assert(fve.value.length == groupValue.instancesCount, 'Your supplied list of `${fve.key}` is not `${groupValue.instancesCount}` as stated..');
|
||||
if (groupValue.fieldObscurityValues != null) {
|
||||
assert(groupValue.fieldObscurityValues!.length == groupValue.instancesCount, 'Your supplied obscurity list of `${fve.key}` is not `${groupValue.instancesCount}` as stated..');
|
||||
}
|
||||
String fieldKey = fve.key;
|
||||
String fID = standeredGroupFormat((prefix != null ? '$prefix-' : '') + groupID, i.toString(), fieldKey);
|
||||
String? fieldValue = fve.value[i] ?? '';
|
||||
bool obscValue = groupValue.fieldObscurityValues?[fieldKey]?[i] ?? false;
|
||||
log('Doing the controller $fID with initialText $fieldValue');
|
||||
controller(fID, initialText: fieldValue, isObscure: obscValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Values
|
||||
if (groupValue.hostedValues != null) {
|
||||
for (MapEntry<String, List<(dynamic, bool)>> hve in groupValue.hostedValues!.entries) {
|
||||
assert(hve.value.length == groupValue.instancesCount, 'Your supplied list of `${hve.key}` is not `${groupValue.instancesCount}` as stated..');
|
||||
String fieldKey = hve.key;
|
||||
String fID = standeredGroupFormat((prefix != null ? '$prefix-' : '') + groupID, i.toString(), fieldKey);
|
||||
dynamic fieldValue = hve.value[i].$1;
|
||||
bool isReq = hve.value[i].$2;
|
||||
log('Doing the controller $fID with initial Value $fieldValue');
|
||||
|
||||
setValue(fID, fieldValue, isRequired: isReq);
|
||||
}
|
||||
}
|
||||
for (var subGroupId in def.subGroups) {
|
||||
row[subGroupId] = getGroupData(subGroupId, parentUuid: uuid);
|
||||
}
|
||||
}
|
||||
|
||||
return row;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Retrieves the schema definition for a registered group.
|
||||
AstromicFormGroupDefinition? getGroupDef(String id) => _groupDefs[id];
|
||||
|
||||
/// Disposes of all internal nodes and associated UI controllers.
|
||||
void dispose() {
|
||||
for (var n in _nodes.values) {
|
||||
// ignore: unnecessary_type_check
|
||||
if (n is AstromicFieldNode) n.disposeUI();
|
||||
n.dispose();
|
||||
}
|
||||
_errorStreamController.close();
|
||||
}
|
||||
//!SECTION
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
//s1 Imports
|
||||
//s2 Core Package Imports
|
||||
import 'package:flutter/material.dart';
|
||||
//s2 1st-party Package Imports
|
||||
//s2 3rd-party Package Imports
|
||||
//s2 Dependancies Imports
|
||||
//s3 Routes
|
||||
//s3 Services
|
||||
//s3 Models & Widgets
|
||||
//s1 Exports
|
||||
|
||||
class AstromicForm extends StatefulWidget {
|
||||
//SECTION - Widget Arguments
|
||||
//!SECTION
|
||||
//
|
||||
const AstromicForm({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AstromicForm> createState() => _AstromicFormState();
|
||||
}
|
||||
|
||||
class _AstromicFormState extends State<AstromicForm> {
|
||||
//
|
||||
//SECTION - State Variables
|
||||
//s1 --State
|
||||
//s1 --State
|
||||
//
|
||||
//s1 --Controllers
|
||||
//late AstromicFormController _formController;
|
||||
//s1 --Controllers
|
||||
//
|
||||
//s1 --Constants
|
||||
//s1 --Constants
|
||||
//!SECTION
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
//
|
||||
//SECTION - State Variables initializations & Listeners
|
||||
//s1 --State
|
||||
//s1 --State
|
||||
//
|
||||
//s1 --Controllers & Listeners
|
||||
// _formController = AstromicFormController();
|
||||
//s1 --Controllers & Listeners
|
||||
//
|
||||
//s1 --Late & Async Initializers
|
||||
//s1 --Late & Async Initializers
|
||||
//!SECTION
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
//
|
||||
//SECTION - State Variables initializations & Listeners
|
||||
//s1 --State
|
||||
//s1 --State
|
||||
//
|
||||
//s1 --Controllers & Listeners
|
||||
//s1 --Controllers & Listeners
|
||||
//
|
||||
//!SECTION
|
||||
}
|
||||
|
||||
//SECTION - Dumb Widgets
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Stateless functions
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Action Callbacks
|
||||
//!SECTION
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//SECTION - Build Setup
|
||||
//s1 --Values
|
||||
//double w = MediaQuery.of(context).size.width;
|
||||
//double h = MediaQuery.of(context).size.height;
|
||||
//s1 --Values
|
||||
//
|
||||
//s1 --Contexted Widgets
|
||||
//s1 --Contexted Widgets
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Build Return
|
||||
return Scaffold(
|
||||
body: Container(),
|
||||
);
|
||||
//!SECTION
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
//SECTION - Disposable variables
|
||||
//!SECTION
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
//s1 Imports
|
||||
//s2 Core Package Imports
|
||||
import 'package:flutter/widgets.dart';
|
||||
//s2 1st-party Package Imports
|
||||
import 'package:astromic_elements/astromic_elements.dart';
|
||||
import 'package:form_controller/form_controller.dart';
|
||||
//s2 3rd-party Package Imports
|
||||
//s2 Dependancies Imports
|
||||
//s3 Routes
|
||||
//s3 Services
|
||||
//s3 Models & Widgets
|
||||
import 'controller.dart';
|
||||
import 'enums/enums.exports.dart';
|
||||
//s1 Exports
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class AstromicFormField<T> extends StatefulWidget {
|
||||
//SECTION - Widget Arguments
|
||||
//S1 -- Shared
|
||||
final AstromicFormController formController;
|
||||
final String formID;
|
||||
final bool? initialObscurity;
|
||||
//
|
||||
final AstromicFieldConfiguration? configuration;
|
||||
final List<FormControllerValidator>? validators;
|
||||
final bool? resetMessageOnChange;
|
||||
final Map<String, String?>? errorsCatcher;
|
||||
//
|
||||
final AstromicFieldStyle Function(bool isEnabled, bool isFocused, AstromicFieldState state)? style;
|
||||
//
|
||||
final String? hint;
|
||||
final Widget? Function(bool isEnabled, bool isFocused, VoidCallback stateSetter, AstromicFieldState state)? prefixWidget;
|
||||
final Widget? Function(bool isEnabled, bool isFocused, VoidCallback stateSetter, AstromicFieldState state)? suffixWidget;
|
||||
final Widget? Function(bool isEnabled, bool isFocused, AstromicFieldState state, String? message)? messageBuilder;
|
||||
//S1 -- Text Specific
|
||||
String? initialText;
|
||||
void Function(String v, AstromicFieldState state)? onChanged;
|
||||
void Function(String v, AstromicFieldState state)? onSubmited;
|
||||
Iterable<ContextMenuButtonItem>? contextButtons;
|
||||
//S1 -- Action Specific
|
||||
(T item, String label)? initialValue;
|
||||
Future<(T item, String label)?> Function((T item, String label)? currentValue)? onTap;
|
||||
Future<(T item, String label)?> Function((T item, String label)? currentValue)? onHold;
|
||||
String Function(String? oldValue, String newValue)? onValueChangedMapper;
|
||||
//!SECTION
|
||||
|
||||
AstromicFormField.text({
|
||||
required this.formController,
|
||||
required this.formID,
|
||||
this.initialObscurity,
|
||||
this.configuration,
|
||||
this.validators,
|
||||
this.resetMessageOnChange,
|
||||
this.errorsCatcher,
|
||||
this.style,
|
||||
this.hint,
|
||||
this.prefixWidget,
|
||||
this.suffixWidget,
|
||||
this.messageBuilder,
|
||||
//
|
||||
this.initialText,
|
||||
this.onChanged,
|
||||
this.onSubmited,
|
||||
this.contextButtons,
|
||||
});
|
||||
|
||||
AstromicFormField.action({
|
||||
required this.formController,
|
||||
required this.formID,
|
||||
this.initialObscurity,
|
||||
this.configuration,
|
||||
this.validators,
|
||||
this.resetMessageOnChange,
|
||||
this.errorsCatcher,
|
||||
this.style,
|
||||
this.hint,
|
||||
this.prefixWidget,
|
||||
this.suffixWidget,
|
||||
this.messageBuilder,
|
||||
//
|
||||
this.initialValue,
|
||||
this.onTap,
|
||||
this.onHold,
|
||||
this.onValueChangedMapper,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AstromicFormField<T>> createState() => _AstromicFormFieldState<T>();
|
||||
}
|
||||
|
||||
class _AstromicFormFieldState<T> extends State<AstromicFormField<T>> {
|
||||
//
|
||||
//SECTION - State Variables
|
||||
//s1 --State
|
||||
late AstromicFieldState _currentState;
|
||||
//s1 --State
|
||||
//
|
||||
//s1 --Controllers
|
||||
late TextEditingController _controller;
|
||||
//s1 --Controllers
|
||||
//
|
||||
//s1 --Constants
|
||||
//s1 --Constants
|
||||
//!SECTION
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
//
|
||||
//SECTION - State Variables initializations & Listeners
|
||||
//s1 --State
|
||||
_currentState = AstromicFieldState.idle;
|
||||
//s1 --State
|
||||
//
|
||||
//s1 --Controllers & Listeners
|
||||
// Set the textEditingController for this field
|
||||
_controller = widget.formController.controller(widget.formID, initialText: widget.initialText, isObscure: widget.initialObscurity ?? false);
|
||||
|
||||
// Listen to the state stream for updated form states...
|
||||
widget.formController.stateStream.listen(((String, AstromicFieldState) newState) {
|
||||
if (mounted && widget.formID == newState.$1) {
|
||||
setState(() {
|
||||
_currentState = newState.$2;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to the error stream for updated errors and set the field's state accordingly...
|
||||
if (widget.formController.errorStream != null) {
|
||||
widget.formController.errorStream!.listen((List<(String internalCode, String? message)> errorCodes) {
|
||||
if (widget.errorsCatcher != null) {
|
||||
// fieldId: {errorCode: errorMessage}
|
||||
for (String toBeCaughtInternalErrorCode in widget.errorsCatcher!.keys.toList()) {
|
||||
if (errorCodes.map(((String, String?) x) => x.$1).contains(toBeCaughtInternalErrorCode)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_setFieldErrorState(widget.formID,
|
||||
errorCodes.where(((String, String?) c) => c.$1 == toBeCaughtInternalErrorCode).first.$2 ?? widget.errorsCatcher![toBeCaughtInternalErrorCode] ?? 'Undefined Error Message');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//
|
||||
});
|
||||
}
|
||||
//s1 --Controllers & Listeners
|
||||
//
|
||||
//s1 --Late & Async Initializers
|
||||
//s1 --Late & Async Initializers
|
||||
//!SECTION
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
//
|
||||
//SECTION - State Variables initializations & Listeners
|
||||
//s1 --State
|
||||
//s1 --State
|
||||
//
|
||||
//s1 --Controllers & Listeners
|
||||
//s1 --Controllers & Listeners
|
||||
//
|
||||
//!SECTION
|
||||
}
|
||||
|
||||
//SECTION - Dumb Widgets
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Stateless functions
|
||||
_setFieldErrorState(String id, String? message) {
|
||||
widget.formController.setState(id, AstromicFieldState.withError, message: message);
|
||||
}
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Action Callbacks
|
||||
//!SECTION
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//SECTION - Build Setup
|
||||
//s1 --Values
|
||||
//double w = MediaQuery.of(context).size.width;
|
||||
//double h = MediaQuery.of(context).size.height;
|
||||
//s1 --Values
|
||||
//
|
||||
//s1 --Contexted Widgets
|
||||
Widget buildText() {
|
||||
return AstromicFields.text(
|
||||
stateKey: widget.formController.fieldStateKey(widget.formID),
|
||||
controller: _controller,
|
||||
onChanged: (String s) {
|
||||
setState(() {
|
||||
if (widget.resetMessageOnChange ?? false) {
|
||||
widget.formController.resetState(widget.formID);
|
||||
}
|
||||
if (widget.onChanged != null) {
|
||||
widget.onChanged!(s, _currentState);
|
||||
}
|
||||
});
|
||||
},
|
||||
onSubmited: (String s) {
|
||||
if (widget.onSubmited != null) {
|
||||
widget.onSubmited!(s, _currentState);
|
||||
}
|
||||
},
|
||||
configuration: widget.configuration?.copyWith(
|
||||
isTextObscured: widget.formController.isObscure(widget.formID),
|
||||
validator: (widget.validators != null && widget.validators!.isNotEmpty)
|
||||
? (bool enabled, bool focused, String? s) {
|
||||
widget.formController.resetState(widget.formID);
|
||||
//
|
||||
List<FormControllerValidator> validators = <FormControllerValidator>[];
|
||||
//
|
||||
validators.addAll(widget.validators!);
|
||||
//
|
||||
Map<FormControllerValidator, bool> checks = <FormControllerValidator, bool>{};
|
||||
//
|
||||
for (FormControllerValidator validator in validators) {
|
||||
bool res = validator.checker(s);
|
||||
checks.addEntries(<MapEntry<FormControllerValidator, bool>>[MapEntry<FormControllerValidator, bool>(validator, res)]);
|
||||
}
|
||||
//
|
||||
if (checks.containsValue(false)) {
|
||||
// It has an Error!
|
||||
_setFieldErrorState(widget.formID, checks.entries.where((MapEntry<FormControllerValidator, bool> e) => e.value == false).toList().first.key.message);
|
||||
return '';
|
||||
} else {
|
||||
// It has no Errors!
|
||||
return null;
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
style: (bool enabled, bool focused) => widget.style != null ? widget.style!(enabled, focused, _currentState) : const AstromicFieldStyle(),
|
||||
//
|
||||
contextButtons: widget.contextButtons,
|
||||
//
|
||||
hint: widget.hint,
|
||||
prefixWidget: widget.prefixWidget != null ? (bool enabled, bool focused, void Function() setter) => widget.prefixWidget!(enabled, focused, setter, _currentState) : null,
|
||||
suffixWidget: widget.suffixWidget != null ? (bool enabled, bool focused, void Function() setter) => widget.suffixWidget!(enabled, focused, setter, _currentState) : null,
|
||||
messageBuilder:
|
||||
widget.messageBuilder != null ? (bool enabled, bool focused) => widget.messageBuilder!(enabled, focused, _currentState, widget.formController.getState(widget.formID)?.$2) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAction() {
|
||||
return AstromicFields.action(
|
||||
stateKey: widget.formController.fieldStateKey(widget.formID),
|
||||
controller: _controller,
|
||||
initialValue: widget.initialValue,
|
||||
onTap: widget.onTap != null
|
||||
? ((T, String)? currentValue) {
|
||||
if (widget.resetMessageOnChange ?? false) {
|
||||
widget.formController.resetState(widget.formID);
|
||||
}
|
||||
return widget.onTap!(currentValue);
|
||||
}
|
||||
: null,
|
||||
onHold: widget.onHold,
|
||||
onValueChangedMapper: widget.onValueChangedMapper!,
|
||||
//
|
||||
style: (bool enabled) => widget.style != null ? widget.style!(enabled, false, _currentState) : const AstromicFieldStyle(),
|
||||
configuration: widget.configuration?.copyWith(
|
||||
isTextObscured: widget.formController.isObscure(widget.formID),
|
||||
validator: (widget.validators != null && widget.validators!.isNotEmpty)
|
||||
? (bool enabled, bool focused, String? s) {
|
||||
widget.formController.resetState(widget.formID);
|
||||
//
|
||||
List<FormControllerValidator> validators = <FormControllerValidator>[];
|
||||
//
|
||||
validators.addAll(widget.validators!);
|
||||
//
|
||||
Map<FormControllerValidator, bool> checks = <FormControllerValidator, bool>{};
|
||||
//
|
||||
for (FormControllerValidator validator in validators) {
|
||||
bool res = validator.checker(s);
|
||||
checks.addEntries(<MapEntry<FormControllerValidator, bool>>[MapEntry<FormControllerValidator, bool>(validator, res)]);
|
||||
}
|
||||
//
|
||||
if (checks.containsValue(false)) {
|
||||
// It has an Error!
|
||||
_setFieldErrorState(widget.formID, checks.entries.where((MapEntry<FormControllerValidator, bool> e) => e.value == false).toList().first.key.message);
|
||||
return '';
|
||||
} else {
|
||||
// It has no Errors!
|
||||
return null;
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
//
|
||||
hint: widget.hint,
|
||||
prefixWidget: widget.prefixWidget != null ? (bool enabled, void Function() setter) => widget.prefixWidget!(enabled, false, setter, _currentState) : null,
|
||||
suffixWidget: widget.suffixWidget != null ? (bool enabled, void Function() setter) => widget.suffixWidget!(enabled, false, setter, _currentState) : null,
|
||||
messageBuilder: widget.messageBuilder != null ? (bool enabled) => widget.messageBuilder!(enabled, false, _currentState, widget.formController.getState(widget.formID)?.$2) : null,
|
||||
//
|
||||
);
|
||||
}
|
||||
//s1 --Contexted Widgets
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Build Return
|
||||
return widget.onValueChangedMapper != null
|
||||
?
|
||||
// Is Action Field
|
||||
buildAction()
|
||||
:
|
||||
// Is Text Field
|
||||
buildText();
|
||||
//!SECTION
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
//SECTION - Disposable variables
|
||||
// _controller.dispose();
|
||||
//!SECTION
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
//s1 Imports
|
||||
//s2 Packages
|
||||
//s3 Core Packages
|
||||
import 'package:flutter/material.dart';
|
||||
//s3 Internal Packages
|
||||
//s3 3rd-party Packages
|
||||
//s2 Utility
|
||||
//s3 Configs
|
||||
//s3 Misc
|
||||
//s2 Domain
|
||||
//s3 Entities
|
||||
import 'controller.dart';
|
||||
import 'models/models.exports.dart';
|
||||
//s3 Usecases
|
||||
//s1 Exports
|
||||
|
||||
class FormGroupWrapper extends StatefulWidget {
|
||||
//SECTION - Widget Arguments
|
||||
final AstromicFormController formController;
|
||||
final String groupID;
|
||||
final Widget Function(List<Widget> children, String Function() addItem, void Function(int) removeItem) groupBuilder;
|
||||
final Widget Function(int index, String composedID, VoidCallback removeItem) itemBuilder;
|
||||
final Map<String, int>? parents;
|
||||
//!SECTION
|
||||
//
|
||||
const FormGroupWrapper({
|
||||
super.key,
|
||||
required this.formController,
|
||||
required this.groupID,
|
||||
required this.groupBuilder,
|
||||
required this.itemBuilder,
|
||||
this.parents,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FormGroupWrapper> createState() => _FormGroupWrapperState();
|
||||
}
|
||||
|
||||
class _FormGroupWrapperState extends State<FormGroupWrapper> {
|
||||
//
|
||||
//SECTION - State Variables
|
||||
//s1 --State
|
||||
late List<FormGroupInstance> instances;
|
||||
//s1 --State
|
||||
//
|
||||
//s1 --Controllers
|
||||
//s1 --Controllers
|
||||
//
|
||||
//s1 --Constants
|
||||
//s1 --Constants
|
||||
//!SECTION
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
//
|
||||
//SECTION - State Variables initializations & Listeners
|
||||
//s1 --State
|
||||
//s1 --State
|
||||
//
|
||||
//s1 --Controllers & Listeners
|
||||
instances = widget.formController.getFormGroupValue(widget.groupID, parents: widget.parents)!.instances;
|
||||
// LoggingService.log("instances: $instances");
|
||||
//s1 --Controllers & Listeners
|
||||
//
|
||||
//s1 --Late & Async Initializers
|
||||
//s1 --Late & Async Initializers
|
||||
//!SECTION
|
||||
}
|
||||
|
||||
//SECTION - Dumb Widgets
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Stateless functions
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Action Callbacks
|
||||
//!SECTION
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//SECTION - Build Setup
|
||||
//s1 --Values
|
||||
//double w = MediaQuery.of(context).size.width;
|
||||
//double h = MediaQuery.of(context).size.height;
|
||||
//s1 --Values
|
||||
//
|
||||
//s1 --Contexted Widgets
|
||||
//s1 --Contexted Widgets
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Build Return
|
||||
return widget.groupBuilder(
|
||||
// Children
|
||||
List<Widget>.generate(
|
||||
instances.length,
|
||||
(int i) => widget.itemBuilder(i, instances[i].composedID, () {
|
||||
setState(() {
|
||||
_removeItem(i);
|
||||
instances = widget.formController.getFormGroupValue(widget.groupID, parents: widget.parents)!.instances;
|
||||
});
|
||||
})),
|
||||
// Add Callback
|
||||
() {
|
||||
String id = '';
|
||||
setState(() {
|
||||
id = widget.formController.addInstanceToFormGroup(widget.groupID, parents: widget.parents);
|
||||
instances = widget.formController.getFormGroupValue(widget.groupID, parents: widget.parents)!.instances;
|
||||
});
|
||||
return id;
|
||||
},
|
||||
// Remove Callback
|
||||
(int i) {
|
||||
setState(() {
|
||||
_removeItem(i);
|
||||
instances = widget.formController.getFormGroupValue(widget.groupID, parents: widget.parents)!.instances;
|
||||
});
|
||||
},
|
||||
);
|
||||
//!SECTION
|
||||
}
|
||||
|
||||
void _removeItem(int i) {
|
||||
widget.formController.removeInstanceFromGroup(widget.groupID, i, parents: widget.parents);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
//SECTION - Disposable variables
|
||||
//!SECTION
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
//s1 Imports
|
||||
//s2 Packages
|
||||
//s3 Core Packages
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../sheet/sheet_helper.astromic.dart';
|
||||
//s3 Internal Packages
|
||||
//s3 3rd-party Packages
|
||||
//s2 Utility
|
||||
//s3 Configs
|
||||
//s3 Misc
|
||||
//s2 Domain
|
||||
//s3 Entities
|
||||
//s3 Usecases
|
||||
//s2 Presentation
|
||||
//s3 Design
|
||||
//s3 Presenters
|
||||
//s3 Widgets
|
||||
//s1 Exports
|
||||
|
||||
class FormValueWrapper<T extends Object?> extends StatefulWidget {
|
||||
//SECTION - Widget Arguments
|
||||
final AstromicFormController controller;
|
||||
final String id;
|
||||
final bool isRequired;
|
||||
final Widget Function(T? value, bool isErroredForValidation, void Function(T? value) valueSetter, VoidCallback valueClear) builder;
|
||||
//!SECTION
|
||||
//
|
||||
const FormValueWrapper({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.id,
|
||||
required this.isRequired,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FormValueWrapper<T>> createState() => _FormValueWrapperState<T>();
|
||||
}
|
||||
|
||||
class _FormValueWrapperState<T> extends State<FormValueWrapper<T>> {
|
||||
//
|
||||
//SECTION - State Variables
|
||||
//s1 --State
|
||||
//s1 --State
|
||||
//
|
||||
//s1 --Controllers
|
||||
// late AstromicFormController _formController;
|
||||
//s1 --Controllers
|
||||
//
|
||||
//s1 --Constants
|
||||
//s1 --Constants
|
||||
//!SECTION
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
//
|
||||
//SECTION - State Variables initializations & Listeners
|
||||
//s1 --State
|
||||
//s1 --State
|
||||
//
|
||||
//s1 --Controllers & Listeners
|
||||
if (!widget.controller.allValues.keys.contains(widget.id)) {
|
||||
widget.controller.setValue<T>(widget.id, null, isRequired: widget.isRequired);
|
||||
}
|
||||
//s1 --Controllers & Listeners
|
||||
//
|
||||
//s1 --Late & Async Initializers
|
||||
//s1 --Late & Async Initializers
|
||||
//!SECTION
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
//
|
||||
//SECTION - State Variables initializations & Listeners
|
||||
//s1 --State
|
||||
//s1 --State
|
||||
//
|
||||
//s1 --Controllers & Listeners
|
||||
//s1 --Controllers & Listeners
|
||||
//
|
||||
//!SECTION
|
||||
}
|
||||
|
||||
//SECTION - Dumb Widgets
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Stateless functions
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Action Callbacks
|
||||
//!SECTION
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//SECTION - Build Setup
|
||||
//s1 --Values
|
||||
//double w = MediaQuery.of(context).size.width;
|
||||
//double h = MediaQuery.of(context).size.height;
|
||||
//s1 --Values
|
||||
//
|
||||
//s1 --Contexted Widgets
|
||||
//s1 --Contexted Widgets
|
||||
//!SECTION
|
||||
|
||||
//SECTION - Build Return
|
||||
return StreamBuilder<(String, bool)>(
|
||||
stream: widget.controller.hostedValueValidationStream,
|
||||
builder: (BuildContext context, AsyncSnapshot<(String, bool)> validationSnapshot) {
|
||||
return widget.builder(widget.controller.getValue<T>(widget.id),
|
||||
validationSnapshot.hasData && validationSnapshot.data != null && validationSnapshot.data!.$1 == widget.id && validationSnapshot.data!.$2 ? true : false, (T? newValue) {
|
||||
return widget.controller.setValue(widget.id, newValue);
|
||||
}, () => widget.controller.removeValue(widget.id));
|
||||
});
|
||||
//!SECTION
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
//SECTION - Disposable variables
|
||||
//!SECTION
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../controller.dart';
|
||||
import '../models/form_group_definition.dart';
|
||||
|
||||
/// A fluent API handler for managing a specific group instance within the [AstromicFormController].
|
||||
class AstromicGroupHandler {
|
||||
AstromicGroupHandler(this._ctrl, this._groupId, [this._parentUuid]);
|
||||
|
||||
final AstromicFormController _ctrl;
|
||||
final String _groupId;
|
||||
final String? _parentUuid;
|
||||
|
||||
int get count => _ids.value.length;
|
||||
|
||||
ValueNotifier<List<String>> get _ids => _ctrl.groupIds(_groupId, _parentUuid);
|
||||
|
||||
/// Registers a new instance within this group and optionally hydrates it with [initialData].
|
||||
///
|
||||
/// Returns the unique UUID generated for the new instance.
|
||||
String add({Map<String, dynamic>? data}) {
|
||||
return _ctrl.addGroupInstance(_groupId, parentUuid: _parentUuid, initialData: data);
|
||||
}
|
||||
|
||||
/// Permanently removes an instance from the group by its [uuid] and disposes of associated nodes.
|
||||
void remove(String uuid) {
|
||||
_ctrl.removeGroupInstance(_groupId, uuid, parentUuid: _parentUuid);
|
||||
}
|
||||
|
||||
/// Navigates to a nested subgroup belonging to a specific parent instance.
|
||||
AstromicGroupHandler subGroup(String subGroupId, String currentInstanceUuid) {
|
||||
return AstromicGroupHandler(_ctrl, subGroupId, currentInstanceUuid);
|
||||
}
|
||||
|
||||
/// Generates the deterministic unique identifier for a field within a specific group instance.
|
||||
String getFieldId(String instanceUuid, String fieldName) {
|
||||
return '${_groupId}_${fieldName}_$instanceUuid';
|
||||
}
|
||||
|
||||
/// Retrieves the [TextEditingController] associated with a specific field in a group instance.
|
||||
TextEditingController field(String uuid, String fieldName) {
|
||||
return _ctrl.controller('${_groupId}_${fieldName}_$uuid');
|
||||
}
|
||||
|
||||
/// Retrieves the [FieldNode] for a specific field, allowing for reactive type-safe data access.
|
||||
ValueNotifier<T> node<T>(String uuid, String fieldName) {
|
||||
return _ctrl.node<T>('${_groupId}_${fieldName}_$uuid');
|
||||
}
|
||||
|
||||
/// Performs validation on every registered field within all instances of this specific group.
|
||||
///
|
||||
/// Returns `true` if all fields pass their respective validation contracts.
|
||||
bool validate() {
|
||||
final def = _ctrl.groupDefs[_groupId];
|
||||
if (def == null) return true;
|
||||
|
||||
final activeUuids = _ids.value;
|
||||
final List<String> idsToValidate = [];
|
||||
|
||||
for (var uuid in activeUuids) {
|
||||
for (var fieldName in def.schema.keys) {
|
||||
idsToValidate.add('${_groupId}_${fieldName}_$uuid');
|
||||
}
|
||||
}
|
||||
|
||||
return _ctrl.validateOnly(idsToValidate);
|
||||
}
|
||||
}
|
||||
|
||||
/// A reactive widget that builds a dynamic list of items based on a [FormGroupDefinition].
|
||||
class AstromicGroupBuilder extends StatelessWidget {
|
||||
const AstromicGroupBuilder({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.builder,
|
||||
this.group,
|
||||
this.definition,
|
||||
this.parentId,
|
||||
}) : assert(group != null || definition != null, "You must provide either a 'group' ID or a 'definition'.");
|
||||
|
||||
final AstromicFormController controller;
|
||||
final String? group;
|
||||
final AstromicFormGroupDefinition? definition;
|
||||
final String? parentId;
|
||||
final Widget Function(BuildContext context, List<String> items, AstromicGroupHandler handler) builder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (definition != null) {
|
||||
controller.registerGroup(definition!);
|
||||
}
|
||||
|
||||
final String effectiveGroupId = group ?? definition!.id;
|
||||
final handler = AstromicGroupHandler(controller, effectiveGroupId, parentId);
|
||||
|
||||
return ValueListenableBuilder<List<String>>(
|
||||
valueListenable: handler._ids,
|
||||
builder: (context, ids, _) {
|
||||
return builder(context, ids, handler);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensions to provide cleaner syntax for accessing group features from the controller.
|
||||
extension AstromicGroupHandlerExt on AstromicFormController {
|
||||
/// Provides an [AstromicGroupHandler] for the specified group identifier.
|
||||
AstromicGroupHandler group(String id) => AstromicGroupHandler(this, id);
|
||||
|
||||
/// Retrieves the reactive list of instance UUIDs for a given group and parent context.
|
||||
ValueNotifier<List<String>> groupIds(String groupId, String? parentUuid) {
|
||||
return getManifest(groupId, parentUuid);
|
||||
}
|
||||
}
|
||||
|
||||
28
lib/src/form/src/models/field_config.dart
Normal file
28
lib/src/form/src/models/field_config.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'package:form_controller/form_controller.dart' show FormControllerValidator;
|
||||
|
||||
/// Configuration for a single field within a [FormGroupDefinition].
|
||||
///
|
||||
/// Defines the type [T], the initial value, and the logic for parsing, formatting, and validation.
|
||||
class AstromicFieldConfig<T> {
|
||||
AstromicFieldConfig({
|
||||
this.initialValue,
|
||||
this.formatter,
|
||||
this.parser,
|
||||
this.validator,
|
||||
this.validators,
|
||||
});
|
||||
|
||||
final T? initialValue;
|
||||
final String Function(T)? formatter;
|
||||
final T? Function(String)? parser;
|
||||
final FormControllerValidator? validator;
|
||||
final List<FormControllerValidator>? validators;
|
||||
|
||||
/// Captures the specific type [T] and invokes the [creator] callback to register the field.
|
||||
///
|
||||
/// The [key] serves as the field identifier, [value] as the initial data, and [creator]
|
||||
/// as the factory function that handles the underlying node registration.
|
||||
void register(String key, T value, dynamic Function<V>(String, AstromicFieldConfig<V>, V) creator) {
|
||||
creator<T>(key, this, value);
|
||||
}
|
||||
}
|
||||
142
lib/src/form/src/models/field_node.dart
Normal file
142
lib/src/form/src/models/field_node.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:form_controller/form_controller.dart' show FormControllerValidator;
|
||||
import '../enums/enums.exports.dart' show AstromicFieldState;
|
||||
|
||||
/// A reactive data node that holds a single value of type [T] and manages its synchronization with the UI.
|
||||
///
|
||||
/// The [AstromicFieldNode] acts as a bridge between the raw data (e.g., `double`, `DateTime`, `bool`)
|
||||
/// and the string-based input expected by Flutter's [TextEditingController].
|
||||
///
|
||||
/// It extends [ValueNotifier], allowing it to be used with [ValueListenableBuilder] for non-text inputs like
|
||||
/// switches, checkboxes, or custom pickers.
|
||||
class AstromicFieldNode<T> extends ValueNotifier<T> {
|
||||
AstromicFieldNode(
|
||||
super.value, {
|
||||
required this.formatter,
|
||||
required this.parser,
|
||||
this.validators = const <FormControllerValidator>[],
|
||||
});
|
||||
|
||||
/// Transforms the stored value [T] into a [String] for display in the UI.
|
||||
String Function(T) formatter;
|
||||
|
||||
/// Transforms user input [String] back into the stored value [T].
|
||||
T? Function(String) parser;
|
||||
|
||||
/// Internal list of validators used for business logic and UI feedback.
|
||||
List<FormControllerValidator> validators;
|
||||
|
||||
/// Notifies listeners of changes in the field's visual or interaction state (e.g., idle, focused, error).
|
||||
final ValueNotifier<AstromicFieldState> state = ValueNotifier(AstromicFieldState.idle);
|
||||
|
||||
/// Holds the current error message or feedback string to be displayed in the UI.
|
||||
final ValueNotifier<String?> message = ValueNotifier(null);
|
||||
|
||||
TextEditingController? _uiController;
|
||||
|
||||
/// Returns a [TextEditingController] synchronized with this node's value.
|
||||
///
|
||||
/// Updates [value] when the text changes via [parser], and updates the text
|
||||
/// when [value] changes programmatically via [formatter].
|
||||
TextEditingController get syncedController {
|
||||
if (_uiController != null) return _uiController!;
|
||||
|
||||
_uiController = TextEditingController(text: formatter(value));
|
||||
|
||||
_uiController!.addListener(() {
|
||||
final newString = _uiController!.text;
|
||||
final parsedValue = parser(newString);
|
||||
|
||||
if (state.value == AstromicFieldState.withError) {
|
||||
state.value = AstromicFieldState.idle;
|
||||
message.value = null;
|
||||
}
|
||||
|
||||
if (parsedValue != value) {
|
||||
if (parsedValue != null) {
|
||||
value = parsedValue as T;
|
||||
} else if (T == String) {
|
||||
value = newString as T;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
addListener(() {
|
||||
final newString = formatter(value);
|
||||
if (parser(_uiController!.text) != value) {
|
||||
_uiController!.text = newString;
|
||||
_uiController!.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _uiController!.text.length),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return _uiController!;
|
||||
}
|
||||
|
||||
/// Programmatically updates the [formatter] and [parser] logic for this node.
|
||||
void setConfiguration({
|
||||
String Function(T value)? formatter,
|
||||
T? Function(String value)? parser,
|
||||
}) {
|
||||
if (formatter != null) this.formatter = formatter;
|
||||
if (parser != null) this.parser = parser;
|
||||
|
||||
if (_uiController != null) {
|
||||
final newText = this.formatter(value);
|
||||
if (_uiController!.text != newText) {
|
||||
_uiController!.text = newText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the current visual [state] and [message] for the field.
|
||||
void setState(AstromicFieldState newState, {String? newMessage}) {
|
||||
state.value = newState;
|
||||
message.value = newMessage;
|
||||
}
|
||||
|
||||
/// Clears error messages and returns the field state to [AstromicFieldState.idle].
|
||||
void resetState() {
|
||||
state.value = AstromicFieldState.idle;
|
||||
message.value = null;
|
||||
}
|
||||
|
||||
/// Executes all attached [validators] against the current [value].
|
||||
///
|
||||
/// Returns the first error message encountered, or `null` if the value is valid.
|
||||
/// Automatically updates the [state] and [message] listeners.
|
||||
String? validate({List<FormControllerValidator>? extraValidators}) {
|
||||
String? error;
|
||||
|
||||
for (var v in validators) {
|
||||
if (!v.checker(formatter(value))) {
|
||||
error = v.message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (error == null && extraValidators != null) {
|
||||
for (var v in extraValidators) {
|
||||
if (!v.checker(formatter(value))) {
|
||||
error = v.message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
setState(AstromicFieldState.withError, newMessage: error);
|
||||
} else {
|
||||
resetState();
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
/// Disposes the associated [TextEditingController] to free up memory resources.
|
||||
void disposeUI() {
|
||||
_uiController?.dispose();
|
||||
_uiController = null;
|
||||
}
|
||||
}
|
||||
26
lib/src/form/src/models/form_group_definition.dart
Normal file
26
lib/src/form/src/models/form_group_definition.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'field_config.dart';
|
||||
|
||||
/// Defines the structure of a dynamic group (e.g., "Projects", "Experience").
|
||||
///
|
||||
/// Contains the [schema] for its fields and a list of nested [subGroups].
|
||||
class AstromicFormGroupDefinition {
|
||||
/// Creates a definition for a form group.
|
||||
AstromicFormGroupDefinition({
|
||||
required this.id,
|
||||
required this.schema,
|
||||
this.subGroups = const [],
|
||||
});
|
||||
|
||||
/// The unique identifier for this group type (e.g., 'projects').
|
||||
final String id;
|
||||
|
||||
/// A map defining the fields in this group.
|
||||
///
|
||||
/// Key: Field name (e.g., 'title').
|
||||
/// Value: Configuration defining the type and behavior.
|
||||
// ignore: strict_raw_type
|
||||
final Map<String, AstromicFieldConfig> schema;
|
||||
|
||||
/// A list of identifiers for nested subgroups that belong to this group.
|
||||
final List<String> subGroups;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'form_group_value.model.dart';
|
||||
|
||||
class FormGroupInstance {
|
||||
String composedID;
|
||||
Map<String, String> fields;
|
||||
Map<String, dynamic> values;
|
||||
List<FormGroupValue>? subGroups;
|
||||
FormGroupInstance({
|
||||
required this.composedID,
|
||||
required this.fields,
|
||||
required this.values,
|
||||
this.subGroups,
|
||||
});
|
||||
|
||||
FormGroupInstance copyWith({
|
||||
String? composedID,
|
||||
Map<String, String>? fields,
|
||||
Map<String, dynamic>? values,
|
||||
List<FormGroupValue>? subGroups,
|
||||
}) {
|
||||
return FormGroupInstance(
|
||||
composedID: composedID ?? this.composedID,
|
||||
fields: fields ?? this.fields,
|
||||
values: values ?? this.values,
|
||||
subGroups: subGroups ?? this.subGroups,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'composedID': composedID,
|
||||
'fields': fields,
|
||||
'values': values,
|
||||
'subGroups': subGroups?.map((FormGroupValue x) => x.toMap()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
factory FormGroupInstance.fromMap(Map<String, dynamic> map) {
|
||||
return FormGroupInstance(
|
||||
composedID: map['composedID'],
|
||||
fields: Map<String, String>.from(map['fields'] as Map<String, String>),
|
||||
values: Map<String, dynamic>.from(map['values'] as Map<String, dynamic>),
|
||||
subGroups: map['subGroups'] != null
|
||||
? List<FormGroupValue>.from(
|
||||
(map['subGroups'] as List<int>).map<FormGroupValue?>(
|
||||
(int x) => FormGroupValue.fromMap(x as Map<String, dynamic>),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory FormGroupInstance.fromJson(String source) => FormGroupInstance.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() => 'FormGroupInstance(composedID: $composedID, fields: $fields, values: $values, subGroups: $subGroups)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant FormGroupInstance other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return mapEquals(other.fields, fields) && mapEquals(other.values, values) && listEquals(other.subGroups, subGroups) && composedID == other.composedID;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => composedID.hashCode ^ fields.hashCode ^ values.hashCode ^ subGroups.hashCode;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class FormGroupStructure {
|
||||
final String id;
|
||||
final List<String> fields;
|
||||
final List<String>? values;
|
||||
final List<(int initialCount, FormGroupStructure structure)>? subGroups;
|
||||
FormGroupStructure({
|
||||
required this.id,
|
||||
required this.fields,
|
||||
this.values,
|
||||
this.subGroups,
|
||||
});
|
||||
|
||||
FormGroupStructure copyWith({
|
||||
String? id,
|
||||
List<String>? fields,
|
||||
Map<String, String>? preFields,
|
||||
List<String>? values,
|
||||
Map<String, (String, dynamic)>? preValues,
|
||||
List<(int initialCount, FormGroupStructure structure)>? subGroups,
|
||||
}) {
|
||||
return FormGroupStructure(
|
||||
id: id ?? this.id,
|
||||
fields: fields ?? this.fields,
|
||||
values: values ?? this.values,
|
||||
subGroups: subGroups ?? this.subGroups,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'fields': fields,
|
||||
'values': values,
|
||||
'subGroups': subGroups?.map(((int initialCount, FormGroupStructure structure) x) => <String, dynamic>{'structure': x.$2.toMap(), 'initialCount': x.$1}).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
factory FormGroupStructure.fromMap(Map<String, dynamic> map) {
|
||||
return FormGroupStructure(
|
||||
id: map['id'] as String,
|
||||
fields: List<String>.from(map['fields'] as List<String>),
|
||||
values: map['values'] != null ? List<String>.from(map['values'] as List<String>) : null,
|
||||
subGroups: map['subGroups'] != null
|
||||
? (map['subGroups'] as List<Map<String, dynamic>>).map((Map<String, dynamic> map) => (int.tryParse(map['initialCount']) ?? 0, FormGroupStructure.fromMap(map['structure']))).toList()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory FormGroupStructure.fromJson(String source) => FormGroupStructure.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'FormGroupStructure(id: $id, fields: $fields, values: $values, subGroups: $subGroups)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant FormGroupStructure other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.id == id && listEquals(other.fields, fields) && listEquals(other.values, values) && listEquals(other.subGroups, subGroups);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^ fields.hashCode ^ values.hashCode ^ subGroups.hashCode;
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'form_group_instance.model.dart';
|
||||
|
||||
class FormGroupValue {
|
||||
final String groupID;
|
||||
final int instancesCount;
|
||||
final List<FormGroupInstance> instances;
|
||||
FormGroupValue({
|
||||
required this.groupID,
|
||||
required this.instancesCount,
|
||||
required this.instances,
|
||||
});
|
||||
|
||||
FormGroupValue copyWith({
|
||||
String? groupID,
|
||||
int? instancesCount,
|
||||
List<FormGroupInstance>? instances,
|
||||
}) {
|
||||
return FormGroupValue(
|
||||
groupID: groupID ?? this.groupID,
|
||||
instancesCount: instancesCount ?? this.instancesCount,
|
||||
instances: instances ?? this.instances,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'groupID': groupID,
|
||||
'instancesCount': instancesCount,
|
||||
'instances': instances.map((FormGroupInstance x) => x.toMap()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
factory FormGroupValue.fromMap(Map<String, dynamic> map) {
|
||||
return FormGroupValue(
|
||||
groupID: map['groupID'] as String,
|
||||
instancesCount: map['instancesCount'] as int,
|
||||
instances: List<FormGroupInstance>.from((map['instances'] as List<int>).map<FormGroupInstance>((int x) => FormGroupInstance.fromMap(x as Map<String,dynamic>),),),
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory FormGroupValue.fromJson(String source) => FormGroupValue.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() => 'FromGroupValue(groupID: $groupID, instancesCount: $instancesCount, instances: $instances)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant FormGroupValue other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return
|
||||
other.groupID == groupID &&
|
||||
other.instancesCount == instancesCount &&
|
||||
listEquals(other.instances, instances);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => groupID.hashCode ^ instancesCount.hashCode ^ instances.hashCode;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class InitialFormGroupValue {
|
||||
final int instancesCount;
|
||||
final Map<String, List<String?>>? fieldValues;
|
||||
final Map<String, List<bool>>? fieldObscurityValues;
|
||||
final Map<String, List<(dynamic, bool)>>? hostedValues;
|
||||
final Map<String, InitialFormGroupValue>? subGroups;
|
||||
InitialFormGroupValue({
|
||||
required this.instancesCount,
|
||||
this.fieldValues,
|
||||
this.fieldObscurityValues,
|
||||
this.hostedValues,
|
||||
this.subGroups,
|
||||
});
|
||||
|
||||
InitialFormGroupValue copyWith({
|
||||
int? instancesCount,
|
||||
Map<String, List<String?>>? fieldValues,
|
||||
Map<String, List<bool>>? fieldObscurityValues,
|
||||
Map<String, List<(dynamic, bool)>>? hostedValues,
|
||||
Map<String, InitialFormGroupValue>? subGroups,
|
||||
}) {
|
||||
return InitialFormGroupValue(
|
||||
instancesCount: instancesCount ?? this.instancesCount,
|
||||
fieldValues: fieldValues ?? this.fieldValues,
|
||||
fieldObscurityValues: fieldObscurityValues ?? this.fieldObscurityValues,
|
||||
hostedValues: hostedValues ?? this.hostedValues,
|
||||
subGroups: subGroups ?? this.subGroups,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'instancesCount': instancesCount,
|
||||
'fieldValues': fieldValues,
|
||||
'fieldObscurityValues': fieldObscurityValues,
|
||||
'hostedValues': hostedValues,
|
||||
'subGroups': subGroups,
|
||||
};
|
||||
}
|
||||
|
||||
factory InitialFormGroupValue.fromMap(Map<String, dynamic> map) {
|
||||
return InitialFormGroupValue(
|
||||
instancesCount: map['instancesCount'] as int,
|
||||
fieldValues: map['fieldValues'] != null ? Map<String, List<String?>>.from(map['fieldValues'] as Map<String, List<String?>>) : null,
|
||||
fieldObscurityValues: map['fieldObscurityValues'] != null ? Map<String, List<bool>>.from(map['fieldObscurityValues'] as Map<String, List<bool>>) : null,
|
||||
hostedValues: map['hostedValues'] != null ? Map<String, List<(dynamic, bool)>>.from(map['hostedValues'] as Map<String, List<(dynamic, bool)>>) : null,
|
||||
subGroups: map['subGroups'] != null ? Map<String, InitialFormGroupValue>.from(map['subGroups'] as Map<String, InitialFormGroupValue>) : null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory InitialFormGroupValue.fromJson(String source) => InitialFormGroupValue.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'InitialFormGroupValue(instancesCount: $instancesCount, fieldValues: $fieldValues, fieldObscurityValues: $fieldObscurityValues, hostedValues: $hostedValues, subGroups: $subGroups)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant InitialFormGroupValue other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.instancesCount == instancesCount &&
|
||||
mapEquals(other.fieldValues, fieldValues) &&
|
||||
mapEquals(other.fieldObscurityValues, fieldObscurityValues) &&
|
||||
mapEquals(other.hostedValues, hostedValues) &&
|
||||
mapEquals(other.subGroups, subGroups);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return instancesCount.hashCode ^ fieldValues.hashCode ^ fieldObscurityValues.hashCode ^ hostedValues.hashCode ^ subGroups.hashCode;
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'initial_form_group_values.model.dart';
|
||||
|
||||
class AstromicFormInitialValues {
|
||||
// Field Values
|
||||
final Map<String, String?>? fieldValues;
|
||||
final Map<String, bool>? fieldObscurityValues;
|
||||
// Hosted Values
|
||||
final Map<String, (dynamic, bool)>? hostedValues;
|
||||
// Form Groups
|
||||
final Map<String, InitialFormGroupValue>? groupValues;
|
||||
AstromicFormInitialValues({
|
||||
this.fieldValues,
|
||||
this.fieldObscurityValues,
|
||||
this.hostedValues,
|
||||
this.groupValues,
|
||||
});
|
||||
|
||||
AstromicFormInitialValues copyWith({
|
||||
Map<String, String?>? fieldValues,
|
||||
Map<String, bool>? fieldObscurityValues,
|
||||
Map<String, (dynamic, bool)>? hostedValues,
|
||||
Map<String, InitialFormGroupValue>? groupValues,
|
||||
}) {
|
||||
return AstromicFormInitialValues(
|
||||
fieldValues: fieldValues ?? this.fieldValues,
|
||||
fieldObscurityValues: fieldObscurityValues ?? this.fieldObscurityValues,
|
||||
hostedValues: hostedValues ?? this.hostedValues,
|
||||
groupValues: groupValues ?? this.groupValues,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'fieldValues': fieldValues,
|
||||
'fieldObscurityValues': fieldObscurityValues,
|
||||
'hostedValues': hostedValues,
|
||||
'groupValues': groupValues,
|
||||
};
|
||||
}
|
||||
|
||||
factory AstromicFormInitialValues.fromMap(Map<String, dynamic> map) {
|
||||
return AstromicFormInitialValues(
|
||||
fieldValues: map['fieldValues'] != null ? Map<String, String?>.from(map['fieldValues'] as Map<String, String?>) : null,
|
||||
fieldObscurityValues: map['fieldObscurityValues'] != null ? Map<String, bool>.from(map['fieldObscurityValues'] as Map<String, bool>) : null,
|
||||
hostedValues: map['hostedValues'] != null ? Map<String, (dynamic, bool)>.from(map['hostedValues'] as Map<String, dynamic>) : null,
|
||||
groupValues: map['groupValues'] != null ? Map<String, InitialFormGroupValue>.from(map['groupValues'] as Map<String, InitialFormGroupValue>) : null,
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory AstromicFormInitialValues.fromJson(String source) => AstromicFormInitialValues.fromMap(json.decode(source) as Map<String, dynamic>);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AstromicFormInitialValues(fieldValues: $fieldValues, fieldObscurityValues: $fieldObscurityValues, hostedValues: $hostedValues, groupValues: $groupValues)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant AstromicFormInitialValues other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return mapEquals(other.fieldValues, fieldValues) &&
|
||||
mapEquals(other.fieldObscurityValues, fieldObscurityValues) &&
|
||||
mapEquals(other.hostedValues, hostedValues) &&
|
||||
mapEquals(other.groupValues, groupValues);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return fieldValues.hashCode ^ fieldObscurityValues.hashCode ^ hostedValues.hashCode ^ groupValues.hashCode;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
export './form_group_structure.model.dart';
|
||||
export './form_group_instance.model.dart';
|
||||
export './form_group_value.model.dart';
|
||||
export './initial_form_group_values.model.dart';
|
||||
export './initial_values.model.dart';
|
||||
export './field_config.dart';
|
||||
export './field_node.dart';
|
||||
export './form_group_definition.dart';
|
||||
|
||||
87
lib/src/form/src/widgets/consumers.dart
Normal file
87
lib/src/form/src/widgets/consumers.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../controller.dart';
|
||||
|
||||
/// A reactive consumer widget that listens to a specific field node by its [id].
|
||||
///
|
||||
/// It provides the current value of type [T] to its [builder]. This implementation
|
||||
/// uses dynamic node retrieval to ensure stability across Flutter Web and mobile.
|
||||
class AstromicFieldConsumer<T> extends StatelessWidget {
|
||||
const AstromicFieldConsumer({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.id,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
final AstromicFormController controller;
|
||||
final String id;
|
||||
final Widget Function(BuildContext context, T value) builder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Access the node as a dynamic ValueListenable to prevent type-casting issues during retrieval.
|
||||
final ValueListenable<dynamic> node = controller.node<dynamic>(id);
|
||||
|
||||
return ValueListenableBuilder<dynamic>(
|
||||
valueListenable: node,
|
||||
builder: (context, value, _) {
|
||||
// Safely cast the specific value to T within the builder scope.
|
||||
return builder(context, value as T);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A reactive consumer that listens to an entire dynamic group and its nested fields.
|
||||
///
|
||||
/// Rebuilds whenever the group manifest changes (items added/removed) or when
|
||||
/// any individual field within the group instances is modified (live typing).
|
||||
class AstromicGroupConsumer extends StatelessWidget {
|
||||
const AstromicGroupConsumer({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.groupId,
|
||||
this.parentUuid,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
final AstromicFormController controller;
|
||||
final String groupId;
|
||||
final String? parentUuid;
|
||||
final Widget Function(BuildContext context, List<Map<String, dynamic>> data) builder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Retrieve the manifest holding the current list of instance UUIDs.
|
||||
final manifest = controller.getManifest(groupId, parentUuid);
|
||||
|
||||
return ValueListenableBuilder<List<String>>(
|
||||
valueListenable: manifest,
|
||||
builder: (context, uuids, _) {
|
||||
// Aggregate the manifest and all field nodes into a single listener list.
|
||||
final List<Listenable> listeners = [manifest];
|
||||
|
||||
final def = controller.getGroupDef(groupId);
|
||||
if (def != null) {
|
||||
for (var uuid in uuids) {
|
||||
for (var fieldName in def.schema.keys) {
|
||||
final fieldId = '${groupId}_${fieldName}_$uuid';
|
||||
listeners.add(controller.node<dynamic>(fieldId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge listeners so that any change in the group structure or content triggers a rebuild.
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge(listeners),
|
||||
builder: (context, _) {
|
||||
final data = controller.getGroupData(groupId, parentUuid: parentUuid);
|
||||
return builder(context, data);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
155
lib/src/form/src/widgets/custom_form_field.dart
Normal file
155
lib/src/form/src/widgets/custom_form_field.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'package:astromic_elements/astromic_elements.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:form_controller/form_controller.dart';
|
||||
|
||||
import '../controller.dart';
|
||||
import '../enums/enums.exports.dart' show AstromicFieldState;
|
||||
import '../models/models.exports.dart' show AstromicFieldNode;
|
||||
|
||||
/// A custom form field that manages complex data types [T] using a dual-node architecture.
|
||||
///
|
||||
/// It maintains a data node for the raw value of type [T] and a shadow label node
|
||||
/// for the string representation used by the UI.
|
||||
class AstromicCustomField<T> extends StatefulWidget {
|
||||
const AstromicCustomField({
|
||||
super.key,
|
||||
required this.formController,
|
||||
required this.formID,
|
||||
required this.initialValue,
|
||||
required this.labeler,
|
||||
this.configuration,
|
||||
this.validators,
|
||||
this.resetMessageOnChange,
|
||||
this.errorsCatcher,
|
||||
this.style,
|
||||
this.hint,
|
||||
this.prefixWidget,
|
||||
this.suffixWidget,
|
||||
this.messageBuilder,
|
||||
this.onTap,
|
||||
this.onHold,
|
||||
this.onValueChangedMapper,
|
||||
});
|
||||
|
||||
final AstromicFormController formController;
|
||||
final String formID;
|
||||
|
||||
final T initialValue;
|
||||
final String Function(T value) labeler;
|
||||
|
||||
final AstromicFieldConfiguration? configuration;
|
||||
final List<FormControllerValidator>? validators;
|
||||
final bool? resetMessageOnChange;
|
||||
final Map<String, String?>? errorsCatcher;
|
||||
final AstromicFieldStyle Function(bool isEnabled, bool isFocused, AstromicFieldState state)? style;
|
||||
|
||||
final String? hint;
|
||||
final Widget? Function(bool isEnabled, bool isFocused, VoidCallback stateSetter, AstromicFieldState state)? prefixWidget;
|
||||
final Widget? Function(bool isEnabled, bool isFocused, VoidCallback stateSetter, AstromicFieldState state)? suffixWidget;
|
||||
final Widget? Function(bool isEnabled, bool isFocused, AstromicFieldState state, String? message)? messageBuilder;
|
||||
|
||||
final Future<T?> Function(T currentValue)? onTap;
|
||||
final Future<T?> Function(T currentValue)? onHold;
|
||||
final String Function(String? oldValue, String newValue)? onValueChangedMapper;
|
||||
|
||||
@override
|
||||
State<AstromicCustomField<T>> createState() => _AstromicCustomFieldState<T>();
|
||||
}
|
||||
|
||||
class _AstromicCustomFieldState<T> extends State<AstromicCustomField<T>> {
|
||||
late AstromicFieldNode<T> _dataNode;
|
||||
late AstromicFieldNode<String> _labelNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_dataNode = widget.formController.node<T>(widget.formID, initialValue: widget.initialValue);
|
||||
|
||||
if (widget.validators != null) {
|
||||
_dataNode.validators = widget.validators!;
|
||||
}
|
||||
|
||||
final initialLabel = widget.labeler(widget.initialValue);
|
||||
_labelNode = widget.formController.node<String>('${widget.formID}_label', initialValue: initialLabel);
|
||||
|
||||
_dataNode.addListener(_syncDataToLabel);
|
||||
widget.formController.errorStream.listen(_handleApiErrors);
|
||||
}
|
||||
|
||||
/// Synchronizes the string display node whenever the raw data value changes.
|
||||
void _syncDataToLabel() {
|
||||
final newLabel = widget.labeler(_dataNode.value);
|
||||
if (_labelNode.value != newLabel) {
|
||||
_labelNode.value = newLabel;
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps global error codes to the local node state for API-driven validation feedback.
|
||||
void _handleApiErrors(List<(String code, String? message)> errors) {
|
||||
if (widget.errorsCatcher == null) return;
|
||||
for (var entry in widget.errorsCatcher!.entries) {
|
||||
final codeToCatch = entry.key;
|
||||
final matchingError = errors.where((e) => e.$1 == codeToCatch).firstOrNull;
|
||||
|
||||
if (matchingError != null && mounted) {
|
||||
_dataNode.setState(
|
||||
AstromicFieldState.withError,
|
||||
newMessage: matchingError.$2 ?? entry.value ?? 'Unknown Error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dataNode.removeListener(_syncDataToLabel);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<AstromicFieldState>(
|
||||
valueListenable: _dataNode.state,
|
||||
builder: (context, currentState, _) {
|
||||
return ValueListenableBuilder<String?>(
|
||||
valueListenable: _dataNode.message,
|
||||
builder: (context, errorMsg, _) {
|
||||
return AstromicFields.action<T>(
|
||||
stateKey: ValueKey('${widget.formID}_custom'),
|
||||
controller: _labelNode.syncedController,
|
||||
initialValue: (_dataNode.value, _labelNode.value),
|
||||
onTap: widget.onTap == null
|
||||
? null
|
||||
: (_) async {
|
||||
if (widget.resetMessageOnChange ?? false) _dataNode.resetState();
|
||||
|
||||
final picked = await widget.onTap!(_dataNode.value);
|
||||
if (picked != null) {
|
||||
_dataNode.value = picked;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onHold: widget.onHold == null
|
||||
? null
|
||||
: (_) async {
|
||||
final held = await widget.onHold!(_dataNode.value);
|
||||
if (held != null) _dataNode.value = held;
|
||||
return null;
|
||||
},
|
||||
onValueChangedMapper: widget.onValueChangedMapper ?? (old, _) => old!,
|
||||
configuration: widget.configuration?.copyWith(
|
||||
validator: (e, f, v) => _dataNode.validate() ?? widget.validators?.where((v) => !v.checker(_labelNode.value)).firstOrNull?.message,
|
||||
),
|
||||
style: (enabled) => widget.style?.call(enabled, false, currentState) ?? const AstromicFieldStyle(),
|
||||
hint: widget.hint,
|
||||
prefixWidget: widget.prefixWidget != null ? (enabled, setter) => widget.prefixWidget!(enabled, false, setter, currentState) : null,
|
||||
suffixWidget: widget.suffixWidget != null ? (enabled, setter) => widget.suffixWidget!(enabled, false, setter, currentState) : null,
|
||||
messageBuilder: widget.messageBuilder != null ? (enabled) => widget.messageBuilder!(enabled, false, currentState, errorMsg) : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
156
lib/src/form/src/widgets/string_form_field.dart
Normal file
156
lib/src/form/src/widgets/string_form_field.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'package:astromic_elements/astromic_elements.dart' show AstromicFieldConfiguration, AstromicFieldStyle, AstromicFields;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:form_controller/form_controller.dart' show FormControllerValidator;
|
||||
|
||||
import '../controller.dart';
|
||||
import '../enums/enums.exports.dart' show AstromicFieldState;
|
||||
import '../models/field_node.dart';
|
||||
|
||||
/// A specialized form field for handling [String] inputs with integrated [FieldNode] synchronization.
|
||||
///
|
||||
/// Manages text input lifecycle, including obscurity toggles, validation rules,
|
||||
/// and global API error stream integration.
|
||||
class AstromicStringFormField extends StatefulWidget {
|
||||
const AstromicStringFormField({
|
||||
super.key,
|
||||
required this.formController,
|
||||
required this.formID,
|
||||
this.initialText,
|
||||
this.initialObscurity,
|
||||
this.configuration,
|
||||
this.validators,
|
||||
this.resetMessageOnChange,
|
||||
this.errorsCatcher,
|
||||
this.style,
|
||||
this.hint,
|
||||
this.prefixWidget,
|
||||
this.suffixWidget,
|
||||
this.messageBuilder,
|
||||
this.onChanged,
|
||||
this.onSubmited,
|
||||
this.contextButtons,
|
||||
});
|
||||
|
||||
final AstromicFormController formController;
|
||||
final String formID;
|
||||
|
||||
final AstromicFieldConfiguration? configuration;
|
||||
final List<FormControllerValidator>? validators;
|
||||
final bool? resetMessageOnChange;
|
||||
final Map<String, String?>? errorsCatcher;
|
||||
final AstromicFieldStyle Function(bool isEnabled, bool isFocused, AstromicFieldState state)? style;
|
||||
|
||||
final String? hint;
|
||||
final Widget? Function(bool isEnabled, bool isFocused, VoidCallback stateSetter, AstromicFieldState state)? prefixWidget;
|
||||
final Widget? Function(bool isEnabled, bool isFocused, VoidCallback stateSetter, AstromicFieldState state)? suffixWidget;
|
||||
final Widget? Function(bool isEnabled, bool isFocused, AstromicFieldState state, String? message)? messageBuilder;
|
||||
|
||||
final String? initialText;
|
||||
final bool? initialObscurity;
|
||||
final void Function(String v, AstromicFieldState state)? onChanged;
|
||||
final void Function(String v, AstromicFieldState state)? onSubmited;
|
||||
final Iterable<ContextMenuButtonItem>? contextButtons;
|
||||
|
||||
@override
|
||||
State<AstromicStringFormField> createState() => _AstromicStringFormFieldState();
|
||||
}
|
||||
|
||||
class _AstromicStringFormFieldState extends State<AstromicStringFormField> {
|
||||
late AstromicFieldNode<String> _node;
|
||||
AstromicFieldNode<bool>? _obscurityNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_node = widget.formController.node<String>(
|
||||
widget.formID,
|
||||
initialValue: widget.initialText ?? '',
|
||||
);
|
||||
|
||||
if (widget.validators != null && widget.validators!.isNotEmpty) {
|
||||
_node.validators = widget.validators!;
|
||||
}
|
||||
|
||||
if (widget.configuration?.withObscurity ?? false) {
|
||||
_obscurityNode = widget.formController.node<bool>('${widget.formID}_obscurity');
|
||||
if (widget.initialObscurity != null) {
|
||||
_obscurityNode!.value = widget.initialObscurity!;
|
||||
}
|
||||
}
|
||||
|
||||
widget.formController.errorStream.listen(_handleApiErrors);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AstromicStringFormField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.validators != oldWidget.validators) {
|
||||
_node.validators = widget.validators ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps global API error codes to the local field state based on the [errorsCatcher] map.
|
||||
void _handleApiErrors(List<(String code, String? message)> errors) {
|
||||
if (widget.errorsCatcher == null) return;
|
||||
|
||||
for (var entry in widget.errorsCatcher!.entries) {
|
||||
final codeToCatch = entry.key;
|
||||
final fallbackMsg = entry.value;
|
||||
|
||||
final matchingError = errors.where((e) => e.$1 == codeToCatch).firstOrNull;
|
||||
|
||||
if (matchingError != null && mounted) {
|
||||
_node.setState(
|
||||
AstromicFieldState.withError,
|
||||
newMessage: matchingError.$2 ?? fallbackMsg ?? 'Unknown Error',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronizes the widget-level validation with the internal [FieldNode] logic.
|
||||
String? _runValidation(String? value) {
|
||||
return _node.validate(extraValidators: widget.validators);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<AstromicFieldState>(
|
||||
valueListenable: _node.state,
|
||||
builder: (context, currentState, _) {
|
||||
return ValueListenableBuilder<String?>(
|
||||
valueListenable: _node.message,
|
||||
builder: (context, errorMsg, _) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _obscurityNode ?? ValueNotifier(false),
|
||||
builder: (context, isObscured, _) {
|
||||
return AstromicFields.text(
|
||||
stateKey: ValueKey(widget.formID),
|
||||
controller: _node.syncedController,
|
||||
onChanged: (s) {
|
||||
if (widget.resetMessageOnChange ?? false) {
|
||||
_node.resetState();
|
||||
}
|
||||
widget.onChanged?.call(s, currentState);
|
||||
},
|
||||
onSubmited: (s) => widget.onSubmited?.call(s, currentState),
|
||||
configuration: widget.configuration?.copyWith(
|
||||
isTextObscured: isObscured,
|
||||
validator: (enabled, focused, val) => _runValidation(val),
|
||||
),
|
||||
style: (enabled, focused) => widget.style?.call(enabled, focused, currentState) ?? const AstromicFieldStyle(),
|
||||
hint: widget.hint,
|
||||
prefixWidget: widget.prefixWidget != null ? (e, f, s) => widget.prefixWidget!(e, f, s, currentState) : null,
|
||||
suffixWidget: widget.suffixWidget != null ? (e, f, s) => widget.suffixWidget!(e, f, s, currentState) : null,
|
||||
messageBuilder: widget.messageBuilder != null ? (e, f) => widget.messageBuilder!(e, f, currentState, errorMsg) : null,
|
||||
contextButtons: widget.contextButtons,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
83
lib/src/form/src/widgets/value_wrapper.dart
Normal file
83
lib/src/form/src/widgets/value_wrapper.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:form_controller/form_controller.dart' show FormControllerValidator;
|
||||
import '../controller.dart';
|
||||
import '../enums/enums.exports.dart' show AstromicFieldState;
|
||||
import '../models/field_node.dart';
|
||||
|
||||
/// A reactive wrapper widget that synchronizes a specific [FieldNode] with a custom UI builder.
|
||||
///
|
||||
/// It acts as a bridge for non-standard fields, handling validation logic injection
|
||||
/// and state notification for values of type [T].
|
||||
class AstromicValueWrapper<T> extends StatefulWidget {
|
||||
const AstromicValueWrapper({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.nodeId,
|
||||
required this.builder,
|
||||
this.initialValue,
|
||||
this.validators,
|
||||
});
|
||||
|
||||
final AstromicFormController controller;
|
||||
final String nodeId;
|
||||
final T? initialValue;
|
||||
final List<FormControllerValidator>? validators;
|
||||
final Widget Function(BuildContext context, T value, String? error, dynamic Function(T) onChange) builder;
|
||||
|
||||
@override
|
||||
State<AstromicValueWrapper<T>> createState() => _AstromicValueWrapperState<T>();
|
||||
}
|
||||
|
||||
class _AstromicValueWrapperState<T> extends State<AstromicValueWrapper<T>> {
|
||||
late AstromicFieldNode<T> _node;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
try {
|
||||
_node = widget.controller.node<T>(widget.nodeId, initialValue: widget.initialValue);
|
||||
} catch (_) {
|
||||
if (widget.initialValue != null) {
|
||||
widget.controller.set<T>(widget.nodeId, widget.initialValue as T);
|
||||
_node = widget.controller.node<T>(widget.nodeId);
|
||||
} else {
|
||||
throw Exception('Node ${widget.nodeId} not found or failed to initialize.');
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.validators != null) {
|
||||
_node.validators = widget.validators!;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<T>(
|
||||
valueListenable: _node,
|
||||
builder: (context, currentValue, _) {
|
||||
return ValueListenableBuilder<AstromicFieldState>(
|
||||
valueListenable: _node.state,
|
||||
builder: (context, fieldState, _) {
|
||||
return ValueListenableBuilder<String?>(
|
||||
valueListenable: _node.message,
|
||||
builder: (context, errorMsg, _) {
|
||||
final visibleError = fieldState == AstromicFieldState.withError ? errorMsg : null;
|
||||
|
||||
return widget.builder(
|
||||
context,
|
||||
currentValue,
|
||||
visibleError,
|
||||
(newValue) {
|
||||
_node.value = newValue;
|
||||
_node.resetState();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
4
lib/src/form/src/widgets/widget.exports.dart
Normal file
4
lib/src/form/src/widgets/widget.exports.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
export './string_form_field.dart';
|
||||
export './custom_form_field.dart';
|
||||
export './value_wrapper.dart';
|
||||
export './consumers.dart';
|
||||
Reference in New Issue
Block a user