diff --git a/analysis_options.yaml b/analysis_options.yaml index 9062b84..c3e7c5c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,47 +1,48 @@ -include: package:lints/recommended.yaml +include: package:flutter_lints/flutter.yaml + +analyzer: + language: + # Ensures the compiler never implicitly assumes 'dynamic' when it cannot infer a type. + strict-inference: true + # Disallows the use of generic types without explicit type arguments (e.g., List instead of List). + strict-raw-types: true + errors: + # Elevates missing required parameters from a warning to a hard compile error. + missing_required_param: error + # Elevates missing return statements in non-void functions to a hard compile error. + missing_return: error linter: rules: - # ==== Project Organization Rules ==== - # Enforces relative imports to maintain project structure and avoid unnecessary long paths + # Requires explicit return types for all functions and methods to ensure API clarity. + - always_declare_return_types + # Discourages redundant type annotations on closure parameters where inference is sufficient. + - avoid_types_on_closure_parameters + # Requires the @override annotation when a class member overrides a member from a superclass. + - annotate_overrides + # Enforces the use of relative imports for files within the same package to maintain portability. - prefer_relative_imports - - # Ensures that files are named in lowercase_with_underscores format - - file_names - - # ==== Best Practices ==== - # Enforces the closing of streams and sinks to avoid memory leaks - - close_sinks - - # Avoids empty 'else' blocks to reduce confusion and improve code readability - - avoid_empty_else - - # Prefer using 'const' constructors wherever possible for better performance and immutability + # Enables the use of the 'super.parameter' syntax in constructors to reduce boilerplate. + - use_super_parameters + # Encourages 'const' constructors for classes to optimize memory and performance. - prefer_const_constructors - - # Avoid leading underscores for local variable names to prevent conflicts and improve clarity - - no_leading_underscores_for_local_identifiers - - # ==== Code Consistency ==== - # Avoids the use of 'print' statements in production code, encouraging proper logging instead - - avoid_print - - # Encourages using 'final' for fields that are not reassigned to promote immutability + # Requires 'const' for variable declarations that are initialized with a constant value. + - prefer_const_declarations + # Encourages declaring local variables as 'final' if they are not reassigned. + - prefer_final_locals + # Encourages declaring private fields as 'final' if they are not reassigned. - prefer_final_fields - - # Ensures that all types are explicitly specified for better readability and type safety - - always_specify_types - - # Avoids redundant default argument values to keep the code clean - - avoid_redundant_argument_values - - # Enforces consistency by preferring single quotes over double quotes for string literals + # Triggers a warning when a future is not awaited or explicitly handled, preventing race conditions. + - unawaited_futures + # Prevents accessing BuildContext across asynchronous gaps to avoid runtime crashes. + - use_build_context_synchronously + # Ensures that Sink and StreamController instances are properly closed to prevent memory leaks. + - close_sinks + # Prevents the use of control flow statements (like return or throw) inside a finally block. + - control_flow_in_finally + # Standardizes string literals to use single quotes unless the string contains a single quote. - prefer_single_quotes - - # ==== Documentation Rules ==== - # Enforces documentation for all public classes, methods, and fields to improve API clarity - # - public_member_api_docs - - # ==== Null Safety ==== - # Avoids unnecessary null checks and encourages the use of null-aware operators - - unnecessary_null_checks + # Requires constructors to be placed before other members in a class. + - sort_constructors_first + # Disallows leading underscores for local variables to distinguish them from private class members. + - no_leading_underscores_for_local_identifiers \ No newline at end of file diff --git a/lib/astromic_helpers.dart b/lib/astromic_helpers.dart index 93d68a4..1d46e1f 100644 --- a/lib/astromic_helpers.dart +++ b/lib/astromic_helpers.dart @@ -1,4 +1,4 @@ -library astromic_helpers; +library; export 'src/form/form_helper.astromic.dart'; export 'src/loading/loading_helper.astromic.dart'; diff --git a/lib/src/form/form_helper.astromic.dart b/lib/src/form/form_helper.astromic.dart index fd465ed..3e6e3ba 100644 --- a/lib/src/form/form_helper.astromic.dart +++ b/lib/src/form/form_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'; diff --git a/lib/src/form/src/controller.dart b/lib/src/form/src/controller.dart index 5ba0329..f7c296f 100644 --- a/lib/src/form/src/controller.dart +++ b/lib/src/form/src/controller.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 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 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 key = GlobalKey(); + + /// Registry containing all active [AstromicFieldNode]s indexed by their unique identifiers. + // ignore: strict_raw_type + final Map _nodes = {}; + + /// Registry containing all registered [AstromicFormGroupDefinition] schemas. + final Map _groupDefs = {}; + + /// Accessor for the map of group definitions currently registered in the controller. + Map get groupDefs => _groupDefs; + + final StreamController> _errorStreamController = StreamController.broadcast(); + + /// A stream that emits a list of error codes and messages for external handling. + Stream> 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(id, AstromicFieldState.idle)]); - fieldMessages.addEntries(>[MapEntry(id, null)]); + /// Retrieves a specific [AstromicFieldNode] by its unique [id], creating it if an [initialValue] is provided. + AstromicFieldNode node(String id, {T? initialValue}) { + if (_nodes.containsKey(id)) { + return (_nodes[id] as dynamic) as AstromicFieldNode; } - SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { - if (ret.text.isEmpty) { - ret.text = initialText ?? ''; - } - }); - return ret; + + if (initialValue != null || _isNullable()) { + final newNode = AstromicFieldNode( + 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()) { + defaultValue = '' as T; + } else if (T == int || T == _typeOf()) { + defaultValue = 0 as T; + } else if (T == bool || T == _typeOf()) { + defaultValue = false as T; + } + + if (defaultValue != null) { + final newNode = AstromicFieldNode( + 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; - // Remove the field state - if (fieldStates.containsKey(id)) { - fieldStates.remove(id); - } + bool _isNullable() => null is T; - // Remove the field message - if (fieldMessages.containsKey(id)) { - fieldMessages.remove(id); - } - } - //!SECTION - - //SECTION - Field State - //S1 - State Variables - final Map fieldStates = {}; - final Map fieldMessages = {}; - //S1 - Streams - static final StreamController<(String, AstromicFieldState)> _stateStreamController = StreamController<(String id, AstromicFieldState)>.broadcast(); - final Stream<(String id, AstromicFieldState)> stateStream = _stateStreamController.stream; - final Stream>? 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(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 _hostedValues = {}; - - //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 get allValues => _hostedValues; - - /// Get the value of a hosted state variable using it's ID. - T? getValue(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(String id, T? data, {bool isRequired = false}) { - if (!_hostedValues.keys.toList().contains(id)) { - return _hostedValues.addEntries(>[MapEntry(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 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 _formGroups = []; - //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 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(String id, T value) { + if (_nodes.containsKey(id)) { + final node = _nodes[id]!; + if (node is AstromicFieldNode) { + 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( + value, + formatter: (v) => v.toString(), + parser: (v) => null, + ); } - - return currentGroup; } - /// returns the full path of this group ID. - String? getFullPathOfGroup(String targetGroupID, {List? 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(String id) { + return node(id).value; + } - // Otherwise, check in its subgroups recursively - for (final (int, FormGroupStructure) subGroup in group.subGroups ?? <(int, FormGroupStructure)>[]) { - final String? subGroupPath = getFullPathOfGroup(targetGroupID, formGroups: [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(String id) { + if (_nodes.containsKey(id)) { + final n = _nodes[id]; + if (n is AstromicFieldNode) { + 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? parents}) { - // Get the group structure - FormGroupStructure? structure = getGroupStructure(targetGroupID); + /// Retrieves the manifest [ValueNotifier] containing the list of UUIDs for a given group. + ValueNotifier> 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 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 ?? []) { - 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? 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 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 fieldsIDs = groupStructure!.fields.nonNulls.toList(); - - // get the values IDs - List valuesIDs = groupStructure.values?.nonNulls.toList() ?? []; - - List instances = []; - for (int i = 0; i < instancesCount; i++) { - // get the subGroups - List subValues = []; - if (groupStructure.subGroups != null && groupStructure.subGroups!.isNotEmpty) { - subValues = groupStructure.subGroups! - .map( - ((int, FormGroupStructure) s) => getFormGroupValue( - s.$2.id, - parents: { - if (parents != null) ...parents, - formGroupID: i, - }, - ), - ) - .nonNulls - .toList(); - } - String pr = prefix != null ? '$prefix-' : ''; - instances.add( - FormGroupInstance( - composedID: pr + standeredGroupFormat(formGroupID, i.toString(), ''), - fields: Map.fromEntries(fieldsIDs - .map((String id) => MapEntry(pr + standeredGroupFormat(formGroupID, i.toString(), id), tryValue(pr + standeredGroupFormat(formGroupID, i.toString(), id)) ?? '')) - .toList()), - values: valuesIDs.isNotEmpty - ? Map.fromEntries(valuesIDs - .map((String id) => MapEntry(pr + standeredGroupFormat(formGroupID, i.toString(), id), getValue(pr + standeredGroupFormat(formGroupID, i.toString(), id)))) - .toList()) - : {}, - subGroups: subValues, - ), + if (!_nodes.containsKey(key)) { + _nodes[key] = AstromicFieldNode>( + [], + formatter: (v) => v.length.toString(), + parser: (v) => [], ); } - FormGroupValue groupValue = FormGroupValue(groupID: formGroupID, instancesCount: instancesCount, instances: instances); - return groupValue; + return _nodes[key] as ValueNotifier>; } - /// Is this group a SubGroup? - bool isASubGroup(String formGroupID) { - List? 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? 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>( + [], + 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? 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 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? parents) { - log('_copyInstance: $groupID from $fromIndex to $toIndex, parents=$parents'); - FormGroupStructure? structure = getGroupStructure(groupID); - assert(structure != null); - - String prefix = parents?.entries.map((MapEntry 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 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: {...?parents, groupID: fromIndex}); - log('Subgroup ${sg.$2.id} has $sgCount instances'); - for (int j = 0; j < sgCount; j++) { - _copyInstance(sg.$2.id, j, j, {...?parents, groupID: toIndex}); - } - } - } + void _createTypedNode(String key, AstromicFieldConfig config, T initialValue) { + _nodes[key] = AstromicFieldNode( + 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? parents) { - log('_removeInstance: $groupID index $index, parents=$parents'); - _checkSubgroups(groupID, parents: {...?parents, groupID: index}); - FormGroupStructure? structure = getGroupStructure(groupID); - String prefix = parents?.entries.map((MapEntry e) => standeredGroupFormat(e.key, e.value.toString(), null)).join('-') ?? ''; - String composedID = prefix.isNotEmpty ? '$prefix-$groupID' : groupID; - _removeGroupControllers(composedID, [index], structure!.fields, structure.values ?? []); + String _getParentKey(String childGroup, String parentUuid) { + return '${childGroup}_$parentUuid'; } - void _removeGroupControllers(String composedID, List indecies, List fields, List 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? 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 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 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: { - ...?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.generate(subgroupCount, (int ii) => ii), sg.$2.fields, sg.$2.values ?? []); - } + final error = node.validate(); + if (error != null) allValid = false; } + return allValid; } - /// Remove an instance from the group - void removeInstanceFromGroup(String targetGroupID, int indexToRemove, {Map? 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> getGroupData(String groupId, {String? parentUuid}) { + final manifest = getManifest(groupId, parentUuid); + final List 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 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 fields, List values) _checkSubgroupsToValidate(String groupID, {Map? parents}) { - FormGroupStructure? groupStructure = getGroupStructure(groupID); - String? prefix; - if (parents != null) { - prefix = parents.entries.map((MapEntry parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-'); - } - // - // - List fieldsToValidate = []; - List valuesToValidate = []; - for ((int, FormGroupStructure) sg in (groupStructure?.subGroups ?? <(int, FormGroupStructure)>[])) { - FormGroupStructure? subgroupStructure = getGroupStructure(sg.$2.id); - int sgInstances = getInstanceCount(sg.$2.id, parents: {...?parents}); - for (int i = 0; i < sgInstances; i++) { - _checkSubgroupsToValidate( - sg.$2.id, - parents: { - ...?parents, - ...{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 ?? [])) { - 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 fieldsToValidate = []; - List valuesToValidate = []; - - 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 ?? [])) { - 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 fields, List values) sgRet = _checkSubgroupsToValidate(groupID, parents: {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? initialValues) { - // if (initialValues != null) { - // // Add in the initial field states... - // fieldStates.addEntries(initialValues.entries.map((MapEntry e) => MapEntry( - // 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 e) => MapEntry( - // e.key, // Controller ID - // null, // The initial message it has which is Null - // ))); - // } - // } - - // void _addInitialHostedValues(Map? initialValues) { - // if (initialValues != null) { - // for (MapEntry vEntry in initialValues.entries) { - // setValue(vEntry.key, vEntry.value.$1, isRequired: vEntry.value.$2); - // } - // } - // } - _initializeFormGroups(Map initVals, {Map? parents}) { - log('Now we have groups, Initialize them with the parent: $parents'); - for (MapEntry 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: {...?parents, groupID: currentGroupIndex}); - } - - String? prefix = parents?.entries.map((MapEntry parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-'); - log('Got the prefix, it\'s $prefix'); - - // Fields - if (groupValue.fieldValues != null) { - for (MapEntry> 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> 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 } diff --git a/lib/src/form/src/form.dart b/lib/src/form/src/form.dart deleted file mode 100644 index bb37b3f..0000000 --- a/lib/src/form/src/form.dart +++ /dev/null @@ -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 createState() => _AstromicFormState(); -} - -class _AstromicFormState extends State { - // - //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(); - } -} diff --git a/lib/src/form/src/form_field.dart b/lib/src/form/src/form_field.dart deleted file mode 100644 index 5a2921f..0000000 --- a/lib/src/form/src/form_field.dart +++ /dev/null @@ -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 extends StatefulWidget { - //SECTION - Widget Arguments - //S1 -- Shared - final AstromicFormController formController; - final String formID; - final bool? initialObscurity; - // - final AstromicFieldConfiguration? configuration; - final List? validators; - final bool? resetMessageOnChange; - final Map? 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? 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> createState() => _AstromicFormFieldState(); -} - -class _AstromicFormFieldState extends State> { - // - //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 validators = []; - // - validators.addAll(widget.validators!); - // - Map checks = {}; - // - for (FormControllerValidator validator in validators) { - bool res = validator.checker(s); - checks.addEntries(>[MapEntry(validator, res)]); - } - // - if (checks.containsValue(false)) { - // It has an Error! - _setFieldErrorState(widget.formID, checks.entries.where((MapEntry 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 validators = []; - // - validators.addAll(widget.validators!); - // - Map checks = {}; - // - for (FormControllerValidator validator in validators) { - bool res = validator.checker(s); - checks.addEntries(>[MapEntry(validator, res)]); - } - // - if (checks.containsValue(false)) { - // It has an Error! - _setFieldErrorState(widget.formID, checks.entries.where((MapEntry 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(); - } -} diff --git a/lib/src/form/src/form_group_wrapper.dart b/lib/src/form/src/form_group_wrapper.dart deleted file mode 100644 index b215812..0000000 --- a/lib/src/form/src/form_group_wrapper.dart +++ /dev/null @@ -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 children, String Function() addItem, void Function(int) removeItem) groupBuilder; - final Widget Function(int index, String composedID, VoidCallback removeItem) itemBuilder; - final Map? parents; - //!SECTION - // - const FormGroupWrapper({ - super.key, - required this.formController, - required this.groupID, - required this.groupBuilder, - required this.itemBuilder, - this.parents, - }); - - @override - State createState() => _FormGroupWrapperState(); -} - -class _FormGroupWrapperState extends State { - // - //SECTION - State Variables - //s1 --State - late List 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.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(); - } -} diff --git a/lib/src/form/src/form_value_wrapper.dart b/lib/src/form/src/form_value_wrapper.dart deleted file mode 100644 index 1e0d7b2..0000000 --- a/lib/src/form/src/form_value_wrapper.dart +++ /dev/null @@ -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 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> createState() => _FormValueWrapperState(); -} - -class _FormValueWrapperState extends State> { - // - //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(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(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(); - } -} diff --git a/lib/src/form/src/helpers/form_group_helper.dart b/lib/src/form/src/helpers/form_group_helper.dart index e69de29..09ce5a9 100644 --- a/lib/src/form/src/helpers/form_group_helper.dart +++ b/lib/src/form/src/helpers/form_group_helper.dart @@ -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> 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? 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 node(String uuid, String fieldName) { + return _ctrl.node('${_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 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 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>( + 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> groupIds(String groupId, String? parentUuid) { + return getManifest(groupId, parentUuid); + } +} diff --git a/lib/src/form/src/models/field_config.dart b/lib/src/form/src/models/field_config.dart new file mode 100644 index 0000000..5c0f9a5 --- /dev/null +++ b/lib/src/form/src/models/field_config.dart @@ -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 { + 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? 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(String, AstromicFieldConfig, V) creator) { + creator(key, this, value); + } +} diff --git a/lib/src/form/src/models/field_node.dart b/lib/src/form/src/models/field_node.dart new file mode 100644 index 0000000..eda9ace --- /dev/null +++ b/lib/src/form/src/models/field_node.dart @@ -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 extends ValueNotifier { + AstromicFieldNode( + super.value, { + required this.formatter, + required this.parser, + this.validators = const [], + }); + + /// 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 validators; + + /// Notifies listeners of changes in the field's visual or interaction state (e.g., idle, focused, error). + final ValueNotifier state = ValueNotifier(AstromicFieldState.idle); + + /// Holds the current error message or feedback string to be displayed in the UI. + final ValueNotifier 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? 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; + } +} diff --git a/lib/src/form/src/models/form_group_definition.dart b/lib/src/form/src/models/form_group_definition.dart new file mode 100644 index 0000000..cacab71 --- /dev/null +++ b/lib/src/form/src/models/form_group_definition.dart @@ -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 schema; + + /// A list of identifiers for nested subgroups that belong to this group. + final List subGroups; +} \ No newline at end of file diff --git a/lib/src/form/src/models/form_group_instance.model.dart b/lib/src/form/src/models/form_group_instance.model.dart deleted file mode 100644 index 3510550..0000000 --- a/lib/src/form/src/models/form_group_instance.model.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; - -import 'form_group_value.model.dart'; - -class FormGroupInstance { - String composedID; - Map fields; - Map values; - List? subGroups; - FormGroupInstance({ - required this.composedID, - required this.fields, - required this.values, - this.subGroups, - }); - - FormGroupInstance copyWith({ - String? composedID, - Map? fields, - Map? values, - List? subGroups, - }) { - return FormGroupInstance( - composedID: composedID ?? this.composedID, - fields: fields ?? this.fields, - values: values ?? this.values, - subGroups: subGroups ?? this.subGroups, - ); - } - - Map toMap() { - return { - 'composedID': composedID, - 'fields': fields, - 'values': values, - 'subGroups': subGroups?.map((FormGroupValue x) => x.toMap()).toList(), - }; - } - - factory FormGroupInstance.fromMap(Map map) { - return FormGroupInstance( - composedID: map['composedID'], - fields: Map.from(map['fields'] as Map), - values: Map.from(map['values'] as Map), - subGroups: map['subGroups'] != null - ? List.from( - (map['subGroups'] as List).map( - (int x) => FormGroupValue.fromMap(x as Map), - ), - ) - : null, - ); - } - - String toJson() => json.encode(toMap()); - - factory FormGroupInstance.fromJson(String source) => FormGroupInstance.fromMap(json.decode(source) as Map); - - @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; -} diff --git a/lib/src/form/src/models/form_group_structure.model.dart b/lib/src/form/src/models/form_group_structure.model.dart deleted file mode 100644 index 7d89e84..0000000 --- a/lib/src/form/src/models/form_group_structure.model.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; - -class FormGroupStructure { - final String id; - final List fields; - final List? values; - final List<(int initialCount, FormGroupStructure structure)>? subGroups; - FormGroupStructure({ - required this.id, - required this.fields, - this.values, - this.subGroups, - }); - - FormGroupStructure copyWith({ - String? id, - List? fields, - Map? preFields, - List? values, - Map? 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 toMap() { - return { - 'id': id, - 'fields': fields, - 'values': values, - 'subGroups': subGroups?.map(((int initialCount, FormGroupStructure structure) x) => {'structure': x.$2.toMap(), 'initialCount': x.$1}).toList(), - }; - } - - factory FormGroupStructure.fromMap(Map map) { - return FormGroupStructure( - id: map['id'] as String, - fields: List.from(map['fields'] as List), - values: map['values'] != null ? List.from(map['values'] as List) : null, - subGroups: map['subGroups'] != null - ? (map['subGroups'] as List>).map((Map 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); - - @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; - } -} diff --git a/lib/src/form/src/models/form_group_value.model.dart b/lib/src/form/src/models/form_group_value.model.dart deleted file mode 100644 index 82b9e78..0000000 --- a/lib/src/form/src/models/form_group_value.model.dart +++ /dev/null @@ -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 instances; - FormGroupValue({ - required this.groupID, - required this.instancesCount, - required this.instances, - }); - - FormGroupValue copyWith({ - String? groupID, - int? instancesCount, - List? instances, - }) { - return FormGroupValue( - groupID: groupID ?? this.groupID, - instancesCount: instancesCount ?? this.instancesCount, - instances: instances ?? this.instances, - ); - } - - Map toMap() { - return { - 'groupID': groupID, - 'instancesCount': instancesCount, - 'instances': instances.map((FormGroupInstance x) => x.toMap()).toList(), - }; - } - - factory FormGroupValue.fromMap(Map map) { - return FormGroupValue( - groupID: map['groupID'] as String, - instancesCount: map['instancesCount'] as int, - instances: List.from((map['instances'] as List).map((int x) => FormGroupInstance.fromMap(x as Map),),), - ); - } - - String toJson() => json.encode(toMap()); - - factory FormGroupValue.fromJson(String source) => FormGroupValue.fromMap(json.decode(source) as Map); - - @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; -} diff --git a/lib/src/form/src/models/initial_form_group_values.model.dart b/lib/src/form/src/models/initial_form_group_values.model.dart deleted file mode 100644 index 0d8fd76..0000000 --- a/lib/src/form/src/models/initial_form_group_values.model.dart +++ /dev/null @@ -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>? fieldValues; - final Map>? fieldObscurityValues; - final Map>? hostedValues; - final Map? subGroups; - InitialFormGroupValue({ - required this.instancesCount, - this.fieldValues, - this.fieldObscurityValues, - this.hostedValues, - this.subGroups, - }); - - InitialFormGroupValue copyWith({ - int? instancesCount, - Map>? fieldValues, - Map>? fieldObscurityValues, - Map>? hostedValues, - Map? subGroups, - }) { - return InitialFormGroupValue( - instancesCount: instancesCount ?? this.instancesCount, - fieldValues: fieldValues ?? this.fieldValues, - fieldObscurityValues: fieldObscurityValues ?? this.fieldObscurityValues, - hostedValues: hostedValues ?? this.hostedValues, - subGroups: subGroups ?? this.subGroups, - ); - } - - Map toMap() { - return { - 'instancesCount': instancesCount, - 'fieldValues': fieldValues, - 'fieldObscurityValues': fieldObscurityValues, - 'hostedValues': hostedValues, - 'subGroups': subGroups, - }; - } - - factory InitialFormGroupValue.fromMap(Map map) { - return InitialFormGroupValue( - instancesCount: map['instancesCount'] as int, - fieldValues: map['fieldValues'] != null ? Map>.from(map['fieldValues'] as Map>) : null, - fieldObscurityValues: map['fieldObscurityValues'] != null ? Map>.from(map['fieldObscurityValues'] as Map>) : null, - hostedValues: map['hostedValues'] != null ? Map>.from(map['hostedValues'] as Map>) : null, - subGroups: map['subGroups'] != null ? Map.from(map['subGroups'] as Map) : null, - ); - } - - String toJson() => json.encode(toMap()); - - factory InitialFormGroupValue.fromJson(String source) => InitialFormGroupValue.fromMap(json.decode(source) as Map); - - @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; - } -} diff --git a/lib/src/form/src/models/initial_values.model.dart b/lib/src/form/src/models/initial_values.model.dart deleted file mode 100644 index b5f5498..0000000 --- a/lib/src/form/src/models/initial_values.model.dart +++ /dev/null @@ -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? fieldValues; - final Map? fieldObscurityValues; - // Hosted Values - final Map? hostedValues; - // Form Groups - final Map? groupValues; - AstromicFormInitialValues({ - this.fieldValues, - this.fieldObscurityValues, - this.hostedValues, - this.groupValues, - }); - - AstromicFormInitialValues copyWith({ - Map? fieldValues, - Map? fieldObscurityValues, - Map? hostedValues, - Map? groupValues, - }) { - return AstromicFormInitialValues( - fieldValues: fieldValues ?? this.fieldValues, - fieldObscurityValues: fieldObscurityValues ?? this.fieldObscurityValues, - hostedValues: hostedValues ?? this.hostedValues, - groupValues: groupValues ?? this.groupValues, - ); - } - - Map toMap() { - return { - 'fieldValues': fieldValues, - 'fieldObscurityValues': fieldObscurityValues, - 'hostedValues': hostedValues, - 'groupValues': groupValues, - }; - } - - factory AstromicFormInitialValues.fromMap(Map map) { - return AstromicFormInitialValues( - fieldValues: map['fieldValues'] != null ? Map.from(map['fieldValues'] as Map) : null, - fieldObscurityValues: map['fieldObscurityValues'] != null ? Map.from(map['fieldObscurityValues'] as Map) : null, - hostedValues: map['hostedValues'] != null ? Map.from(map['hostedValues'] as Map) : null, - groupValues: map['groupValues'] != null ? Map.from(map['groupValues'] as Map) : null, - ); - } - - String toJson() => json.encode(toMap()); - - factory AstromicFormInitialValues.fromJson(String source) => AstromicFormInitialValues.fromMap(json.decode(source) as Map); - - @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; - } -} diff --git a/lib/src/form/src/models/models.exports.dart b/lib/src/form/src/models/models.exports.dart index 0cf0136..8ee00f2 100644 --- a/lib/src/form/src/models/models.exports.dart +++ b/lib/src/form/src/models/models.exports.dart @@ -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'; \ No newline at end of file +export './field_config.dart'; +export './field_node.dart'; +export './form_group_definition.dart'; diff --git a/lib/src/form/src/widgets/consumers.dart b/lib/src/form/src/widgets/consumers.dart new file mode 100644 index 0000000..4b8eb87 --- /dev/null +++ b/lib/src/form/src/widgets/consumers.dart @@ -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 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 node = controller.node(id); + + return ValueListenableBuilder( + 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> 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>( + valueListenable: manifest, + builder: (context, uuids, _) { + // Aggregate the manifest and all field nodes into a single listener list. + final List 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(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); + }, + ); + }, + ); + } +} diff --git a/lib/src/form/src/widgets/custom_form_field.dart b/lib/src/form/src/widgets/custom_form_field.dart new file mode 100644 index 0000000..c2d7cee --- /dev/null +++ b/lib/src/form/src/widgets/custom_form_field.dart @@ -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 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? validators; + final bool? resetMessageOnChange; + final Map? 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 Function(T currentValue)? onTap; + final Future Function(T currentValue)? onHold; + final String Function(String? oldValue, String newValue)? onValueChangedMapper; + + @override + State> createState() => _AstromicCustomFieldState(); +} + +class _AstromicCustomFieldState extends State> { + late AstromicFieldNode _dataNode; + late AstromicFieldNode _labelNode; + + @override + void initState() { + super.initState(); + + _dataNode = widget.formController.node(widget.formID, initialValue: widget.initialValue); + + if (widget.validators != null) { + _dataNode.validators = widget.validators!; + } + + final initialLabel = widget.labeler(widget.initialValue); + _labelNode = widget.formController.node('${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( + valueListenable: _dataNode.state, + builder: (context, currentState, _) { + return ValueListenableBuilder( + valueListenable: _dataNode.message, + builder: (context, errorMsg, _) { + return AstromicFields.action( + 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, + ); + }, + ); + }, + ); + } +} diff --git a/lib/src/form/src/widgets/string_form_field.dart b/lib/src/form/src/widgets/string_form_field.dart new file mode 100644 index 0000000..3c6565a --- /dev/null +++ b/lib/src/form/src/widgets/string_form_field.dart @@ -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? validators; + final bool? resetMessageOnChange; + final Map? 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? contextButtons; + + @override + State createState() => _AstromicStringFormFieldState(); +} + +class _AstromicStringFormFieldState extends State { + late AstromicFieldNode _node; + AstromicFieldNode? _obscurityNode; + + @override + void initState() { + super.initState(); + + _node = widget.formController.node( + 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('${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( + valueListenable: _node.state, + builder: (context, currentState, _) { + return ValueListenableBuilder( + valueListenable: _node.message, + builder: (context, errorMsg, _) { + return ValueListenableBuilder( + 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, + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/src/form/src/widgets/value_wrapper.dart b/lib/src/form/src/widgets/value_wrapper.dart new file mode 100644 index 0000000..5c2490d --- /dev/null +++ b/lib/src/form/src/widgets/value_wrapper.dart @@ -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 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? validators; + final Widget Function(BuildContext context, T value, String? error, dynamic Function(T) onChange) builder; + + @override + State> createState() => _AstromicValueWrapperState(); +} + +class _AstromicValueWrapperState extends State> { + late AstromicFieldNode _node; + + @override + void initState() { + super.initState(); + + try { + _node = widget.controller.node(widget.nodeId, initialValue: widget.initialValue); + } catch (_) { + if (widget.initialValue != null) { + widget.controller.set(widget.nodeId, widget.initialValue as T); + _node = widget.controller.node(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( + valueListenable: _node, + builder: (context, currentValue, _) { + return ValueListenableBuilder( + valueListenable: _node.state, + builder: (context, fieldState, _) { + return ValueListenableBuilder( + 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(); + }, + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/src/form/src/widgets/widget.exports.dart b/lib/src/form/src/widgets/widget.exports.dart new file mode 100644 index 0000000..2e0d2ce --- /dev/null +++ b/lib/src/form/src/widgets/widget.exports.dart @@ -0,0 +1,4 @@ +export './string_form_field.dart'; +export './custom_form_field.dart'; +export './value_wrapper.dart'; +export './consumers.dart';