manifold 1.0.10 copy "manifold: ^1.0.10" to clipboard
manifold: ^1.0.10 copied to clipboard

Edit objects with UI

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.