manifold 1.0.2
manifold: ^1.0.2 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.
- Supports:
- primitives:
String,int,double,bool,DateTime enum- nested artifact models
- recursive
List,Set, andMap - deep mixed nesting (
Map<String, List<Map<int, Set<Model>>>>)
- primitives:
- Supports read-only mode (
viewOnly). - Supports:
- field-level overrides (
propertyEditorBuilder) - type-level overrides (
editorOverrides) - decorator replacement (
decoratorBuilder) - container strategy (
embedvssubScreen)
- field-level overrides (
- 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) {
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->0double->0.0bool->falseDateTime->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
falseandtrue - 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) {},
)
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: (v) {},
)
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: (v) {},
)
ManifoldEditorOverrideContext includes:
field,propertyvalue,valueTypeonChangedcollectionElement(true for list/set/map item context)readOnly
3) Decorators #
Use built-in decorators:
ManifoldDensePropertyDecoratorManifoldCompactPropertyDecorator
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: (v) {},
)
The decorator context contains:
fieldpropertyvalueeditor(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
nullvalues.
- Example: TOML cannot encode
Search #
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.
Important Behavior Notes #
- Only fields annotated with
@Propertyare 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,
documentPathis 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 instanceonChanged: required callbackviewOnly: read-only modepropertyEditorBuilder: early field override hookeditorOverrides: type-based editor override mapdecorator: built-in decorator selectiondecoratorBuilder: full decorator overridecontainerStyle: embed/sub-screen strategy for collection/map/subobjectinlineSubObjects: force nested object embeddingdense: force dense/compact mode (instead of responsive auto mode)
Example App #
See /example for a runnable demo with nested objects, enums, doubles, recursive collections, maps, and raw editor integration.