[FEAT] new Form Helper!

This commit is contained in:
2026-01-23 21:50:31 +02:00
parent d44ffa4888
commit 5ccc04736a
23 changed files with 1024 additions and 1762 deletions

View File

@@ -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<int>).
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: linter:
rules: rules:
# ==== Project Organization Rules ==== # Requires explicit return types for all functions and methods to ensure API clarity.
# Enforces relative imports to maintain project structure and avoid unnecessary long paths - 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 - prefer_relative_imports
# Enables the use of the 'super.parameter' syntax in constructors to reduce boilerplate.
# Ensures that files are named in lowercase_with_underscores format - use_super_parameters
- file_names # Encourages 'const' constructors for classes to optimize memory and performance.
# ==== 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
- prefer_const_constructors - prefer_const_constructors
# Requires 'const' for variable declarations that are initialized with a constant value.
# Avoid leading underscores for local variable names to prevent conflicts and improve clarity - prefer_const_declarations
- no_leading_underscores_for_local_identifiers # Encourages declaring local variables as 'final' if they are not reassigned.
- prefer_final_locals
# ==== Code Consistency ==== # Encourages declaring private fields as 'final' if they are not reassigned.
# 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
- prefer_final_fields - prefer_final_fields
# Triggers a warning when a future is not awaited or explicitly handled, preventing race conditions.
# Ensures that all types are explicitly specified for better readability and type safety - unawaited_futures
- always_specify_types # Prevents accessing BuildContext across asynchronous gaps to avoid runtime crashes.
- use_build_context_synchronously
# Avoids redundant default argument values to keep the code clean # Ensures that Sink and StreamController instances are properly closed to prevent memory leaks.
- avoid_redundant_argument_values - close_sinks
# Prevents the use of control flow statements (like return or throw) inside a finally block.
# Enforces consistency by preferring single quotes over double quotes for string literals - control_flow_in_finally
# Standardizes string literals to use single quotes unless the string contains a single quote.
- prefer_single_quotes - prefer_single_quotes
# Requires constructors to be placed before other members in a class.
# ==== Documentation Rules ==== - sort_constructors_first
# Enforces documentation for all public classes, methods, and fields to improve API clarity # Disallows leading underscores for local variables to distinguish them from private class members.
# - public_member_api_docs - no_leading_underscores_for_local_identifiers
# ==== Null Safety ====
# Avoids unnecessary null checks and encourages the use of null-aware operators
- unnecessary_null_checks

View File

@@ -1,4 +1,4 @@
library astromic_helpers; library;
export 'src/form/form_helper.astromic.dart'; export 'src/form/form_helper.astromic.dart';
export 'src/loading/loading_helper.astromic.dart'; export 'src/loading/loading_helper.astromic.dart';

View File

@@ -1,7 +1,6 @@
export 'package:form_controller/form_controller.dart'; export 'package:form_controller/form_controller.dart';
export 'src/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/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';

View File

@@ -1,708 +1,233 @@
//s1 Imports
//s2 Core Package Imports
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/scheduler.dart'; import './models/models.exports.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
/// A specialized form controller to handle form states, /// The central brain of the Astromic Form System.
class AstromicFormController extends FormController { ///
AstromicFormController({ /// Manages a registry of reactive [AstromicFieldNode]s, handles dynamic group creation via UUIDs,
AstromicFormInitialValues? initialValues, /// and provides a robust validation mechanism covering both visible and hidden fields.
this.errorStream, class AstromicFormController {
}) : super(null) { /// The global key attached to the [Form] widget for integrated Flutter validation.
if (initialValues != null) { final GlobalKey<FormState> key = GlobalKey<FormState>();
// Take in the initials and proceed to fill:
//1. Field Values /// Registry containing all active [AstromicFieldNode]s indexed by their unique identifiers.
if (initialValues.fieldValues != null && initialValues.fieldValues!.isNotEmpty) { // ignore: strict_raw_type
for (MapEntry<String, String?> fieldValueEntry in initialValues.fieldValues!.entries) { final Map<String, AstromicFieldNode> _nodes = {};
String k = fieldValueEntry.key;
String v = fieldValueEntry.value ?? ''; /// Registry containing all registered [AstromicFormGroupDefinition] schemas.
bool isObs = initialValues.fieldObscurityValues != null && initialValues.fieldObscurityValues!.containsKey(k) && initialValues.fieldObscurityValues![k]!; final Map<String, AstromicFormGroupDefinition> _groupDefs = {};
//
controller(k, initialText: v, isObscure: isObs); /// Accessor for the map of group definitions currently registered in the controller.
} Map<String, AstromicFormGroupDefinition> get groupDefs => _groupDefs;
}
//2. Hosted Values final StreamController<List<(String code, String? message)>> _errorStreamController = StreamController.broadcast();
if (initialValues.hostedValues != null && initialValues.hostedValues!.isNotEmpty) {
for (MapEntry<String, (dynamic, bool)> hostedValueEntry in initialValues.hostedValues!.entries) { /// A stream that emits a list of error codes and messages for external handling.
String k = hostedValueEntry.key; Stream<List<(String code, String? message)>> get errorStream => _errorStreamController.stream;
dynamic v = hostedValueEntry.value.$1;
dynamic isReq = hostedValueEntry.value.$2; /// Registers a [AstromicFormGroupDefinition] to the controller to enable dynamic instance handling.
// void registerGroup(AstromicFormGroupDefinition def) {
setValue(k, v, isRequired: isReq); if (!_groupDefs.containsKey(def.id)) {
} _groupDefs[def.id] = def;
}
//3. Form Group Values
if (initialValues.groupValues != null && initialValues.groupValues!.isNotEmpty) {
_initializeFormGroups(initialValues.groupValues!);
}
} }
} }
//SECTION - Overrides /// Retrieves a specific [AstromicFieldNode] by its unique [id], creating it if an [initialValue] is provided.
@override AstromicFieldNode<T> node<T>(String id, {T? initialValue}) {
TextEditingController controller(String id, {String? initialText, bool isObscure = false}) { if (_nodes.containsKey(id)) {
TextEditingController ret = super.controller(id, initialText: initialText, isObscure: isObscure); return (_nodes[id] as dynamic) as AstromicFieldNode<T>;
//
if (getState(id) == null) {
fieldStates.addEntries(<MapEntry<String, AstromicFieldState>>[MapEntry<String, AstromicFieldState>(id, AstromicFieldState.idle)]);
fieldMessages.addEntries(<MapEntry<String, String?>>[MapEntry<String, String?>(id, null)]);
} }
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
if (ret.text.isEmpty) { if (initialValue != null || _isNullable<T>()) {
ret.text = initialText ?? ''; final newNode = AstromicFieldNode<T>(
} initialValue as T,
}); formatter: (v) => v?.toString() ?? '',
return ret; parser: (v) => (T == String ? v : null) as T?,
);
_nodes[id] = newNode;
return newNode;
}
T? defaultValue;
if (T == String || T == _typeOf<String?>()) {
defaultValue = '' as T;
} else if (T == int || T == _typeOf<int?>()) {
defaultValue = 0 as T;
} else if (T == bool || T == _typeOf<bool?>()) {
defaultValue = false as T;
}
if (defaultValue != null) {
final newNode = AstromicFieldNode<T>(
defaultValue,
formatter: (v) => v.toString(),
parser: (v) => null,
);
_nodes[id] = newNode;
return newNode;
}
throw Exception("Node $id not found and cannot be lazy-inited for type $T. Provide an initialValue.");
} }
@override Type _typeOf<T>() => T;
void removeController(String id) {
super.removeController(id);
// Remove the field state bool _isNullable<T>() => null is T;
if (fieldStates.containsKey(id)) {
fieldStates.remove(id);
}
// Remove the field message /// Retrieves the [TextEditingController] associated with a specific field ID.
if (fieldMessages.containsKey(id)) { TextEditingController controller(String id) {
fieldMessages.remove(id); return node<dynamic>(id).syncedController;
}
}
//!SECTION
//SECTION - Field State
//S1 - State Variables
final Map<String, AstromicFieldState> fieldStates = <String, AstromicFieldState>{};
final Map<String, String?> fieldMessages = <String, String?>{};
//S1 - Streams
static final StreamController<(String, AstromicFieldState)> _stateStreamController = StreamController<(String id, AstromicFieldState)>.broadcast();
final Stream<(String id, AstromicFieldState)> stateStream = _stateStreamController.stream;
final Stream<List<(String internalCode, String? message)>>? errorStream;
//S1 - Methods
/// Get the field state and message of a specific field using it's ID.
(AstromicFieldState, String? message)? getState(String fieldId) => (fieldStates.containsKey(fieldId)) ? (fieldStates[fieldId]!, fieldMessages[fieldId]) : null;
/// Set the field state and message of a specific field using it's ID.
void setState(String fieldId, AstromicFieldState state, {String? message}) {
if (!fieldStates.containsKey(fieldId)) {
throw Exception('The state of the field ID $fieldId does not exist.');
}
fieldStates[fieldId] = state;
fieldMessages[fieldId] = message;
_stateStreamController.add((fieldId, state));
} }
/// Reset the state of a specific field using it's ID. /// Programmatically sets the [value] for a field, creating the node if it does not exist.
void resetState(String fieldId) => setState(fieldId, AstromicFieldState.idle); void set<T>(String id, T value) {
//!SECTION if (_nodes.containsKey(id)) {
final node = _nodes[id]!;
//SECTION - Hosted Values if (node is AstromicFieldNode<T>) {
//S1 - State Variables node.value = value;
final Map<String, (dynamic value, bool isRequired)> _hostedValues = <String, (dynamic, bool)>{};
//S1 - Streams
static final StreamController<(String, bool)> _hostedValueValidationStreamController = StreamController<(String id, bool)>.broadcast();
final Stream<(String id, bool isValidationErrored)> hostedValueValidationStream = _hostedValueValidationStreamController.stream;
//S1 - Methods
/// Get all the hosted values IDs.
Map<String, (dynamic, bool)> get allValues => _hostedValues;
/// Get the value of a hosted state variable using it's ID.
T? getValue<T>(String id) {
if (!_hostedValues.containsKey(id)) {
return null;
}
if (_hostedValues[id]?.$1 is T?) {
return _hostedValues[id]?.$1;
} else {
throw Exception('Value found but is not of the type $T, it\'s of type ${_hostedValues[id]?.$1.runtimeType}');
}
}
/// Set the value of a hosted state variable using it's ID.
void setValue<T>(String id, T? data, {bool isRequired = false}) {
if (!_hostedValues.keys.toList().contains(id)) {
return _hostedValues.addEntries(<MapEntry<String, (T?, bool)>>[MapEntry<String, (T?, bool)>(id, (null, isRequired))]);
}
//
else {
bool isReq = _hostedValues[id]!.$2;
_hostedValues[id] = (data, isReq);
_hostedValueValidationStreamController.add((id, false));
}
}
/// Remove the value of a hosted state variable using it's ID.
void removeValue(String id) {
if (_hostedValues.keys.toList().contains(id)) {
_hostedValues.remove(id);
}
}
/// Validate hosted values.
bool validateValues(List<String> valueIDs) {
for (String hostedValueID in valueIDs) {
if (_hostedValues.containsKey(hostedValueID) && _hostedValues[hostedValueID]!.$2 && _hostedValues[hostedValueID]!.$1 == null) {
// Validation Error!
_hostedValueValidationStreamController.add((hostedValueID, true));
return false;
}
}
return true;
}
//!SECTION
//SECTION - Form Groups
//S1 - State Variables
final List<FormGroupStructure> _formGroups = <FormGroupStructure>[];
//S1 - Methods
/// Does the controller has a field group with this ID.
bool hasFieldGroup(String formGroupID) => getGroupStructure(formGroupID) != null;
/// Get the structure of this groupId
FormGroupStructure? getGroupStructure(String groupID) {
// Get the full path of the group
final String? fullPath = getFullPathOfGroup(groupID, formGroups: _formGroups);
// If no path is found, return null
if (fullPath == null) return null;
// Split the path into segments (using the standard separator)
final List<String> pathSegments = fullPath.split('->');
// We start with the root group (the first segment in the path)
FormGroupStructure? currentGroup = _formGroups.where((FormGroupStructure group) => group.id == pathSegments.first).firstOrNull;
// If the root group is not found, return null
if (currentGroup == null) return null;
// Traverse through the path segments to find the group or subgroup
for (int i = 1; i < pathSegments.length; i++) {
final String segment = pathSegments[i];
// Search for the subgroup within the current group
final (int, FormGroupStructure)? subGroup = currentGroup?.subGroups?.where(((int, FormGroupStructure) subGroup) => subGroup.$2.id == segment).firstOrNull;
// If a subgroup is found, update currentGroup to the subgroup
if (subGroup != null) {
currentGroup = subGroup.$2;
} else { } else {
// If no subgroup is found at this level, return null throw Exception("Type Mismatch: Node '$id' is type ${node.runtimeType}, but tried to set $T");
return null;
} }
} else {
_nodes[id] = AstromicFieldNode<T>(
value,
formatter: (v) => v.toString(),
parser: (v) => null,
);
} }
return currentGroup;
} }
/// returns the full path of this group ID. /// Retrieves the current value of a field, leveraging lazy initialization for primitives.
String? getFullPathOfGroup(String targetGroupID, {List<FormGroupStructure>? formGroups, String currentPath = '', String separator = '->'}) { T get<T>(String id) {
// Loop through each FormGroupStructure return node<T>(id).value;
for (final FormGroupStructure group in (formGroups ?? _formGroups)) { }
// If the group ID matches, return the current path
if (group.id == targetGroupID) return '$currentPath${group.id}';
// Otherwise, check in its subgroups recursively /// Safely attempts to retrieve a value, returning `null` if the node is missing or of a different type.
for (final (int, FormGroupStructure) subGroup in group.subGroups ?? <(int, FormGroupStructure)>[]) { T? tryGet<T>(String id) {
final String? subGroupPath = getFullPathOfGroup(targetGroupID, formGroups: <FormGroupStructure>[subGroup.$2], currentPath: '$currentPath${group.id}$separator'); if (_nodes.containsKey(id)) {
// Return the path if found final n = _nodes[id];
if (subGroupPath != null) { if (n is AstromicFieldNode<T>) {
return subGroupPath; return n.value;
}
} }
} }
// Return an empty string if the group ID is not found
return null; return null;
} }
/// Get the count of instances of this group. /// Retrieves the manifest [ValueNotifier] containing the list of UUIDs for a given group.
int getInstanceCount(String targetGroupID, {Map<String, int>? parents}) { ValueNotifier<List<String>> getManifest(String groupId, String? parentUuid) {
// Get the group structure final key = parentUuid == null ? '${groupId}_manifest' : '${_getParentKey(groupId, parentUuid)}_manifest';
FormGroupStructure? structure = getGroupStructure(targetGroupID);
if (structure != null) { if (!_nodes.containsKey(key)) {
// Get the prefix of the parents _nodes[key] = AstromicFieldNode<List<String>>(
String? prefix; [],
if (isASubGroup(targetGroupID) && parents != null) { formatter: (v) => v.length.toString(),
prefix = parents.entries.map((MapEntry<String, int> parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-'); parser: (v) => [],
}
// Get and process the regex pattern.
late RegExp pattern;
if (prefix != null) {
// This is a subgroup
pattern = RegExp(r'^' + ('$prefix-') + standeredGroupFormat(targetGroupID, r'[\d+]', structure.fields.first) + r'$');
} else {
pattern = RegExp(r'^' + standeredGroupFormat(targetGroupID, r'[\d+]', structure.fields.first) + r'$');
}
// Return keys that match the pattern.
return controllers.keys.where((String c) => pattern.hasMatch(c)).nonNulls.toList().length;
}
return 0;
}
void _validateSubGroupsRecursively(FormGroupStructure groupStructure) {
groupStructure.subGroups?.forEach(((int, FormGroupStructure) subGroupTuple) {
final FormGroupStructure subGroup = subGroupTuple.$2;
assert(subGroup.fields.isNotEmpty, '${subGroup.id}: Subgroup fields should NOT be empty.');
// Recursively validate subgroups of subgroups
if (subGroup.subGroups != null) {
_validateSubGroupsRecursively(subGroup);
}
});
}
void _addGroupControllers(FormGroupStructure structure, int index, {String? parentPrefix}) {
final String baseID = parentPrefix ?? structure.id;
for (final String fieldID in structure.fields) {
final String fullID = standeredGroupFormat(baseID, index.toString(), fieldID);
log('Trying to initialize and add $fullID');
controller(fullID);
}
for (final String valueID in structure.values ?? <String>[]) {
final String fullID = standeredGroupFormat(baseID, index.toString(), valueID);
log('Trying to initialize and add $fullID');
controller(fullID);
}
}
void _initializeGroupControllersRecursively(FormGroupStructure groupStructure, int initialCount, {String parentPrefix = ''}) {
// Add main group fields/values
for (int groupIndex = 0; groupIndex < initialCount; groupIndex++) {
_addGroupControllers(groupStructure, groupIndex, parentPrefix: parentPrefix.isEmpty ? null : parentPrefix);
// Recursively handle subgroups
if (groupStructure.subGroups != null && groupStructure.subGroups!.isNotEmpty) {
for (final (int subgroupInitialCount, FormGroupStructure subGroup) in groupStructure.subGroups!) {
final String subgroupPrefix = parentPrefix.isEmpty
? standeredGroupFormat(groupStructure.id, groupIndex.toString(), subGroup.id)
: standeredGroupFormat(parentPrefix, groupIndex.toString(), subGroup.id); // Add to parentPrefix only once
// Initialize subgroup controllers recursively
_initializeGroupControllersRecursively(subGroup, subgroupInitialCount, parentPrefix: subgroupPrefix);
}
}
}
}
/// Recursively initialize controllers for the group and its subgroups
void initializeFormGroup(FormGroupStructure groupStructure, {int initialCount = 1}) {
assert(groupStructure.fields.isNotEmpty, '${groupStructure.id}: Group fields should NOT be empty.');
// Validate subgroups (if any)
_validateSubGroupsRecursively(groupStructure);
// Add structure to registry
_formGroups.add(groupStructure);
// Initialize the group instances
_initializeGroupControllersRecursively(groupStructure, initialCount);
}
/// Get the target formGroup Value.
FormGroupValue? getFormGroupValue(String formGroupID, {Map<String, int>? parents}) {
// Get the group structure with the ID
FormGroupStructure? groupStructure = getGroupStructure(formGroupID);
assert(groupStructure != null, 'The Group $formGroupID doesn\'t seem to be found, are you sure you initialized it?');
// Get the prefix for subgroups if exists
String? prefix;
if (isASubGroup(formGroupID) && parents != null) {
prefix = parents.entries.map((MapEntry<String, int> parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-');
}
// Get the current fields with this ID
int instancesCount = getInstanceCount(formGroupID, parents: parents);
// get the fields IDs
List<String> fieldsIDs = groupStructure!.fields.nonNulls.toList();
// get the values IDs
List<String> valuesIDs = groupStructure.values?.nonNulls.toList() ?? <String>[];
List<FormGroupInstance> instances = <FormGroupInstance>[];
for (int i = 0; i < instancesCount; i++) {
// get the subGroups
List<FormGroupValue> subValues = <FormGroupValue>[];
if (groupStructure.subGroups != null && groupStructure.subGroups!.isNotEmpty) {
subValues = groupStructure.subGroups!
.map(
((int, FormGroupStructure) s) => getFormGroupValue(
s.$2.id,
parents: <String, int>{
if (parents != null) ...parents,
formGroupID: i,
},
),
)
.nonNulls
.toList();
}
String pr = prefix != null ? '$prefix-' : '';
instances.add(
FormGroupInstance(
composedID: pr + standeredGroupFormat(formGroupID, i.toString(), ''),
fields: Map<String, String>.fromEntries(fieldsIDs
.map((String id) => MapEntry<String, String>(pr + standeredGroupFormat(formGroupID, i.toString(), id), tryValue(pr + standeredGroupFormat(formGroupID, i.toString(), id)) ?? ''))
.toList()),
values: valuesIDs.isNotEmpty
? Map<String, dynamic>.fromEntries(valuesIDs
.map((String id) => MapEntry<String, dynamic>(pr + standeredGroupFormat(formGroupID, i.toString(), id), getValue<dynamic>(pr + standeredGroupFormat(formGroupID, i.toString(), id))))
.toList())
: <String, dynamic>{},
subGroups: subValues,
),
); );
} }
FormGroupValue groupValue = FormGroupValue(groupID: formGroupID, instancesCount: instancesCount, instances: instances); return _nodes[key] as ValueNotifier<List<String>>;
return groupValue;
} }
/// Is this group a SubGroup? /// Adds a new instance to a dynamic group, generating a UUID and initializing all schema fields.
bool isASubGroup(String formGroupID) { String addGroupInstance(String groupId, {String? parentUuid, Map<String, dynamic>? initialData}) {
List<String>? fullPath = getFullPathOfGroup(formGroupID)?.split('->'); final def = _groupDefs[groupId];
return fullPath != null && fullPath.length > 1 && fullPath.indexOf(formGroupID) != 0; if (def == null) throw Exception("Group $groupId not registered.");
final newUuid = DateTime.now().microsecondsSinceEpoch.toString();
def.schema.forEach((fieldName, config) {
final nodeKey = '${groupId}_${fieldName}_$newUuid';
final startVal = initialData?[fieldName] ?? config.initialValue;
config.register(nodeKey, startVal, _createTypedNode);
});
for (var sub in def.subGroups) {
final subKey = '${groupId}_${sub}_${newUuid}_manifest';
_nodes[subKey] = AstromicFieldNode<List<String>>(
[],
formatter: (v) => v.length.toString(),
parser: (v) => [],
);
}
final manifest = getManifest(groupId, parentUuid);
manifest.value = [...manifest.value, newUuid];
return newUuid;
} }
/// Add an instance to the group. /// Hides a group instance from the UI by removing its UUID from the manifest.
String addInstanceToFormGroup(String formGroupID, {Map<String, int>? parents}) { void removeGroupInstance(String groupId, String uuid, {String? parentUuid}) {
// Get the group structure with the same ID final manifest = getManifest(groupId, parentUuid);
FormGroupStructure? structure = getGroupStructure(formGroupID); manifest.value = manifest.value.where((id) => id != uuid).toList();
assert(structure != null, 'The Group $formGroupID doesn\'t seem to be found, are you sure you initialized it?');
// Get the prefix for subgroups if exists
String? prefix;
if (isASubGroup(formGroupID) && parents != null) {
prefix = parents.entries.map((MapEntry<String, int> parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-');
}
// Get current instances of this group and the new index.
int currentGroupInstances = getInstanceCount(formGroupID, parents: parents);
int newGroupIndex = currentGroupInstances;
// Add the controllers and values.
for (String fieldID in structure!.fields.nonNulls) {
String p = (prefix != null ? '$prefix-' : '') + standeredGroupFormat(formGroupID, newGroupIndex.toString(), fieldID);
controller(p.trim(), initialText: '');
}
if (structure.values != null && structure.values!.isNotEmpty) {
for (String valueID in structure.values!.nonNulls) {
String p = (prefix != null ? '$prefix-' : '') + standeredGroupFormat(formGroupID, newGroupIndex.toString(), valueID);
controller(p.trim());
}
}
String p = (prefix != null ? '$prefix-' : '') + standeredGroupFormat(formGroupID, newGroupIndex.toString(), '');
return p.trim();
} }
void _copyInstance(String groupID, int fromIndex, int toIndex, Map<String, int>? parents) { void _createTypedNode<T>(String key, AstromicFieldConfig<T> config, T initialValue) {
log('_copyInstance: $groupID from $fromIndex to $toIndex, parents=$parents'); _nodes[key] = AstromicFieldNode<T>(
FormGroupStructure? structure = getGroupStructure(groupID); initialValue,
assert(structure != null); formatter: config.formatter ?? (v) => v.toString(),
parser: config.parser ?? (v) => (T == String ? v : null) as T,
String prefix = parents?.entries.map((MapEntry<String, int> e) => standeredGroupFormat(e.key, e.value.toString(), null)).join('-') ?? ''; validators: config.validators ?? (config.validator != null ? [config.validator!] : []),
String composedID = prefix.isNotEmpty ? '$prefix-$groupID' : groupID; );
// Copy fields
for (String field in structure!.fields) {
String fromID = standeredGroupFormat(composedID, fromIndex.toString(), field);
String toID = standeredGroupFormat(composedID, toIndex.toString(), field);
String val = tryValue(fromID) ?? '';
log('Copying field $fromID ($val) to $toID');
set(toID, val);
}
// Copy values
for (String value in structure.values ?? <String>[]) {
String fromID = standeredGroupFormat(composedID, fromIndex.toString(), value);
String toID = standeredGroupFormat(composedID, toIndex.toString(), value);
dynamic val = getValue(fromID);
log('Copying value $fromID ($val) to $toID');
setValue(toID, val);
set(toID, tryValue(fromID) ?? '');
}
// Recursively copy subgroups
if (structure.subGroups != null) {
for ((int, FormGroupStructure) sg in structure.subGroups!) {
int sgCount = getInstanceCount(sg.$2.id, parents: <String, int>{...?parents, groupID: fromIndex});
log('Subgroup ${sg.$2.id} has $sgCount instances');
for (int j = 0; j < sgCount; j++) {
_copyInstance(sg.$2.id, j, j, <String, int>{...?parents, groupID: toIndex});
}
}
}
} }
void _removeInstance(String groupID, int index, Map<String, int>? parents) { String _getParentKey(String childGroup, String parentUuid) {
log('_removeInstance: $groupID index $index, parents=$parents'); return '${childGroup}_$parentUuid';
_checkSubgroups(groupID, parents: <String, int>{...?parents, groupID: index});
FormGroupStructure? structure = getGroupStructure(groupID);
String prefix = parents?.entries.map((MapEntry<String, int> e) => standeredGroupFormat(e.key, e.value.toString(), null)).join('-') ?? '';
String composedID = prefix.isNotEmpty ? '$prefix-$groupID' : groupID;
_removeGroupControllers(composedID, <int>[index], structure!.fields, structure.values ?? <String>[]);
} }
void _removeGroupControllers(String composedID, List<int> indecies, List<String> fields, List<String> values, {bool switchValuesFirst = false}) { /// Performs a full validation of both the UI state and internal logical data nodes.
log('_removeGroupControllers: composedID=$composedID, indecies=$indecies, switchValuesFirst=$switchValuesFirst'); bool validate() {
for (int i in indecies) { final bool ui = key.currentState?.validate() ?? false;
log('Processing index $i'); bool data = true;
for (String fieldID in fields) { for (var n in _nodes.values) {
String p = standeredGroupFormat(composedID, (switchValuesFirst ? (i + 1) : i).toString(), fieldID); if (n.validate() != null) data = false;
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);
}
} }
return ui && data;
} }
void _checkSubgroups(String groupID, {Map<String, int>? parents}) { /// Validates a specific list of field [ids] and triggers their visual error states.
log('_checkSubgroups: groupID=$groupID, parents=$parents'); bool validateOnly(List<String> ids) {
// Get the group structure and check it's subGroups bool allValid = true;
FormGroupStructure? structure = getGroupStructure(groupID); for (var id in ids) {
if (structure!.subGroups != null && structure.subGroups!.isNotEmpty) { final node = _nodes[id];
for ((int, FormGroupStructure) sg in structure.subGroups!) { if (node == null) continue;
log('Processing subgroup ${sg.$2.id}');
// Get the SubGroup instances Count under this parent instance
int subgroupCount = getInstanceCount(sg.$2.id, parents: parents);
log('Subgroup count: $subgroupCount');
String prefix = parents?.entries.map((MapEntry<String, int> parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-') ?? '';
log('Prefix for subgroup: $prefix');
// Recurse through subGroups to remove their sub-subgroups final error = node.validate();
for (int sgInstance = 0; sgInstance < subgroupCount; sgInstance++) { if (error != null) allValid = false;
log('Recursing for sgInstance $sgInstance');
_checkSubgroups(
sg.$2.id,
parents: <String, int>{
...?parents,
sg.$2.id: sgInstance
},
);
}
// Remove controllers for these subgroups.
String composedID = prefix.isNotEmpty ? '$prefix-${sg.$2.id}' : sg.$2.id;
log('Removing controllers for $composedID, indices 0 to ${subgroupCount-1}');
_removeGroupControllers(composedID, List<int>.generate(subgroupCount, (int ii) => ii), sg.$2.fields, sg.$2.values ?? <String>[]);
}
} }
return allValid;
} }
/// Remove an instance from the group /// Exports the data of a specific group as a list of maps, including all nested subgroups.
void removeInstanceFromGroup(String targetGroupID, int indexToRemove, {Map<String, int>? parents}) { List<Map<String, dynamic>> getGroupData(String groupId, {String? parentUuid}) {
log('Removing instance $indexToRemove from group $targetGroupID with parents $parents'); final manifest = getManifest(groupId, parentUuid);
// Get the main targeted group's structure final List<String> uuids = manifest.value;
FormGroupStructure? targetedGroupStructure = getGroupStructure(targetGroupID); final def = _groupDefs[groupId]!;
assert(targetedGroupStructure != null, 'The Group $targetGroupID doesn\'t seem to be found, are you sure you initialized it?');
// Get the main targeted group's count return uuids.map((uuid) {
int targetedGroupCount = getInstanceCount(targetGroupID, parents: parents); final Map<String, dynamic> row = {};
log('Targeted group count: $targetedGroupCount');
assert(indexToRemove < targetedGroupCount, 'The index to remove is larger than the whole instances count. ($indexToRemove , $targetedGroupCount)');
if (indexToRemove == (targetedGroupCount - 1)) { for (var fieldName in def.schema.keys) {
log('Removing last item'); final fieldId = "${groupId}_${fieldName}_$uuid";
// Last Item in the group, Remove directly. row[fieldName] = get(fieldId);
_removeInstance(targetGroupID, indexToRemove, parents);
} else {
log('Removing middle item, shifting');
// Shift all subsequent instances down
for (int i = indexToRemove; i < targetedGroupCount - 1; i++) {
_copyInstance(targetGroupID, i + 1, i, parents);
}
// Then remove the last instance
_removeInstance(targetGroupID, targetedGroupCount - 1, parents);
}
}
(List<String> fields, List<String> values) _checkSubgroupsToValidate(String groupID, {Map<String, int>? parents}) {
FormGroupStructure? groupStructure = getGroupStructure(groupID);
String? prefix;
if (parents != null) {
prefix = parents.entries.map((MapEntry<String, int> parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-');
}
//
//
List<String> fieldsToValidate = <String>[];
List<String> valuesToValidate = <String>[];
for ((int, FormGroupStructure) sg in (groupStructure?.subGroups ?? <(int, FormGroupStructure)>[])) {
FormGroupStructure? subgroupStructure = getGroupStructure(sg.$2.id);
int sgInstances = getInstanceCount(sg.$2.id, parents: <String, int>{...?parents});
for (int i = 0; i < sgInstances; i++) {
_checkSubgroupsToValidate(
sg.$2.id,
parents: <String, int>{
...?parents,
...<String, int>{sg.$2.id: i}
},
);
for (String fieldID in subgroupStructure!.fields) {
fieldsToValidate.add(standeredGroupFormat((prefix != null ? '$prefix-' : '') + sg.$2.id, i.toString(), fieldID));
}
for (String valueID in (subgroupStructure.values ?? <String>[])) {
valuesToValidate.add(standeredGroupFormat((prefix != null ? '$prefix-' : '') + sg.$2.id, i.toString(), valueID));
}
}
}
return (fieldsToValidate, valuesToValidate);
}
/// Validate the group with ID.
bool validateGroup(String groupID) {
// Get tha main targeted group's structure
FormGroupStructure? groupStructure = getGroupStructure(groupID);
assert(groupStructure != null, 'The Group $groupID doesn\'t seem to be found, are you sure you initialized it?');
// Get current instances of this group and the new index.
int currentGroupInstances = getInstanceCount(groupID);
List<String> fieldsToValidate = <String>[];
List<String> valuesToValidate = <String>[];
for (int i = 0; i < currentGroupInstances; i++) {
for (String fieldID in groupStructure!.fields) {
fieldsToValidate.add(standeredGroupFormat(groupID, i.toString(), fieldID));
}
for (String valueID in (groupStructure.values ?? <String>[])) {
valuesToValidate.add(standeredGroupFormat(groupID, i.toString(), valueID));
} }
// Loop through the subgroups and get their fields and values for (var subGroupId in def.subGroups) {
(List<String> fields, List<String> values) sgRet = _checkSubgroupsToValidate(groupID, parents: <String, int>{groupID: i}); row[subGroupId] = getGroupData(subGroupId, parentUuid: uuid);
fieldsToValidate.addAll(sgRet.$1);
valuesToValidate.addAll(sgRet.$2);
} //
return validateOnly(fieldsToValidate) && validateValues(valuesToValidate);
}
//!SECTION
//SECTION - Helper Methods
String standeredGroupFormat(String groupID, String groupIndex, String? secondaryID) => '$groupID-#$groupIndex${secondaryID == null ? "" : "-$secondaryID"}';
// void _addInitialControllers(Map<String, (String, bool)>? initialValues) {
// if (initialValues != null) {
// // Add in the initial field states...
// fieldStates.addEntries(initialValues.entries.map((MapEntry<String, (String, bool)> e) => MapEntry<String, AstromicFieldState>(
// e.key, // controller ID
// AstromicFieldState.idle, // Initial state of any new controller is Idle
// )));
// // Add in the initial field messages...
// fieldMessages.addEntries(initialValues.entries.toList().map((MapEntry<String, (String, bool)> e) => MapEntry<String, String?>(
// e.key, // Controller ID
// null, // The initial message it has which is Null
// )));
// }
// }
// void _addInitialHostedValues(Map<String, (dynamic, bool)>? initialValues) {
// if (initialValues != null) {
// for (MapEntry<String, (dynamic, bool)> vEntry in initialValues.entries) {
// setValue(vEntry.key, vEntry.value.$1, isRequired: vEntry.value.$2);
// }
// }
// }
_initializeFormGroups(Map<String, InitialFormGroupValue> initVals, {Map<String, int>? parents}) {
log('Now we have groups, Initialize them with the parent: $parents');
for (MapEntry<String, InitialFormGroupValue> groupValueEntry in initVals.entries) {
String groupID = groupValueEntry.key;
InitialFormGroupValue groupValue = groupValueEntry.value;
for (int i = 0; i < groupValue.instancesCount; i++) {
int currentGroupIndex = i;
// Check for the subgroups
if (groupValueEntry.value.subGroups != null && groupValueEntry.value.subGroups!.isNotEmpty) {
// There are subgroups.
_initializeFormGroups(groupValueEntry.value.subGroups!, parents: <String, int>{...?parents, groupID: currentGroupIndex});
}
String? prefix = parents?.entries.map((MapEntry<String, int> parentEntry) => standeredGroupFormat(parentEntry.key, parentEntry.value.toString(), null)).join('-');
log('Got the prefix, it\'s $prefix');
// Fields
if (groupValue.fieldValues != null) {
for (MapEntry<String, List<String?>> fve in groupValue.fieldValues!.entries) {
assert(fve.value.length == groupValue.instancesCount, 'Your supplied list of `${fve.key}` is not `${groupValue.instancesCount}` as stated..');
if (groupValue.fieldObscurityValues != null) {
assert(groupValue.fieldObscurityValues!.length == groupValue.instancesCount, 'Your supplied obscurity list of `${fve.key}` is not `${groupValue.instancesCount}` as stated..');
}
String fieldKey = fve.key;
String fID = standeredGroupFormat((prefix != null ? '$prefix-' : '') + groupID, i.toString(), fieldKey);
String? fieldValue = fve.value[i] ?? '';
bool obscValue = groupValue.fieldObscurityValues?[fieldKey]?[i] ?? false;
log('Doing the controller $fID with initialText $fieldValue');
controller(fID, initialText: fieldValue, isObscure: obscValue);
}
}
// Values
if (groupValue.hostedValues != null) {
for (MapEntry<String, List<(dynamic, bool)>> hve in groupValue.hostedValues!.entries) {
assert(hve.value.length == groupValue.instancesCount, 'Your supplied list of `${hve.key}` is not `${groupValue.instancesCount}` as stated..');
String fieldKey = hve.key;
String fID = standeredGroupFormat((prefix != null ? '$prefix-' : '') + groupID, i.toString(), fieldKey);
dynamic fieldValue = hve.value[i].$1;
bool isReq = hve.value[i].$2;
log('Doing the controller $fID with initial Value $fieldValue');
setValue(fID, fieldValue, isRequired: isReq);
}
}
} }
}
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
} }

View File

@@ -1,104 +0,0 @@
//s1 Imports
//s2 Core Package Imports
import 'package:flutter/material.dart';
//s2 1st-party Package Imports
//s2 3rd-party Package Imports
//s2 Dependancies Imports
//s3 Routes
//s3 Services
//s3 Models & Widgets
//s1 Exports
class AstromicForm extends StatefulWidget {
//SECTION - Widget Arguments
//!SECTION
//
const AstromicForm({
super.key,
});
@override
State<AstromicForm> createState() => _AstromicFormState();
}
class _AstromicFormState extends State<AstromicForm> {
//
//SECTION - State Variables
//s1 --State
//s1 --State
//
//s1 --Controllers
//late AstromicFormController _formController;
//s1 --Controllers
//
//s1 --Constants
//s1 --Constants
//!SECTION
@override
void initState() {
super.initState();
//
//SECTION - State Variables initializations & Listeners
//s1 --State
//s1 --State
//
//s1 --Controllers & Listeners
// _formController = AstromicFormController();
//s1 --Controllers & Listeners
//
//s1 --Late & Async Initializers
//s1 --Late & Async Initializers
//!SECTION
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
//
//SECTION - State Variables initializations & Listeners
//s1 --State
//s1 --State
//
//s1 --Controllers & Listeners
//s1 --Controllers & Listeners
//
//!SECTION
}
//SECTION - Dumb Widgets
//!SECTION
//SECTION - Stateless functions
//!SECTION
//SECTION - Action Callbacks
//!SECTION
@override
Widget build(BuildContext context) {
//SECTION - Build Setup
//s1 --Values
//double w = MediaQuery.of(context).size.width;
//double h = MediaQuery.of(context).size.height;
//s1 --Values
//
//s1 --Contexted Widgets
//s1 --Contexted Widgets
//!SECTION
//SECTION - Build Return
return Scaffold(
body: Container(),
);
//!SECTION
}
@override
void dispose() {
//SECTION - Disposable variables
//!SECTION
super.dispose();
}
}

View File

@@ -1,322 +0,0 @@
//s1 Imports
//s2 Core Package Imports
import 'package:flutter/widgets.dart';
//s2 1st-party Package Imports
import 'package:astromic_elements/astromic_elements.dart';
import 'package:form_controller/form_controller.dart';
//s2 3rd-party Package Imports
//s2 Dependancies Imports
//s3 Routes
//s3 Services
//s3 Models & Widgets
import 'controller.dart';
import 'enums/enums.exports.dart';
//s1 Exports
// ignore: must_be_immutable
class AstromicFormField<T> extends StatefulWidget {
//SECTION - Widget Arguments
//S1 -- Shared
final AstromicFormController formController;
final String formID;
final bool? initialObscurity;
//
final AstromicFieldConfiguration? configuration;
final List<FormControllerValidator>? validators;
final bool? resetMessageOnChange;
final Map<String, String?>? errorsCatcher;
//
final AstromicFieldStyle Function(bool isEnabled, bool isFocused, AstromicFieldState state)? style;
//
final String? hint;
final Widget? Function(bool isEnabled, bool isFocused, VoidCallback stateSetter, AstromicFieldState state)? prefixWidget;
final Widget? Function(bool isEnabled, bool isFocused, VoidCallback stateSetter, AstromicFieldState state)? suffixWidget;
final Widget? Function(bool isEnabled, bool isFocused, AstromicFieldState state, String? message)? messageBuilder;
//S1 -- Text Specific
String? initialText;
void Function(String v, AstromicFieldState state)? onChanged;
void Function(String v, AstromicFieldState state)? onSubmited;
Iterable<ContextMenuButtonItem>? contextButtons;
//S1 -- Action Specific
(T item, String label)? initialValue;
Future<(T item, String label)?> Function((T item, String label)? currentValue)? onTap;
Future<(T item, String label)?> Function((T item, String label)? currentValue)? onHold;
String Function(String? oldValue, String newValue)? onValueChangedMapper;
//!SECTION
AstromicFormField.text({
required this.formController,
required this.formID,
this.initialObscurity,
this.configuration,
this.validators,
this.resetMessageOnChange,
this.errorsCatcher,
this.style,
this.hint,
this.prefixWidget,
this.suffixWidget,
this.messageBuilder,
//
this.initialText,
this.onChanged,
this.onSubmited,
this.contextButtons,
});
AstromicFormField.action({
required this.formController,
required this.formID,
this.initialObscurity,
this.configuration,
this.validators,
this.resetMessageOnChange,
this.errorsCatcher,
this.style,
this.hint,
this.prefixWidget,
this.suffixWidget,
this.messageBuilder,
//
this.initialValue,
this.onTap,
this.onHold,
this.onValueChangedMapper,
});
@override
State<AstromicFormField<T>> createState() => _AstromicFormFieldState<T>();
}
class _AstromicFormFieldState<T> extends State<AstromicFormField<T>> {
//
//SECTION - State Variables
//s1 --State
late AstromicFieldState _currentState;
//s1 --State
//
//s1 --Controllers
late TextEditingController _controller;
//s1 --Controllers
//
//s1 --Constants
//s1 --Constants
//!SECTION
@override
void initState() {
super.initState();
//
//SECTION - State Variables initializations & Listeners
//s1 --State
_currentState = AstromicFieldState.idle;
//s1 --State
//
//s1 --Controllers & Listeners
// Set the textEditingController for this field
_controller = widget.formController.controller(widget.formID, initialText: widget.initialText, isObscure: widget.initialObscurity ?? false);
// Listen to the state stream for updated form states...
widget.formController.stateStream.listen(((String, AstromicFieldState) newState) {
if (mounted && widget.formID == newState.$1) {
setState(() {
_currentState = newState.$2;
});
}
});
// Listen to the error stream for updated errors and set the field's state accordingly...
if (widget.formController.errorStream != null) {
widget.formController.errorStream!.listen((List<(String internalCode, String? message)> errorCodes) {
if (widget.errorsCatcher != null) {
// fieldId: {errorCode: errorMessage}
for (String toBeCaughtInternalErrorCode in widget.errorsCatcher!.keys.toList()) {
if (errorCodes.map(((String, String?) x) => x.$1).contains(toBeCaughtInternalErrorCode)) {
if (mounted) {
setState(() {
_setFieldErrorState(widget.formID,
errorCodes.where(((String, String?) c) => c.$1 == toBeCaughtInternalErrorCode).first.$2 ?? widget.errorsCatcher![toBeCaughtInternalErrorCode] ?? 'Undefined Error Message');
});
}
}
}
}
//
});
}
//s1 --Controllers & Listeners
//
//s1 --Late & Async Initializers
//s1 --Late & Async Initializers
//!SECTION
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
//
//SECTION - State Variables initializations & Listeners
//s1 --State
//s1 --State
//
//s1 --Controllers & Listeners
//s1 --Controllers & Listeners
//
//!SECTION
}
//SECTION - Dumb Widgets
//!SECTION
//SECTION - Stateless functions
_setFieldErrorState(String id, String? message) {
widget.formController.setState(id, AstromicFieldState.withError, message: message);
}
//!SECTION
//SECTION - Action Callbacks
//!SECTION
@override
Widget build(BuildContext context) {
//SECTION - Build Setup
//s1 --Values
//double w = MediaQuery.of(context).size.width;
//double h = MediaQuery.of(context).size.height;
//s1 --Values
//
//s1 --Contexted Widgets
Widget buildText() {
return AstromicFields.text(
stateKey: widget.formController.fieldStateKey(widget.formID),
controller: _controller,
onChanged: (String s) {
setState(() {
if (widget.resetMessageOnChange ?? false) {
widget.formController.resetState(widget.formID);
}
if (widget.onChanged != null) {
widget.onChanged!(s, _currentState);
}
});
},
onSubmited: (String s) {
if (widget.onSubmited != null) {
widget.onSubmited!(s, _currentState);
}
},
configuration: widget.configuration?.copyWith(
isTextObscured: widget.formController.isObscure(widget.formID),
validator: (widget.validators != null && widget.validators!.isNotEmpty)
? (bool enabled, bool focused, String? s) {
widget.formController.resetState(widget.formID);
//
List<FormControllerValidator> validators = <FormControllerValidator>[];
//
validators.addAll(widget.validators!);
//
Map<FormControllerValidator, bool> checks = <FormControllerValidator, bool>{};
//
for (FormControllerValidator validator in validators) {
bool res = validator.checker(s);
checks.addEntries(<MapEntry<FormControllerValidator, bool>>[MapEntry<FormControllerValidator, bool>(validator, res)]);
}
//
if (checks.containsValue(false)) {
// It has an Error!
_setFieldErrorState(widget.formID, checks.entries.where((MapEntry<FormControllerValidator, bool> e) => e.value == false).toList().first.key.message);
return '';
} else {
// It has no Errors!
return null;
}
}
: null,
),
style: (bool enabled, bool focused) => widget.style != null ? widget.style!(enabled, focused, _currentState) : const AstromicFieldStyle(),
//
contextButtons: widget.contextButtons,
//
hint: widget.hint,
prefixWidget: widget.prefixWidget != null ? (bool enabled, bool focused, void Function() setter) => widget.prefixWidget!(enabled, focused, setter, _currentState) : null,
suffixWidget: widget.suffixWidget != null ? (bool enabled, bool focused, void Function() setter) => widget.suffixWidget!(enabled, focused, setter, _currentState) : null,
messageBuilder:
widget.messageBuilder != null ? (bool enabled, bool focused) => widget.messageBuilder!(enabled, focused, _currentState, widget.formController.getState(widget.formID)?.$2) : null,
);
}
Widget buildAction() {
return AstromicFields.action(
stateKey: widget.formController.fieldStateKey(widget.formID),
controller: _controller,
initialValue: widget.initialValue,
onTap: widget.onTap != null
? ((T, String)? currentValue) {
if (widget.resetMessageOnChange ?? false) {
widget.formController.resetState(widget.formID);
}
return widget.onTap!(currentValue);
}
: null,
onHold: widget.onHold,
onValueChangedMapper: widget.onValueChangedMapper!,
//
style: (bool enabled) => widget.style != null ? widget.style!(enabled, false, _currentState) : const AstromicFieldStyle(),
configuration: widget.configuration?.copyWith(
isTextObscured: widget.formController.isObscure(widget.formID),
validator: (widget.validators != null && widget.validators!.isNotEmpty)
? (bool enabled, bool focused, String? s) {
widget.formController.resetState(widget.formID);
//
List<FormControllerValidator> validators = <FormControllerValidator>[];
//
validators.addAll(widget.validators!);
//
Map<FormControllerValidator, bool> checks = <FormControllerValidator, bool>{};
//
for (FormControllerValidator validator in validators) {
bool res = validator.checker(s);
checks.addEntries(<MapEntry<FormControllerValidator, bool>>[MapEntry<FormControllerValidator, bool>(validator, res)]);
}
//
if (checks.containsValue(false)) {
// It has an Error!
_setFieldErrorState(widget.formID, checks.entries.where((MapEntry<FormControllerValidator, bool> e) => e.value == false).toList().first.key.message);
return '';
} else {
// It has no Errors!
return null;
}
}
: null,
),
//
hint: widget.hint,
prefixWidget: widget.prefixWidget != null ? (bool enabled, void Function() setter) => widget.prefixWidget!(enabled, false, setter, _currentState) : null,
suffixWidget: widget.suffixWidget != null ? (bool enabled, void Function() setter) => widget.suffixWidget!(enabled, false, setter, _currentState) : null,
messageBuilder: widget.messageBuilder != null ? (bool enabled) => widget.messageBuilder!(enabled, false, _currentState, widget.formController.getState(widget.formID)?.$2) : null,
//
);
}
//s1 --Contexted Widgets
//!SECTION
//SECTION - Build Return
return widget.onValueChangedMapper != null
?
// Is Action Field
buildAction()
:
// Is Text Field
buildText();
//!SECTION
}
@override
void dispose() {
//SECTION - Disposable variables
// _controller.dispose();
//!SECTION
super.dispose();
}
}

View File

@@ -1,133 +0,0 @@
//s1 Imports
//s2 Packages
//s3 Core Packages
import 'package:flutter/material.dart';
//s3 Internal Packages
//s3 3rd-party Packages
//s2 Utility
//s3 Configs
//s3 Misc
//s2 Domain
//s3 Entities
import 'controller.dart';
import 'models/models.exports.dart';
//s3 Usecases
//s1 Exports
class FormGroupWrapper extends StatefulWidget {
//SECTION - Widget Arguments
final AstromicFormController formController;
final String groupID;
final Widget Function(List<Widget> children, String Function() addItem, void Function(int) removeItem) groupBuilder;
final Widget Function(int index, String composedID, VoidCallback removeItem) itemBuilder;
final Map<String, int>? parents;
//!SECTION
//
const FormGroupWrapper({
super.key,
required this.formController,
required this.groupID,
required this.groupBuilder,
required this.itemBuilder,
this.parents,
});
@override
State<FormGroupWrapper> createState() => _FormGroupWrapperState();
}
class _FormGroupWrapperState extends State<FormGroupWrapper> {
//
//SECTION - State Variables
//s1 --State
late List<FormGroupInstance> instances;
//s1 --State
//
//s1 --Controllers
//s1 --Controllers
//
//s1 --Constants
//s1 --Constants
//!SECTION
@override
void initState() {
super.initState();
//
//SECTION - State Variables initializations & Listeners
//s1 --State
//s1 --State
//
//s1 --Controllers & Listeners
instances = widget.formController.getFormGroupValue(widget.groupID, parents: widget.parents)!.instances;
// LoggingService.log("instances: $instances");
//s1 --Controllers & Listeners
//
//s1 --Late & Async Initializers
//s1 --Late & Async Initializers
//!SECTION
}
//SECTION - Dumb Widgets
//!SECTION
//SECTION - Stateless functions
//!SECTION
//SECTION - Action Callbacks
//!SECTION
@override
Widget build(BuildContext context) {
//SECTION - Build Setup
//s1 --Values
//double w = MediaQuery.of(context).size.width;
//double h = MediaQuery.of(context).size.height;
//s1 --Values
//
//s1 --Contexted Widgets
//s1 --Contexted Widgets
//!SECTION
//SECTION - Build Return
return widget.groupBuilder(
// Children
List<Widget>.generate(
instances.length,
(int i) => widget.itemBuilder(i, instances[i].composedID, () {
setState(() {
_removeItem(i);
instances = widget.formController.getFormGroupValue(widget.groupID, parents: widget.parents)!.instances;
});
})),
// Add Callback
() {
String id = '';
setState(() {
id = widget.formController.addInstanceToFormGroup(widget.groupID, parents: widget.parents);
instances = widget.formController.getFormGroupValue(widget.groupID, parents: widget.parents)!.instances;
});
return id;
},
// Remove Callback
(int i) {
setState(() {
_removeItem(i);
instances = widget.formController.getFormGroupValue(widget.groupID, parents: widget.parents)!.instances;
});
},
);
//!SECTION
}
void _removeItem(int i) {
widget.formController.removeInstanceFromGroup(widget.groupID, i, parents: widget.parents);
}
@override
void dispose() {
//SECTION - Disposable variables
//!SECTION
super.dispose();
}
}

View File

@@ -1,127 +0,0 @@
//s1 Imports
//s2 Packages
//s3 Core Packages
import 'package:flutter/material.dart';
import '../../sheet/sheet_helper.astromic.dart';
//s3 Internal Packages
//s3 3rd-party Packages
//s2 Utility
//s3 Configs
//s3 Misc
//s2 Domain
//s3 Entities
//s3 Usecases
//s2 Presentation
//s3 Design
//s3 Presenters
//s3 Widgets
//s1 Exports
class FormValueWrapper<T extends Object?> extends StatefulWidget {
//SECTION - Widget Arguments
final AstromicFormController controller;
final String id;
final bool isRequired;
final Widget Function(T? value, bool isErroredForValidation, void Function(T? value) valueSetter, VoidCallback valueClear) builder;
//!SECTION
//
const FormValueWrapper({
super.key,
required this.controller,
required this.id,
required this.isRequired,
required this.builder,
});
@override
State<FormValueWrapper<T>> createState() => _FormValueWrapperState<T>();
}
class _FormValueWrapperState<T> extends State<FormValueWrapper<T>> {
//
//SECTION - State Variables
//s1 --State
//s1 --State
//
//s1 --Controllers
// late AstromicFormController _formController;
//s1 --Controllers
//
//s1 --Constants
//s1 --Constants
//!SECTION
@override
void initState() {
super.initState();
//
//SECTION - State Variables initializations & Listeners
//s1 --State
//s1 --State
//
//s1 --Controllers & Listeners
if (!widget.controller.allValues.keys.contains(widget.id)) {
widget.controller.setValue<T>(widget.id, null, isRequired: widget.isRequired);
}
//s1 --Controllers & Listeners
//
//s1 --Late & Async Initializers
//s1 --Late & Async Initializers
//!SECTION
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
//
//SECTION - State Variables initializations & Listeners
//s1 --State
//s1 --State
//
//s1 --Controllers & Listeners
//s1 --Controllers & Listeners
//
//!SECTION
}
//SECTION - Dumb Widgets
//!SECTION
//SECTION - Stateless functions
//!SECTION
//SECTION - Action Callbacks
//!SECTION
@override
Widget build(BuildContext context) {
//SECTION - Build Setup
//s1 --Values
//double w = MediaQuery.of(context).size.width;
//double h = MediaQuery.of(context).size.height;
//s1 --Values
//
//s1 --Contexted Widgets
//s1 --Contexted Widgets
//!SECTION
//SECTION - Build Return
return StreamBuilder<(String, bool)>(
stream: widget.controller.hostedValueValidationStream,
builder: (BuildContext context, AsyncSnapshot<(String, bool)> validationSnapshot) {
return widget.builder(widget.controller.getValue<T>(widget.id),
validationSnapshot.hasData && validationSnapshot.data != null && validationSnapshot.data!.$1 == widget.id && validationSnapshot.data!.$2 ? true : false, (T? newValue) {
return widget.controller.setValue(widget.id, newValue);
}, () => widget.controller.removeValue(widget.id));
});
//!SECTION
}
@override
void dispose() {
//SECTION - Disposable variables
//!SECTION
super.dispose();
}
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import '../controller.dart';
import '../models/form_group_definition.dart';
/// A fluent API handler for managing a specific group instance within the [AstromicFormController].
class AstromicGroupHandler {
AstromicGroupHandler(this._ctrl, this._groupId, [this._parentUuid]);
final AstromicFormController _ctrl;
final String _groupId;
final String? _parentUuid;
int get count => _ids.value.length;
ValueNotifier<List<String>> get _ids => _ctrl.groupIds(_groupId, _parentUuid);
/// Registers a new instance within this group and optionally hydrates it with [initialData].
///
/// Returns the unique UUID generated for the new instance.
String add({Map<String, dynamic>? data}) {
return _ctrl.addGroupInstance(_groupId, parentUuid: _parentUuid, initialData: data);
}
/// Permanently removes an instance from the group by its [uuid] and disposes of associated nodes.
void remove(String uuid) {
_ctrl.removeGroupInstance(_groupId, uuid, parentUuid: _parentUuid);
}
/// Navigates to a nested subgroup belonging to a specific parent instance.
AstromicGroupHandler subGroup(String subGroupId, String currentInstanceUuid) {
return AstromicGroupHandler(_ctrl, subGroupId, currentInstanceUuid);
}
/// Generates the deterministic unique identifier for a field within a specific group instance.
String getFieldId(String instanceUuid, String fieldName) {
return '${_groupId}_${fieldName}_$instanceUuid';
}
/// Retrieves the [TextEditingController] associated with a specific field in a group instance.
TextEditingController field(String uuid, String fieldName) {
return _ctrl.controller('${_groupId}_${fieldName}_$uuid');
}
/// Retrieves the [FieldNode] for a specific field, allowing for reactive type-safe data access.
ValueNotifier<T> node<T>(String uuid, String fieldName) {
return _ctrl.node<T>('${_groupId}_${fieldName}_$uuid');
}
/// Performs validation on every registered field within all instances of this specific group.
///
/// Returns `true` if all fields pass their respective validation contracts.
bool validate() {
final def = _ctrl.groupDefs[_groupId];
if (def == null) return true;
final activeUuids = _ids.value;
final List<String> idsToValidate = [];
for (var uuid in activeUuids) {
for (var fieldName in def.schema.keys) {
idsToValidate.add('${_groupId}_${fieldName}_$uuid');
}
}
return _ctrl.validateOnly(idsToValidate);
}
}
/// A reactive widget that builds a dynamic list of items based on a [FormGroupDefinition].
class AstromicGroupBuilder extends StatelessWidget {
const AstromicGroupBuilder({
super.key,
required this.controller,
required this.builder,
this.group,
this.definition,
this.parentId,
}) : assert(group != null || definition != null, "You must provide either a 'group' ID or a 'definition'.");
final AstromicFormController controller;
final String? group;
final AstromicFormGroupDefinition? definition;
final String? parentId;
final Widget Function(BuildContext context, List<String> items, AstromicGroupHandler handler) builder;
@override
Widget build(BuildContext context) {
if (definition != null) {
controller.registerGroup(definition!);
}
final String effectiveGroupId = group ?? definition!.id;
final handler = AstromicGroupHandler(controller, effectiveGroupId, parentId);
return ValueListenableBuilder<List<String>>(
valueListenable: handler._ids,
builder: (context, ids, _) {
return builder(context, ids, handler);
},
);
}
}
/// Extensions to provide cleaner syntax for accessing group features from the controller.
extension AstromicGroupHandlerExt on AstromicFormController {
/// Provides an [AstromicGroupHandler] for the specified group identifier.
AstromicGroupHandler group(String id) => AstromicGroupHandler(this, id);
/// Retrieves the reactive list of instance UUIDs for a given group and parent context.
ValueNotifier<List<String>> groupIds(String groupId, String? parentUuid) {
return getManifest(groupId, parentUuid);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:form_controller/form_controller.dart' show FormControllerValidator;
/// Configuration for a single field within a [FormGroupDefinition].
///
/// Defines the type [T], the initial value, and the logic for parsing, formatting, and validation.
class AstromicFieldConfig<T> {
AstromicFieldConfig({
this.initialValue,
this.formatter,
this.parser,
this.validator,
this.validators,
});
final T? initialValue;
final String Function(T)? formatter;
final T? Function(String)? parser;
final FormControllerValidator? validator;
final List<FormControllerValidator>? validators;
/// Captures the specific type [T] and invokes the [creator] callback to register the field.
///
/// The [key] serves as the field identifier, [value] as the initial data, and [creator]
/// as the factory function that handles the underlying node registration.
void register(String key, T value, dynamic Function<V>(String, AstromicFieldConfig<V>, V) creator) {
creator<T>(key, this, value);
}
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/widgets.dart';
import 'package:form_controller/form_controller.dart' show FormControllerValidator;
import '../enums/enums.exports.dart' show AstromicFieldState;
/// A reactive data node that holds a single value of type [T] and manages its synchronization with the UI.
///
/// The [AstromicFieldNode] acts as a bridge between the raw data (e.g., `double`, `DateTime`, `bool`)
/// and the string-based input expected by Flutter's [TextEditingController].
///
/// It extends [ValueNotifier], allowing it to be used with [ValueListenableBuilder] for non-text inputs like
/// switches, checkboxes, or custom pickers.
class AstromicFieldNode<T> extends ValueNotifier<T> {
AstromicFieldNode(
super.value, {
required this.formatter,
required this.parser,
this.validators = const <FormControllerValidator>[],
});
/// Transforms the stored value [T] into a [String] for display in the UI.
String Function(T) formatter;
/// Transforms user input [String] back into the stored value [T].
T? Function(String) parser;
/// Internal list of validators used for business logic and UI feedback.
List<FormControllerValidator> validators;
/// Notifies listeners of changes in the field's visual or interaction state (e.g., idle, focused, error).
final ValueNotifier<AstromicFieldState> state = ValueNotifier(AstromicFieldState.idle);
/// Holds the current error message or feedback string to be displayed in the UI.
final ValueNotifier<String?> message = ValueNotifier(null);
TextEditingController? _uiController;
/// Returns a [TextEditingController] synchronized with this node's value.
///
/// Updates [value] when the text changes via [parser], and updates the text
/// when [value] changes programmatically via [formatter].
TextEditingController get syncedController {
if (_uiController != null) return _uiController!;
_uiController = TextEditingController(text: formatter(value));
_uiController!.addListener(() {
final newString = _uiController!.text;
final parsedValue = parser(newString);
if (state.value == AstromicFieldState.withError) {
state.value = AstromicFieldState.idle;
message.value = null;
}
if (parsedValue != value) {
if (parsedValue != null) {
value = parsedValue as T;
} else if (T == String) {
value = newString as T;
}
}
});
addListener(() {
final newString = formatter(value);
if (parser(_uiController!.text) != value) {
_uiController!.text = newString;
_uiController!.selection = TextSelection.fromPosition(
TextPosition(offset: _uiController!.text.length),
);
}
});
return _uiController!;
}
/// Programmatically updates the [formatter] and [parser] logic for this node.
void setConfiguration({
String Function(T value)? formatter,
T? Function(String value)? parser,
}) {
if (formatter != null) this.formatter = formatter;
if (parser != null) this.parser = parser;
if (_uiController != null) {
final newText = this.formatter(value);
if (_uiController!.text != newText) {
_uiController!.text = newText;
}
}
}
/// Sets the current visual [state] and [message] for the field.
void setState(AstromicFieldState newState, {String? newMessage}) {
state.value = newState;
message.value = newMessage;
}
/// Clears error messages and returns the field state to [AstromicFieldState.idle].
void resetState() {
state.value = AstromicFieldState.idle;
message.value = null;
}
/// Executes all attached [validators] against the current [value].
///
/// Returns the first error message encountered, or `null` if the value is valid.
/// Automatically updates the [state] and [message] listeners.
String? validate({List<FormControllerValidator>? extraValidators}) {
String? error;
for (var v in validators) {
if (!v.checker(formatter(value))) {
error = v.message;
break;
}
}
if (error == null && extraValidators != null) {
for (var v in extraValidators) {
if (!v.checker(formatter(value))) {
error = v.message;
break;
}
}
}
if (error != null) {
setState(AstromicFieldState.withError, newMessage: error);
} else {
resetState();
}
return error;
}
/// Disposes the associated [TextEditingController] to free up memory resources.
void disposeUI() {
_uiController?.dispose();
_uiController = null;
}
}

View File

@@ -0,0 +1,26 @@
import 'field_config.dart';
/// Defines the structure of a dynamic group (e.g., "Projects", "Experience").
///
/// Contains the [schema] for its fields and a list of nested [subGroups].
class AstromicFormGroupDefinition {
/// Creates a definition for a form group.
AstromicFormGroupDefinition({
required this.id,
required this.schema,
this.subGroups = const [],
});
/// The unique identifier for this group type (e.g., 'projects').
final String id;
/// A map defining the fields in this group.
///
/// Key: Field name (e.g., 'title').
/// Value: Configuration defining the type and behavior.
// ignore: strict_raw_type
final Map<String, AstromicFieldConfig> schema;
/// A list of identifiers for nested subgroups that belong to this group.
final List<String> subGroups;
}

View File

@@ -1,73 +0,0 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'form_group_value.model.dart';
class FormGroupInstance {
String composedID;
Map<String, String> fields;
Map<String, dynamic> values;
List<FormGroupValue>? subGroups;
FormGroupInstance({
required this.composedID,
required this.fields,
required this.values,
this.subGroups,
});
FormGroupInstance copyWith({
String? composedID,
Map<String, String>? fields,
Map<String, dynamic>? values,
List<FormGroupValue>? subGroups,
}) {
return FormGroupInstance(
composedID: composedID ?? this.composedID,
fields: fields ?? this.fields,
values: values ?? this.values,
subGroups: subGroups ?? this.subGroups,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'composedID': composedID,
'fields': fields,
'values': values,
'subGroups': subGroups?.map((FormGroupValue x) => x.toMap()).toList(),
};
}
factory FormGroupInstance.fromMap(Map<String, dynamic> map) {
return FormGroupInstance(
composedID: map['composedID'],
fields: Map<String, String>.from(map['fields'] as Map<String, String>),
values: Map<String, dynamic>.from(map['values'] as Map<String, dynamic>),
subGroups: map['subGroups'] != null
? List<FormGroupValue>.from(
(map['subGroups'] as List<int>).map<FormGroupValue?>(
(int x) => FormGroupValue.fromMap(x as Map<String, dynamic>),
),
)
: null,
);
}
String toJson() => json.encode(toMap());
factory FormGroupInstance.fromJson(String source) => FormGroupInstance.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'FormGroupInstance(composedID: $composedID, fields: $fields, values: $values, subGroups: $subGroups)';
@override
bool operator ==(covariant FormGroupInstance other) {
if (identical(this, other)) return true;
return mapEquals(other.fields, fields) && mapEquals(other.values, values) && listEquals(other.subGroups, subGroups) && composedID == other.composedID;
}
@override
int get hashCode => composedID.hashCode ^ fields.hashCode ^ values.hashCode ^ subGroups.hashCode;
}

View File

@@ -1,73 +0,0 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
class FormGroupStructure {
final String id;
final List<String> fields;
final List<String>? values;
final List<(int initialCount, FormGroupStructure structure)>? subGroups;
FormGroupStructure({
required this.id,
required this.fields,
this.values,
this.subGroups,
});
FormGroupStructure copyWith({
String? id,
List<String>? fields,
Map<String, String>? preFields,
List<String>? values,
Map<String, (String, dynamic)>? preValues,
List<(int initialCount, FormGroupStructure structure)>? subGroups,
}) {
return FormGroupStructure(
id: id ?? this.id,
fields: fields ?? this.fields,
values: values ?? this.values,
subGroups: subGroups ?? this.subGroups,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'id': id,
'fields': fields,
'values': values,
'subGroups': subGroups?.map(((int initialCount, FormGroupStructure structure) x) => <String, dynamic>{'structure': x.$2.toMap(), 'initialCount': x.$1}).toList(),
};
}
factory FormGroupStructure.fromMap(Map<String, dynamic> map) {
return FormGroupStructure(
id: map['id'] as String,
fields: List<String>.from(map['fields'] as List<String>),
values: map['values'] != null ? List<String>.from(map['values'] as List<String>) : null,
subGroups: map['subGroups'] != null
? (map['subGroups'] as List<Map<String, dynamic>>).map((Map<String, dynamic> map) => (int.tryParse(map['initialCount']) ?? 0, FormGroupStructure.fromMap(map['structure']))).toList()
: null,
);
}
String toJson() => json.encode(toMap());
factory FormGroupStructure.fromJson(String source) => FormGroupStructure.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() {
return 'FormGroupStructure(id: $id, fields: $fields, values: $values, subGroups: $subGroups)';
}
@override
bool operator ==(covariant FormGroupStructure other) {
if (identical(this, other)) return true;
return other.id == id && listEquals(other.fields, fields) && listEquals(other.values, values) && listEquals(other.subGroups, subGroups);
}
@override
int get hashCode {
return id.hashCode ^ fields.hashCode ^ values.hashCode ^ subGroups.hashCode;
}
}

View File

@@ -1,65 +0,0 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'form_group_instance.model.dart';
class FormGroupValue {
final String groupID;
final int instancesCount;
final List<FormGroupInstance> instances;
FormGroupValue({
required this.groupID,
required this.instancesCount,
required this.instances,
});
FormGroupValue copyWith({
String? groupID,
int? instancesCount,
List<FormGroupInstance>? instances,
}) {
return FormGroupValue(
groupID: groupID ?? this.groupID,
instancesCount: instancesCount ?? this.instancesCount,
instances: instances ?? this.instances,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'groupID': groupID,
'instancesCount': instancesCount,
'instances': instances.map((FormGroupInstance x) => x.toMap()).toList(),
};
}
factory FormGroupValue.fromMap(Map<String, dynamic> map) {
return FormGroupValue(
groupID: map['groupID'] as String,
instancesCount: map['instancesCount'] as int,
instances: List<FormGroupInstance>.from((map['instances'] as List<int>).map<FormGroupInstance>((int x) => FormGroupInstance.fromMap(x as Map<String,dynamic>),),),
);
}
String toJson() => json.encode(toMap());
factory FormGroupValue.fromJson(String source) => FormGroupValue.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'FromGroupValue(groupID: $groupID, instancesCount: $instancesCount, instances: $instances)';
@override
bool operator ==(covariant FormGroupValue other) {
if (identical(this, other)) return true;
return
other.groupID == groupID &&
other.instancesCount == instancesCount &&
listEquals(other.instances, instances);
}
@override
int get hashCode => groupID.hashCode ^ instancesCount.hashCode ^ instances.hashCode;
}

View File

@@ -1,80 +0,0 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:flutter/foundation.dart';
class InitialFormGroupValue {
final int instancesCount;
final Map<String, List<String?>>? fieldValues;
final Map<String, List<bool>>? fieldObscurityValues;
final Map<String, List<(dynamic, bool)>>? hostedValues;
final Map<String, InitialFormGroupValue>? subGroups;
InitialFormGroupValue({
required this.instancesCount,
this.fieldValues,
this.fieldObscurityValues,
this.hostedValues,
this.subGroups,
});
InitialFormGroupValue copyWith({
int? instancesCount,
Map<String, List<String?>>? fieldValues,
Map<String, List<bool>>? fieldObscurityValues,
Map<String, List<(dynamic, bool)>>? hostedValues,
Map<String, InitialFormGroupValue>? subGroups,
}) {
return InitialFormGroupValue(
instancesCount: instancesCount ?? this.instancesCount,
fieldValues: fieldValues ?? this.fieldValues,
fieldObscurityValues: fieldObscurityValues ?? this.fieldObscurityValues,
hostedValues: hostedValues ?? this.hostedValues,
subGroups: subGroups ?? this.subGroups,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'instancesCount': instancesCount,
'fieldValues': fieldValues,
'fieldObscurityValues': fieldObscurityValues,
'hostedValues': hostedValues,
'subGroups': subGroups,
};
}
factory InitialFormGroupValue.fromMap(Map<String, dynamic> map) {
return InitialFormGroupValue(
instancesCount: map['instancesCount'] as int,
fieldValues: map['fieldValues'] != null ? Map<String, List<String?>>.from(map['fieldValues'] as Map<String, List<String?>>) : null,
fieldObscurityValues: map['fieldObscurityValues'] != null ? Map<String, List<bool>>.from(map['fieldObscurityValues'] as Map<String, List<bool>>) : null,
hostedValues: map['hostedValues'] != null ? Map<String, List<(dynamic, bool)>>.from(map['hostedValues'] as Map<String, List<(dynamic, bool)>>) : null,
subGroups: map['subGroups'] != null ? Map<String, InitialFormGroupValue>.from(map['subGroups'] as Map<String, InitialFormGroupValue>) : null,
);
}
String toJson() => json.encode(toMap());
factory InitialFormGroupValue.fromJson(String source) => InitialFormGroupValue.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() {
return 'InitialFormGroupValue(instancesCount: $instancesCount, fieldValues: $fieldValues, fieldObscurityValues: $fieldObscurityValues, hostedValues: $hostedValues, subGroups: $subGroups)';
}
@override
bool operator ==(covariant InitialFormGroupValue other) {
if (identical(this, other)) return true;
return other.instancesCount == instancesCount &&
mapEquals(other.fieldValues, fieldValues) &&
mapEquals(other.fieldObscurityValues, fieldObscurityValues) &&
mapEquals(other.hostedValues, hostedValues) &&
mapEquals(other.subGroups, subGroups);
}
@override
int get hashCode {
return instancesCount.hashCode ^ fieldValues.hashCode ^ fieldObscurityValues.hashCode ^ hostedValues.hashCode ^ subGroups.hashCode;
}
}

View File

@@ -1,78 +0,0 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'initial_form_group_values.model.dart';
class AstromicFormInitialValues {
// Field Values
final Map<String, String?>? fieldValues;
final Map<String, bool>? fieldObscurityValues;
// Hosted Values
final Map<String, (dynamic, bool)>? hostedValues;
// Form Groups
final Map<String, InitialFormGroupValue>? groupValues;
AstromicFormInitialValues({
this.fieldValues,
this.fieldObscurityValues,
this.hostedValues,
this.groupValues,
});
AstromicFormInitialValues copyWith({
Map<String, String?>? fieldValues,
Map<String, bool>? fieldObscurityValues,
Map<String, (dynamic, bool)>? hostedValues,
Map<String, InitialFormGroupValue>? groupValues,
}) {
return AstromicFormInitialValues(
fieldValues: fieldValues ?? this.fieldValues,
fieldObscurityValues: fieldObscurityValues ?? this.fieldObscurityValues,
hostedValues: hostedValues ?? this.hostedValues,
groupValues: groupValues ?? this.groupValues,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'fieldValues': fieldValues,
'fieldObscurityValues': fieldObscurityValues,
'hostedValues': hostedValues,
'groupValues': groupValues,
};
}
factory AstromicFormInitialValues.fromMap(Map<String, dynamic> map) {
return AstromicFormInitialValues(
fieldValues: map['fieldValues'] != null ? Map<String, String?>.from(map['fieldValues'] as Map<String, String?>) : null,
fieldObscurityValues: map['fieldObscurityValues'] != null ? Map<String, bool>.from(map['fieldObscurityValues'] as Map<String, bool>) : null,
hostedValues: map['hostedValues'] != null ? Map<String, (dynamic, bool)>.from(map['hostedValues'] as Map<String, dynamic>) : null,
groupValues: map['groupValues'] != null ? Map<String, InitialFormGroupValue>.from(map['groupValues'] as Map<String, InitialFormGroupValue>) : null,
);
}
String toJson() => json.encode(toMap());
factory AstromicFormInitialValues.fromJson(String source) => AstromicFormInitialValues.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() {
return 'AstromicFormInitialValues(fieldValues: $fieldValues, fieldObscurityValues: $fieldObscurityValues, hostedValues: $hostedValues, groupValues: $groupValues)';
}
@override
bool operator ==(covariant AstromicFormInitialValues other) {
if (identical(this, other)) return true;
return mapEquals(other.fieldValues, fieldValues) &&
mapEquals(other.fieldObscurityValues, fieldObscurityValues) &&
mapEquals(other.hostedValues, hostedValues) &&
mapEquals(other.groupValues, groupValues);
}
@override
int get hashCode {
return fieldValues.hashCode ^ fieldObscurityValues.hashCode ^ hostedValues.hashCode ^ groupValues.hashCode;
}
}

View File

@@ -1,5 +1,3 @@
export './form_group_structure.model.dart'; export './field_config.dart';
export './form_group_instance.model.dart'; export './field_node.dart';
export './form_group_value.model.dart'; export './form_group_definition.dart';
export './initial_form_group_values.model.dart';
export './initial_values.model.dart';

View File

@@ -0,0 +1,87 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../controller.dart';
/// A reactive consumer widget that listens to a specific field node by its [id].
///
/// It provides the current value of type [T] to its [builder]. This implementation
/// uses dynamic node retrieval to ensure stability across Flutter Web and mobile.
class AstromicFieldConsumer<T> extends StatelessWidget {
const AstromicFieldConsumer({
super.key,
required this.controller,
required this.id,
required this.builder,
});
final AstromicFormController controller;
final String id;
final Widget Function(BuildContext context, T value) builder;
@override
Widget build(BuildContext context) {
// Access the node as a dynamic ValueListenable to prevent type-casting issues during retrieval.
final ValueListenable<dynamic> node = controller.node<dynamic>(id);
return ValueListenableBuilder<dynamic>(
valueListenable: node,
builder: (context, value, _) {
// Safely cast the specific value to T within the builder scope.
return builder(context, value as T);
},
);
}
}
/// A reactive consumer that listens to an entire dynamic group and its nested fields.
///
/// Rebuilds whenever the group manifest changes (items added/removed) or when
/// any individual field within the group instances is modified (live typing).
class AstromicGroupConsumer extends StatelessWidget {
const AstromicGroupConsumer({
super.key,
required this.controller,
required this.groupId,
this.parentUuid,
required this.builder,
});
final AstromicFormController controller;
final String groupId;
final String? parentUuid;
final Widget Function(BuildContext context, List<Map<String, dynamic>> data) builder;
@override
Widget build(BuildContext context) {
// Retrieve the manifest holding the current list of instance UUIDs.
final manifest = controller.getManifest(groupId, parentUuid);
return ValueListenableBuilder<List<String>>(
valueListenable: manifest,
builder: (context, uuids, _) {
// Aggregate the manifest and all field nodes into a single listener list.
final List<Listenable> listeners = [manifest];
final def = controller.getGroupDef(groupId);
if (def != null) {
for (var uuid in uuids) {
for (var fieldName in def.schema.keys) {
final fieldId = '${groupId}_${fieldName}_$uuid';
listeners.add(controller.node<dynamic>(fieldId));
}
}
}
// Merge listeners so that any change in the group structure or content triggers a rebuild.
return AnimatedBuilder(
animation: Listenable.merge(listeners),
builder: (context, _) {
final data = controller.getGroupData(groupId, parentUuid: parentUuid);
return builder(context, data);
},
);
},
);
}
}

View File

@@ -0,0 +1,155 @@
import 'package:astromic_elements/astromic_elements.dart';
import 'package:flutter/widgets.dart';
import 'package:form_controller/form_controller.dart';
import '../controller.dart';
import '../enums/enums.exports.dart' show AstromicFieldState;
import '../models/models.exports.dart' show AstromicFieldNode;
/// A custom form field that manages complex data types [T] using a dual-node architecture.
///
/// It maintains a data node for the raw value of type [T] and a shadow label node
/// for the string representation used by the UI.
class AstromicCustomField<T> extends StatefulWidget {
const AstromicCustomField({
super.key,
required this.formController,
required this.formID,
required this.initialValue,
required this.labeler,
this.configuration,
this.validators,
this.resetMessageOnChange,
this.errorsCatcher,
this.style,
this.hint,
this.prefixWidget,
this.suffixWidget,
this.messageBuilder,
this.onTap,
this.onHold,
this.onValueChangedMapper,
});
final AstromicFormController formController;
final String formID;
final T initialValue;
final String Function(T value) labeler;
final AstromicFieldConfiguration? configuration;
final List<FormControllerValidator>? validators;
final bool? resetMessageOnChange;
final Map<String, String?>? errorsCatcher;
final AstromicFieldStyle Function(bool isEnabled, bool isFocused, AstromicFieldState state)? style;
final String? hint;
final Widget? Function(bool isEnabled, bool isFocused, VoidCallback stateSetter, AstromicFieldState state)? prefixWidget;
final Widget? Function(bool isEnabled, bool isFocused, VoidCallback stateSetter, AstromicFieldState state)? suffixWidget;
final Widget? Function(bool isEnabled, bool isFocused, AstromicFieldState state, String? message)? messageBuilder;
final Future<T?> Function(T currentValue)? onTap;
final Future<T?> Function(T currentValue)? onHold;
final String Function(String? oldValue, String newValue)? onValueChangedMapper;
@override
State<AstromicCustomField<T>> createState() => _AstromicCustomFieldState<T>();
}
class _AstromicCustomFieldState<T> extends State<AstromicCustomField<T>> {
late AstromicFieldNode<T> _dataNode;
late AstromicFieldNode<String> _labelNode;
@override
void initState() {
super.initState();
_dataNode = widget.formController.node<T>(widget.formID, initialValue: widget.initialValue);
if (widget.validators != null) {
_dataNode.validators = widget.validators!;
}
final initialLabel = widget.labeler(widget.initialValue);
_labelNode = widget.formController.node<String>('${widget.formID}_label', initialValue: initialLabel);
_dataNode.addListener(_syncDataToLabel);
widget.formController.errorStream.listen(_handleApiErrors);
}
/// Synchronizes the string display node whenever the raw data value changes.
void _syncDataToLabel() {
final newLabel = widget.labeler(_dataNode.value);
if (_labelNode.value != newLabel) {
_labelNode.value = newLabel;
}
}
/// Maps global error codes to the local node state for API-driven validation feedback.
void _handleApiErrors(List<(String code, String? message)> errors) {
if (widget.errorsCatcher == null) return;
for (var entry in widget.errorsCatcher!.entries) {
final codeToCatch = entry.key;
final matchingError = errors.where((e) => e.$1 == codeToCatch).firstOrNull;
if (matchingError != null && mounted) {
_dataNode.setState(
AstromicFieldState.withError,
newMessage: matchingError.$2 ?? entry.value ?? 'Unknown Error',
);
}
}
}
@override
void dispose() {
_dataNode.removeListener(_syncDataToLabel);
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<AstromicFieldState>(
valueListenable: _dataNode.state,
builder: (context, currentState, _) {
return ValueListenableBuilder<String?>(
valueListenable: _dataNode.message,
builder: (context, errorMsg, _) {
return AstromicFields.action<T>(
stateKey: ValueKey('${widget.formID}_custom'),
controller: _labelNode.syncedController,
initialValue: (_dataNode.value, _labelNode.value),
onTap: widget.onTap == null
? null
: (_) async {
if (widget.resetMessageOnChange ?? false) _dataNode.resetState();
final picked = await widget.onTap!(_dataNode.value);
if (picked != null) {
_dataNode.value = picked;
}
return null;
},
onHold: widget.onHold == null
? null
: (_) async {
final held = await widget.onHold!(_dataNode.value);
if (held != null) _dataNode.value = held;
return null;
},
onValueChangedMapper: widget.onValueChangedMapper ?? (old, _) => old!,
configuration: widget.configuration?.copyWith(
validator: (e, f, v) => _dataNode.validate() ?? widget.validators?.where((v) => !v.checker(_labelNode.value)).firstOrNull?.message,
),
style: (enabled) => widget.style?.call(enabled, false, currentState) ?? const AstromicFieldStyle(),
hint: widget.hint,
prefixWidget: widget.prefixWidget != null ? (enabled, setter) => widget.prefixWidget!(enabled, false, setter, currentState) : null,
suffixWidget: widget.suffixWidget != null ? (enabled, setter) => widget.suffixWidget!(enabled, false, setter, currentState) : null,
messageBuilder: widget.messageBuilder != null ? (enabled) => widget.messageBuilder!(enabled, false, currentState, errorMsg) : null,
);
},
);
},
);
}
}

View File

@@ -0,0 +1,156 @@
import 'package:astromic_elements/astromic_elements.dart' show AstromicFieldConfiguration, AstromicFieldStyle, AstromicFields;
import 'package:flutter/widgets.dart';
import 'package:form_controller/form_controller.dart' show FormControllerValidator;
import '../controller.dart';
import '../enums/enums.exports.dart' show AstromicFieldState;
import '../models/field_node.dart';
/// A specialized form field for handling [String] inputs with integrated [FieldNode] synchronization.
///
/// Manages text input lifecycle, including obscurity toggles, validation rules,
/// and global API error stream integration.
class AstromicStringFormField extends StatefulWidget {
const AstromicStringFormField({
super.key,
required this.formController,
required this.formID,
this.initialText,
this.initialObscurity,
this.configuration,
this.validators,
this.resetMessageOnChange,
this.errorsCatcher,
this.style,
this.hint,
this.prefixWidget,
this.suffixWidget,
this.messageBuilder,
this.onChanged,
this.onSubmited,
this.contextButtons,
});
final AstromicFormController formController;
final String formID;
final AstromicFieldConfiguration? configuration;
final List<FormControllerValidator>? validators;
final bool? resetMessageOnChange;
final Map<String, String?>? errorsCatcher;
final AstromicFieldStyle Function(bool isEnabled, bool isFocused, AstromicFieldState state)? style;
final String? hint;
final Widget? Function(bool isEnabled, bool isFocused, VoidCallback stateSetter, AstromicFieldState state)? prefixWidget;
final Widget? Function(bool isEnabled, bool isFocused, VoidCallback stateSetter, AstromicFieldState state)? suffixWidget;
final Widget? Function(bool isEnabled, bool isFocused, AstromicFieldState state, String? message)? messageBuilder;
final String? initialText;
final bool? initialObscurity;
final void Function(String v, AstromicFieldState state)? onChanged;
final void Function(String v, AstromicFieldState state)? onSubmited;
final Iterable<ContextMenuButtonItem>? contextButtons;
@override
State<AstromicStringFormField> createState() => _AstromicStringFormFieldState();
}
class _AstromicStringFormFieldState extends State<AstromicStringFormField> {
late AstromicFieldNode<String> _node;
AstromicFieldNode<bool>? _obscurityNode;
@override
void initState() {
super.initState();
_node = widget.formController.node<String>(
widget.formID,
initialValue: widget.initialText ?? '',
);
if (widget.validators != null && widget.validators!.isNotEmpty) {
_node.validators = widget.validators!;
}
if (widget.configuration?.withObscurity ?? false) {
_obscurityNode = widget.formController.node<bool>('${widget.formID}_obscurity');
if (widget.initialObscurity != null) {
_obscurityNode!.value = widget.initialObscurity!;
}
}
widget.formController.errorStream.listen(_handleApiErrors);
}
@override
void didUpdateWidget(covariant AstromicStringFormField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.validators != oldWidget.validators) {
_node.validators = widget.validators ?? [];
}
}
/// Maps global API error codes to the local field state based on the [errorsCatcher] map.
void _handleApiErrors(List<(String code, String? message)> errors) {
if (widget.errorsCatcher == null) return;
for (var entry in widget.errorsCatcher!.entries) {
final codeToCatch = entry.key;
final fallbackMsg = entry.value;
final matchingError = errors.where((e) => e.$1 == codeToCatch).firstOrNull;
if (matchingError != null && mounted) {
_node.setState(
AstromicFieldState.withError,
newMessage: matchingError.$2 ?? fallbackMsg ?? 'Unknown Error',
);
}
}
}
/// Synchronizes the widget-level validation with the internal [FieldNode] logic.
String? _runValidation(String? value) {
return _node.validate(extraValidators: widget.validators);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<AstromicFieldState>(
valueListenable: _node.state,
builder: (context, currentState, _) {
return ValueListenableBuilder<String?>(
valueListenable: _node.message,
builder: (context, errorMsg, _) {
return ValueListenableBuilder<bool>(
valueListenable: _obscurityNode ?? ValueNotifier(false),
builder: (context, isObscured, _) {
return AstromicFields.text(
stateKey: ValueKey(widget.formID),
controller: _node.syncedController,
onChanged: (s) {
if (widget.resetMessageOnChange ?? false) {
_node.resetState();
}
widget.onChanged?.call(s, currentState);
},
onSubmited: (s) => widget.onSubmited?.call(s, currentState),
configuration: widget.configuration?.copyWith(
isTextObscured: isObscured,
validator: (enabled, focused, val) => _runValidation(val),
),
style: (enabled, focused) => widget.style?.call(enabled, focused, currentState) ?? const AstromicFieldStyle(),
hint: widget.hint,
prefixWidget: widget.prefixWidget != null ? (e, f, s) => widget.prefixWidget!(e, f, s, currentState) : null,
suffixWidget: widget.suffixWidget != null ? (e, f, s) => widget.suffixWidget!(e, f, s, currentState) : null,
messageBuilder: widget.messageBuilder != null ? (e, f) => widget.messageBuilder!(e, f, currentState, errorMsg) : null,
contextButtons: widget.contextButtons,
);
},
);
},
);
},
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:form_controller/form_controller.dart' show FormControllerValidator;
import '../controller.dart';
import '../enums/enums.exports.dart' show AstromicFieldState;
import '../models/field_node.dart';
/// A reactive wrapper widget that synchronizes a specific [FieldNode] with a custom UI builder.
///
/// It acts as a bridge for non-standard fields, handling validation logic injection
/// and state notification for values of type [T].
class AstromicValueWrapper<T> extends StatefulWidget {
const AstromicValueWrapper({
super.key,
required this.controller,
required this.nodeId,
required this.builder,
this.initialValue,
this.validators,
});
final AstromicFormController controller;
final String nodeId;
final T? initialValue;
final List<FormControllerValidator>? validators;
final Widget Function(BuildContext context, T value, String? error, dynamic Function(T) onChange) builder;
@override
State<AstromicValueWrapper<T>> createState() => _AstromicValueWrapperState<T>();
}
class _AstromicValueWrapperState<T> extends State<AstromicValueWrapper<T>> {
late AstromicFieldNode<T> _node;
@override
void initState() {
super.initState();
try {
_node = widget.controller.node<T>(widget.nodeId, initialValue: widget.initialValue);
} catch (_) {
if (widget.initialValue != null) {
widget.controller.set<T>(widget.nodeId, widget.initialValue as T);
_node = widget.controller.node<T>(widget.nodeId);
} else {
throw Exception('Node ${widget.nodeId} not found or failed to initialize.');
}
}
if (widget.validators != null) {
_node.validators = widget.validators!;
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<T>(
valueListenable: _node,
builder: (context, currentValue, _) {
return ValueListenableBuilder<AstromicFieldState>(
valueListenable: _node.state,
builder: (context, fieldState, _) {
return ValueListenableBuilder<String?>(
valueListenable: _node.message,
builder: (context, errorMsg, _) {
final visibleError = fieldState == AstromicFieldState.withError ? errorMsg : null;
return widget.builder(
context,
currentValue,
visibleError,
(newValue) {
_node.value = newValue;
_node.resetState();
},
);
},
);
},
);
},
);
}
}

View File

@@ -0,0 +1,4 @@
export './string_form_field.dart';
export './custom_form_field.dart';
export './value_wrapper.dart';
export './consumers.dart';