Manifold

Manifold is a visual editor for immutable Dart models generated by artifact.

You describe your model once with @manifold + @Property, run codegen, and Manifold builds a full editor UI with nested object editing, collections, maps, raw text formats, and override hooks.

At A Glance

  • Auto-generates field editors from artifact metadata.
  • Emits a brand-new immutable model instance on every change.
  • onChanged receives (value, valid) on every edit.
  • Supports:
    • primitives: String, int, double, bool, DateTime
    • enum
    • nested artifact models
    • recursive List, Set, and Map
    • deep mixed nesting (Map<String, List<Map<int, Set<Model>>>>)
  • Supports read-only mode (viewOnly).
  • Supports disabling root/sub-screen search (enableSearch).
  • Supports disabling root/sub-screen raw editor actions (enableRawEditor).
  • Supports:
    • field-level overrides (propertyEditorBuilder)
    • type-level overrides (editorOverrides)
    • decorator replacement (decoratorBuilder)
    • container strategy (embed vs subScreen)
  • Built-in raw editors for YAML, JSON, TOML, TOON.
  • Built-in live search for large model editors.

Install

dependencies:
  manifold: any
  artifact: any

dev_dependencies:
  build_runner: any
  artifact_gen: any

Then run:

dart run build_runner build --delete-conflicting-outputs

Model Example

The example below shows primitives, enums, nested artifacts, sets, maps, and deep recursive structures in one functional domain.

import 'package:manifold/manifold.dart';

enum DeploymentChannel { canary, stable, hotfix }
enum IncidentLevel { low, medium, high, critical }

@manifold
class ServiceEndpoint {
  @Property(label: 'Base URL', hint: 'https://api.acme.dev')
  final String baseUrl;

  @Property(label: 'Timeout (seconds)', min: 0.5, max: 120)
  final double timeoutSeconds;

  @Property(label: 'Retries', min: 0, max: 10)
  final int retries;

  @Property(label: 'Enabled')
  final bool enabled;

  const ServiceEndpoint({
    this.baseUrl = 'https://localhost',
    this.timeoutSeconds = 10,
    this.retries = 2,
    this.enabled = true,
  });
}

@manifold
class OnCallContact {
  @Property()
  final String name;

  @Property()
  final String? slack;

  @Property()
  final String? phone;

  @Property()
  final IncidentLevel level;

  const OnCallContact({
    required this.name,
    this.slack,
    this.phone,
    this.level = IncidentLevel.low,
  });
}

@manifold
class ReleasePlan {
  @Property()
  final String service;

  @Property()
  final DeploymentChannel channel;

  @Property()
  final DateTime deployAt;

  @Property()
  final ServiceEndpoint primary;

  @Property()
  final ServiceEndpoint? fallback;

  @Property()
  final List<ServiceEndpoint> regionalEndpoints;

  @Property()
  final Set<String> featureFlags;

  @Property()
  final Map<String, OnCallContact> onCallByRegion;

  @Property()
  final Map<String, ServiceEndpoint?> failoverByRegion;

  @Property()
  final Map<String, List<Map<int, Set<ServiceEndpoint>>>> rolloutTree;

  const ReleasePlan({
    required this.service,
    required this.channel,
    required this.deployAt,
    required this.primary,
    this.fallback,
    this.regionalEndpoints = const [],
    this.featureFlags = const {},
    this.onCallByRegion = const {},
    this.failoverByRegion = const {},
    this.rolloutTree = const {},
  });
}

Basic Usage

Import your generated artifacts file before opening ManifoldEditor<T> so accessors are registered.

import 'package:arcane/arcane.dart';
import 'package:my_app/gen/artifacts.gen.dart';
import 'package:my_app/models/release_plan.dart';
import 'package:manifold/manifold.dart';

class ReleasePlanEditorScreen extends StatefulWidget {
  const ReleasePlanEditorScreen({super.key});

  @override
  State<ReleasePlanEditorScreen> createState() => _ReleasePlanEditorScreenState();
}

class _ReleasePlanEditorScreenState extends State<ReleasePlanEditorScreen> {
  ReleasePlan value = ReleasePlan(
    service: 'billing-api',
    channel: DeploymentChannel.canary,
    deployAt: DateTime.now().add(const Duration(hours: 2)),
    primary: const ServiceEndpoint(baseUrl: 'https://billing.acme.dev'),
  );

  @override
  Widget build(BuildContext context) {
    return Screen(
      child: ManifoldEditor<ReleasePlan>(
        edit: value,
        onChanged: (next, valid) {
          // valid == true when all field validators currently pass.
          setState(() => value = next);
        },
      ),
    );
  }
}

Supported Field Types

Type Supported Notes
String, int, double, bool, DateTime Yes Nullable and non-nullable
enum Yes Nullable and non-nullable
Artifact objects Yes Opens nested editor (embed or sub-screen)
List<T> Yes Reorderable
Set<T> Yes Not reorderable
Map<K, V> Yes Key/value entry editors, unique key checks
Recursive combinations Yes Descriptor-driven, no Type.toString parsing

Collection And Map Behavior

List vs Set

  • List<T> supports add/remove/edit/reorder.
  • Set<T> supports add/remove/edit (not reorder).

Map

  • Add/remove/edit key/value pairs.
  • Key uniqueness is enforced.
  • Supports nested map/list/set/artifact values recursively.

Default values when adding

  • String -> ""
  • int -> 0
  • double -> 0.0
  • bool -> false
  • DateTime -> DateTime.now()
  • enum -> first inferred option
  • artifact -> $AClass<T>.construct()
  • nullable target -> null

For map keys:

  • string keys auto-generate (key1, key2, ...)
  • numeric/date keys increment to the next available value
  • bool keys allow only false and true
  • if no safe unique key can be inferred, add is blocked with a toast message

Container Strategy: Embed Or Sub-Screen

Use containerStyle when you want fine control over whether a collection/map/object is inline (embed) or opened on navigation (subScreen).

class ReleaseContainerStyle extends ManifoldContainerStyle {
  const ReleaseContainerStyle();

  @override
  ManifoldContainerType getCollectionStyle<M, T>({
    required ManifoldEditorScope<M> scope,
    required $AFld field,
    required Iterable<T>? value,
  }) {
    if (field.name == 'regionalEndpoints') {
      return ManifoldContainerType.subScreen;
    }

    if ((value?.length ?? 0) > 4) {
      return ManifoldContainerType.subScreen;
    }

    return ManifoldContainerType.embed;
  }

  @override
  ManifoldContainerType getMapStyle<M, K, V>({
    required ManifoldEditorScope<M> scope,
    required $AFld field,
    required Map<K, V>? value,
  }) {
    if (field.name == 'rolloutTree') {
      return ManifoldContainerType.subScreen;
    }

    return ManifoldContainerType.embed;
  }

  @override
  ManifoldContainerType getSubObjectStyle<M, O>({
    required ManifoldEditorScope<M> scope,
    required $AFld field,
    required O? value,
  }) {
    return ManifoldContainerType.subScreen;
  }
}

Usage:

ManifoldEditor<ReleasePlan>(
  containerStyle: const ReleaseContainerStyle(),
  onChanged: (v, valid) {},
)

Read-Only Mode

Set viewOnly: true to disable all mutation UI. Inputs are disabled and provider pushes are ignored.

ManifoldEditor<ReleasePlan>(
  viewOnly: true,
  onChanged: (_, __) {},
)

Customization Hooks

1) propertyEditorBuilder (field-level override)

Runs before default logic. Return null to fall back to built-in behavior.

ManifoldEditor<ReleasePlan>(
  propertyEditorBuilder: (ctx) {
    if (ctx.field.name == 'service') {
      return TextField(
        readOnly: ctx.readOnly,
        enabled: !ctx.readOnly,
        placeholder: 'service-name',
        onChanged: (v) => ctx.onChanged(v),
      );
    }

    return null;
  },
  onChanged: (_, __) {},
)

2) editorOverrides (type-level override)

The user map is applied on top of default editors. Matching types replace defaults.

ManifoldEditor<ReleasePlan>(
  editorOverrides: {
    String: (ctx) => MStringField(
      property: ctx.property,
      readOnly: ctx.readOnly,
      initialValue: (ctx.value as String?) ?? '',
      onChanged: (v) => ctx.onChanged(v),
    ),
    Duration: (ctx) => MIntField(
      property: ctx.property,
      readOnly: ctx.readOnly,
      initialValue: (ctx.value as Duration?)?.inSeconds ?? 0,
      onChanged: (seconds) => ctx.onChanged(Duration(seconds: seconds ?? 0)),
    ),
  },
  onChanged: (_, __) {},
)

ManifoldEditorOverrideContext includes:

  • field, property
  • value, valueType
  • onChanged
  • collectionElement (true for list/set/map item context)
  • readOnly

3) Decorators

Use built-in decorators:

  • ManifoldDensePropertyDecorator
  • ManifoldCompactPropertyDecorator

Or fully control field shell rendering with decoratorBuilder.

ManifoldEditor<ReleasePlan>(
  decoratorBuilder: (ctx) {
    return Card(
      leading: Icon(ctx.icon),
      titleText: ctx.label,
      subtitleText: ctx.property?.description,
      child: ctx.editor,
    );
  },
  onChanged: (_, __) {},
)

The decorator context contains:

  • field
  • property
  • value
  • editor (the actual field editor widget)
  • readOnly

Raw Editors (YAML / JSON / TOML / TOON)

The root overflow menu includes text editors for supported formats.

Notes:

  • Raw parse/import errors are surfaced as toasts.
  • If a format cannot represent current values, it is disabled instead of crashing.
    • Example: TOML cannot encode null values.
  • Raw edit/view actions can be disabled entirely with enableRawEditor: false.

Manifold includes live search in the root editor:

  • filters visible fields by:
    • field name / spaced label variant
    • property label/description/hint
    • value text
    • nested artifact YAML preview where available

This keeps large models manageable without extra configuration.

Set enableSearch: false to hide live search controls.

Validation

  • Validators can be defined via:
    • @Property(validators: [...])
    • standalone validator annotations on fields
  • onChanged(value, valid) always fires; use valid to decide whether to persist upstream.
  • If a field has no validators, it is treated as valid.

Important Behavior Notes

  • Only fields annotated with @Property are shown.
  • Nested object editing can be forced inline with inlineSubObjects: true.
  • Collection/map identity tracking is used to preserve focus/state during typing, search, and reorder operations.
  • For fire_crud-style models, documentPath is preserved across edits.

Troubleshooting

"No artifact accessor found for type ..."

Checklist:

  • Verify model is annotated with @manifold.
  • Re-run build runner.
  • Import generated artifacts before building the editor.
  • Ensure generated artifacts are current for your model signatures.

"Type mismatch" card in nested editor

This usually means runtime data shape does not match generated type descriptors (stale codegen or malformed raw import). Regenerate artifacts and re-check raw payloads.

TOML option disabled

Expected when current model contains values TOML cannot represent, especially null.

API Snapshot

ManifoldEditor<T> key params:

  • edit: optional initial instance
  • onChanged: required void Function(T value, bool valid) callback
  • viewOnly: read-only mode
  • enableSearch: show/hide search controls
  • enableRawEditor: show/hide edit/view raw controls
  • propertyEditorBuilder: early field override hook
  • editorOverrides: type-based editor override map
  • decorator: built-in decorator selection
  • decoratorBuilder: full decorator override
  • containerStyle: embed/sub-screen strategy for collection/map/subobject
  • inlineSubObjects: force nested object embedding

Example App

See /example for a runnable demo with nested objects, enums, doubles, recursive collections, maps, and raw editor integration.