Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* `setOptions(List<V>)` on `AdvancedSingleSelectFieldController` and `AdvancedMultiSelectFieldController`: the list of options can change while the form is open. A selected value that is not on the new list is cleared as a program write (no user edit, no error on an untouched field); widgets rebuild even when the value stayed the same.
* The single-select controller now copies the `options` you pass, as the multi-select always did. `options` is an unmodifiable list on both.
* The Agent Skill is a [package skill](https://dart.dev/tools/pub/package-skills): `dart run skills@ get` installs it into your agent's skills folder. The folder moved from `skills/advanced_forms/` to `skills/advanced_forms-build-forms/`, the name the CLI requires; the skill now adds the package with `flutter pub add advanced_forms` when the project lacks it.
* Added `enabled` to `addSubform`: the section is attached only while the closure returns true, and keeps its values when it is not.

## 0.2.1+1

Expand Down
3 changes: 3 additions & 0 deletions docs/subforms/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ class _GuestCard extends StatelessWidget {

- **`addSubform(form)`** attaches. A no-op if already attached. The parent applies its validation mode to the subform
at once — unless the subform has a mode of its own.
- **`addSubform(form, enabled: () => …)`** attaches a conditional section: the closure decides, now and after every
value change in the form, whether the section is attached. Detached, it keeps its values and its owner. See
[Patterns](./patterns.mdx#a-section-behind-a-switch).
- **`removeSubform(form)`** detaches: the subform stops validating, notifying and counting towards the parent's state,
and can be re-attached later. It does **not** dispose it.
- **The parent owns every subform it was ever given** and disposes them all in its own `dispose()`, attached or
Expand Down
54 changes: 40 additions & 14 deletions docs/subforms/patterns.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,54 @@ AI-Provenance:
harness: Claude Code
*/}

## Detach, or switch validation off?
## A section behind a switch

A section that is *visible but not applicable* — invoice details behind an unchecked "I need an invoice" switch — is
better kept attached with `setValidationEnabled(false)`:

- its errors are cleared and its fields leave `canSubmit`, `validating`, `hasFailedValidation` and `validationErrors`;
- its `validate()` returns `true` without running;
- everything else — `resetAll`, `markReadOnly`, `clearErrors`, disposal — still reaches it;
- switching back on re-runs the sync validators at once, and the values are still there.

Detach with `removeSubform` when the section genuinely leaves the screen and its values should not take part in
anything. A detached subform is still owned — and disposed — by the parent, and can be re-attached later.
Invoice details behind an "I need an invoice" checkbox, a shipping address unless "same as billing" is on: hand the
condition to `addSubform`.

```dart
CheckoutFormController() {
registerFields([email, needsInvoice]);
addSubform(invoice);
addRelation(needsInvoice, (on) => on, invoice.setValidationEnabled);
invoice.setValidationEnabled(needsInvoice.fieldValue); // relations fire on change only
addSubform(invoice, enabled: () => needsInvoice.fieldValue);
}
```

The closure is evaluated at `addSubform` and again whenever a value anywhere in the form changes. While it is true the
section is attached; when it turns false the section is detached, exactly as `removeSubform` would: its fields leave
`validate()`, `canSubmit`, `getFieldValues()` and `allFields`, so the payload you build from the form has no invoice
in it. The form owns the section either way, so **the values stay in its fields** and are there again when the
checkbox goes back on — and the section is disposed with the form.

The closure can be any boolean expression over the fields' values. Because the form re-evaluates it after `resetAll()`
too, a reset checkbox takes the section with it.
A condition that depends on something *outside* the form — a service, a route argument — is not re-evaluated for that;
attach and detach by hand then.

**Which to use.** `enabled:` when the decision is a function of the form's own values; `addSubform` and
`removeSubform` by hand when it comes from outside the form. The two do not fight, the last call by hand wins: a manual
`removeSubform` detaches the section *and drops the condition*, so the section stays out until the next `addSubform`;
a plain `addSubform` attaches it for good; `addSubform` with a new `enabled:` replaces the condition.

## Or keep it attached and switch validation off

Detached, the section is out of reach of `resetAll`, `markReadOnly`, `clearErrors` and `setValidationMode`, and its
values are not part of the form's. When you want those to keep reaching a section that merely does not apply right
now, keep it attached and use `setValidationEnabled(false)` instead:

- its errors are cleared and its fields leave `canSubmit`, `validating`, `hasFailedValidation` and `validationErrors`;
- its `validate()` returns `true` without running;
- everything else — `resetAll`, `markReadOnly`, `clearErrors`, disposal — still reaches it, and its values stay in
`getFieldValues()`;
- switching back on re-runs the sync validators at once.

```dart
addSubform(invoice);
addRelation(needsInvoice, (on) => on, invoice.setValidationEnabled);
invoice.setValidationEnabled(needsInvoice.fieldValue); // relations fire on change only
```

`resetAll()` does not touch `validationEnabled`, so seed it again wherever you reset.

## Swapping one section for another

A type selector that swaps the active section — a *person* or a *company* — is `removeSubform` of one and
Expand Down
8 changes: 4 additions & 4 deletions docs/subforms/wizard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ Three decisions in that code are worth keeping:
- **Escalate the failed step, not the whole form.** After a failed `next()`, the step gets
`ValidationMode.onUserInteraction` so it corrects itself as the user types. Because a subform's own mode wins over the
parent's, the steps ahead stay in `manual` and never flag an untouched field.
- **A conditional step stays attached with `setValidationEnabled(false)`.** The example app's *Step Form* screen has an
invoice step that a switch on the address step adds or drops through `addRelation`. Switched off, the step leaves
the flow *and* `validate()`, `canSubmit` and `validating`, so one flag is the whole condition — and its values survive
in case the user flips the switch back.
- **A conditional step comes and goes with its switch.** The example app's *Step Form* screen attaches its invoice step
with `addSubform(invoice, enabled: () => address.needsInvoice.fieldValue)`. Detached, the step leaves the flow *and*
`validate()`, `canSubmit` and `validating`, so membership is the whole condition — and the wizard still owns it, so
its values survive in case the user flips the switch back.
4 changes: 3 additions & 1 deletion docs/validation/relations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ cannot loop. Three details matter in practice:
country changes and nothing else can wipe the user's choice; the [wizard](../subforms/wizard.mdx) does exactly that.

Attaching and detaching subforms, or calling `setValidationEnabled`, from `onChange` is supported too — that is how a
switch on one page adds or drops another page of a wizard.
type selector swaps one section for another. A section that only comes and goes with a switch does not need a relation
at all: `addSubform(section, enabled: () => …)` keeps the condition itself, see [Patterns](../subforms/patterns.mdx).

## Which one do I want?

Expand All @@ -123,4 +124,5 @@ switch on one page adds or drops another page of a wizard.
| re-check every rule because something *outside* the form changed | `form.revalidateSync()` |
| set B's value when A changes | `addRelation(A, select, (a) => B.setValue(…))` |
| forget B's async verdict when A changes | `addRelation(A, select, (_) => B.clearErrors())` |
| attach and detach a section with A | `addSubform(section, enabled: () => A.fieldValue …)` |
| react to A outside the form (analytics, a service) | `A.addListener(…)`, comparing values yourself |
59 changes: 35 additions & 24 deletions example/lib/screens/step_form.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:advanced_forms_example/widgets/form_dropdown_field.dart';
import 'package:advanced_forms_example/widgets/form_switch_field.dart';
import 'package:advanced_forms_example/widgets/form_text_field.dart';
import 'package:advanced_forms_example/widgets/screen_description.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

Expand All @@ -17,8 +18,9 @@ import 'package:provider/provider.dart';
/// would dispose it.
/// * **Next** validates the current page's subform only.
/// * **Submit** validates the parent, which reaches every attached subform.
/// * A **skipped page** stays attached with `setValidationEnabled(false)`: the
/// navigation walks past it and its fields stop counting towards the parent.
/// * A **skipped page** is detached through `addSubform`'s `enabled:`: the
/// navigation walks past it and its fields stop counting towards the parent,
/// while the wizard still owns it and its values.
class StepFormScreen extends StatelessWidget {
const StepFormScreen({super.key});

Expand Down Expand Up @@ -92,9 +94,11 @@ class _StepFormState extends State<StepForm> {
code('john@email.com'),
plain('. The '),
bold('Invoice'),
plain(' page is conditional: with the switch on Address off, '),
code('setValidationEnabled(false)'),
plain(' takes it out of the flow and out of '),
plain(' page is conditional: attached with '),
code('enabled: () => needsInvoice.fieldValue'),
plain(
', so with the switch on Address off it leaves the flow and ',
),
code('validate()'),
plain('.'),
]),
Expand Down Expand Up @@ -278,7 +282,7 @@ class _AddressStep extends StatelessWidget {
hintText: 'Enter your city',
),
// The decision that adds or drops the Invoice page — an ordinary field
// the wizard watches with `addRelation`.
// the wizard's `addSubform(invoice, enabled: …)` reads.
FormSwitchField(
field: controller.needsInvoice,
labelText: 'I need a VAT invoice',
Expand Down Expand Up @@ -363,14 +367,18 @@ abstract class WizardStepController extends AdvancedFormController {
/// fields with `notifyListeners()`; only [next], [back] and [submit] move it.
class StepFormController extends AdvancedFormController {
StepFormController() {
steps.forEach(addSubform);

// Keeping the Invoice step attached and only switching its validation off
// is what makes the skip reversible: its values, its fields and its
// disposal stay with the wizard. `addRelation` fires on change only, so the
// initial state is seeded right after.
addRelation(address.needsInvoice, (value) => value, _setInvoiceEnabled);
_setInvoiceEnabled(address.needsInvoice.fieldValue);
addSubform(account);
addSubform(address);
// The Invoice step is conditional: attached while the switch on the
// Address step is on, detached otherwise. The wizard owns it either way,
// so its values survive a skip and it is disposed with the wizard. The
// form re-evaluates the condition itself whenever a value in it changes.
addSubform(invoice, enabled: () => address.needsInvoice.fieldValue);
addSubform(confirm);

// Navigation follows: the flow is the steps that are attached right now.
addListener(_syncActiveSteps);
_syncActiveSteps();
}

final account = AccountStepController();
Expand All @@ -380,9 +388,9 @@ class StepFormController extends AdvancedFormController {

late final steps = <WizardStepController>[account, address, invoice, confirm];

/// The steps the user actually walks through. A step switched off is out of
/// the flow *and* out of `validate()`, `canSubmit` and `validating`, so that
/// one flag is the whole condition.
/// The steps the user actually walks through. A detached step is out of
/// the flow *and* out of `validate()`, `canSubmit` and `validating`, so
/// membership is the whole condition.
List<WizardStepController> get activeSteps => _activeSteps;
var _activeSteps = <WizardStepController>[];

Expand Down Expand Up @@ -454,13 +462,16 @@ class StepFormController extends AdvancedFormController {
}
}

void _setInvoiceEnabled(bool enabled) {
final current = _activeSteps.isEmpty ? null : currentStep;
invoice.setValidationEnabled(enabled);
_activeSteps = [
void _syncActiveSteps() {
final active = [
for (final step in steps)
if (step.value.validationEnabled) step,
if (value.subforms.contains(step)) step,
];
if (listEquals(active, _activeSteps)) {
return;
}
final current = _activeSteps.isEmpty ? null : currentStep;
_activeSteps = active;
// The user is never standing on a step as it leaves the flow — the switch
// is on an earlier one — but clamp rather than trust that.
_currentIndex = _activeSteps
Expand Down Expand Up @@ -512,8 +523,8 @@ Future<ValidationError?> _checkEmailTaken(String value) async {
}

/// Step 2. Picking another country clears the city, through the form's
/// `addRelation` — "when B changes, set A". The wizard reads the invoice switch
/// the same way, one level up.
/// `addRelation` — "when B changes, set A". The invoice switch needs no
/// relation: the wizard hands it to `addSubform` as the step's `enabled`.
class AddressStepController extends WizardStepController {
AddressStepController() {
registerFields([country, city, needsInvoice]);
Expand Down
89 changes: 81 additions & 8 deletions lib/src/form/advanced_form_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ class AdvancedFormController
// Explicit type — inference would widen the error type from dynamic to Object.
final Set<AdvancedFieldController<dynamic, dynamic>> _ownedFields = {};
final Set<AdvancedFormController> _ownedSubforms = {};
// Conditional sections: the `enabled` closure and its last result.
// Re-evaluated whenever a value in this tree changes; attaches and detaches.
final Map<AdvancedFormController, _SubformCondition> _subformConditions = {};
final _validateCall = SharedCall<bool>();

// null: follow the parent form's mode. Non-null: this form manages its own.
Expand Down Expand Up @@ -201,9 +204,33 @@ class AdvancedFormController
/// Ownership outlives [removeSubform]: a detached subform is still disposed
/// with this form, the same way a deregistered field is.
///
/// [enabled] is a shortcut over calling this and [removeSubform] yourself,
/// for a section that follows a value in the form:
///
/// ```dart
/// addSubform(invoice, enabled: () => needsInvoice.fieldValue);
/// addSubform(company, enabled: () => type.fieldValue == CustomerType.company);
/// ```
///
/// It is evaluated now and again whenever a value anywhere in this form's
/// tree changes. While it is true the section is attached; when it turns
/// false the section is detached, as with [removeSubform]: it leaves
/// `validate`, `canSubmit`, `getFieldValues` and every other broadcast. This
/// form owns the section either way, so its values stay in its fields and
/// are there again when it comes back — and it is disposed with this form.
///
/// Which to use: [enabled] when the decision is a function of the form's
/// own values; the two calls by hand when it comes from outside the form —
/// a route argument, a service — because the condition is not re-evaluated
/// for those. The two do not fight: the last call by hand wins. A manual
/// [removeSubform] detaches the section *and drops the condition*, so the
/// section stays out until the next [addSubform]; a plain [addSubform]
/// attaches it for good; [addSubform] with a new [enabled] replaces the
/// condition.
///
/// Throws a [StateError] if either controller has already been disposed —
/// disposed controllers cannot be reused.
void addSubform(AdvancedFormController form) {
void addSubform(AdvancedFormController form, {bool Function()? enabled}) {
if (isDisposed) {
throw StateError(
'Cannot add a subform to a disposed AdvancedFormController.',
Expand All @@ -214,10 +241,29 @@ class AdvancedFormController
'Cannot add a disposed AdvancedFormController as a subform.',
);
}
if (value.subforms.contains(form)) {
if (enabled == null) {
if (value.subforms.contains(form)) {
return;
}
_attach(form);
return;
}

// A condition given again replaces the previous one.
_subformConditions.remove(form);
_ownedSubforms.add(form);
final result = enabled();
_subformConditions[form] = (enabled: enabled, last: result);
if (result) {
if (!value.subforms.contains(form)) {
_attach(form);
}
} else if (value.subforms.contains(form)) {
_detach(form);
}
}

void _attach(AdvancedFormController form) {
_runChildCleanups();
// value.subforms is what participates; _ownedSubforms is what gets disposed.
_setState(value.copyWith(subforms: {...value.subforms, form}));
Expand All @@ -227,8 +273,34 @@ class AdvancedFormController
_recomputeWasModified();
}

void _detach(AdvancedFormController form) {
_runChildCleanups();
_setState(value.copyWith(subforms: {...value.subforms}..remove(form)));
_wireChildren();
_recomputeWasModified();
}

@override
void _applySubformConditions() {
// A copy: the map is written while it is walked.
for (final MapEntry(key: form, value: (:enabled, :last))
in _subformConditions.entries.toList()) {
final next = enabled();
if (next == last) {
continue;
}
_subformConditions[form] = (enabled: enabled, last: next);
if (next) {
_attach(form);
} else {
_detach(form);
}
}
}

/// Detaches an owned subform: it stops validating, notifying and counting
/// towards this form's state. A noop if [form] was not a subform.
/// towards this form's state. A noop if [form] was not a subform. Drops the
/// `enabled` condition [form] was attached with, if any.
///
/// Does not dispose [form] — this form still owns it and disposes it in
/// [dispose], so a detached subform can be re-attached with [addSubform].
Expand All @@ -241,14 +313,11 @@ class AdvancedFormController
'Cannot remove a subform from a disposed AdvancedFormController.',
);
}
_subformConditions.remove(form);
if (!value.subforms.contains(form)) {
return;
}

_runChildCleanups();
_setState(value.copyWith(subforms: {...value.subforms}..remove(form)));
_wireChildren();
_recomputeWasModified();
_detach(form);
}

/// Turns validation for this whole subtree on or off — above every mode.
Expand All @@ -274,6 +343,7 @@ class AdvancedFormController
_isDisposed = true;
_runChildCleanups();
_runRelationCleanups();
_subformConditions.clear();
for (final field in _ownedFields) {
field.dispose();
}
Expand Down Expand Up @@ -358,3 +428,6 @@ class AdvancedFormController
notifyListeners();
}
}

/// The `enabled` closure of a conditional subform and its last result.
typedef _SubformCondition = ({bool Function() enabled, bool last});
4 changes: 4 additions & 0 deletions lib/src/form/child_wiring.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ mixin _ChildWiring on ChangeNotifier {

void _setState(AdvancedFormState newValue);

/// Re-evaluates the `enabled` conditions of conditional subforms.
void _applySubformConditions();

final _onValuesChanged = ChangeNotifier();

/// Fires when any leaf field's value changes (recursively through subforms),
Expand Down Expand Up @@ -76,6 +79,7 @@ mixin _ChildWiring on ChangeNotifier {
if (validateAll) {
revalidateSync();
}
_applySubformConditions();
_recomputeWasModified();
_onValuesChanged.notifyListeners();
}
Expand Down
Loading
Loading