Building UI
The entity system ships several composable surfaces for displaying entity data. This guide covers the layout taxonomy, the workspace shapes (singleton / list / grouped), the slot-based StandardRouteBuilder, the EntityCustomRoute extension points, and the helper widgets (EntityCardBuilder, SectionCard, EntityProvider).
Layout Taxonomy
Every entity declares its layouts in one place — EntityConfiguration.layouts:
class EntityLayouts<T extends EntityBase> {
final List<CollectionLayout<T>> list;
final List<EntityLayout<T>> details;
final List<EntityItemLayout<T>> items;
final List<EntityComparisonLayout<T>> comparisons;
final EntityLayout<T>? summary;
final AggregateLayout<T>? dashboard;
final List<AggregateLayout<T>> analytics;
}| Slot | What it renders |
|---|---|
list | Tables, grids, mobile compact rows — any view that takes a list of entities |
details | Tabs on the detail page (Overview, Audit, Versions, Workflow, …) |
items | Single-entity rendering primitives (version detail card, sidebar tile, palette preview). Looked up by identifier via getItem(id); framework provides a 'default' field/value table when none registered |
comparisons | Multi-entity rendering primitives (audit delta, future trend matrix). Looked up via getComparison(id) |
summary | Optional summary card shown above the detail tabs |
dashboard | Optional aggregate dashboard at /<prefix>/dashboard |
analytics | Aggregate views (charts, heatmaps, trends) |
Layouts marked with an Authorize policy are filtered automatically before render via EntityLayouts.filterByPermission.
final layouts = EntityLayouts<Course>(
list: [courseTable, courseGrid],
details: [courseDetailsTab, EntityVersionLayout<Course>(/* ... */)],
summary: const CourseSummaryCard(),
dashboard: const CourseDashboard(),
);Workspace Shapes
A workspace is the top-level shell that hosts an entity surface — the master/detail layout, header, dock, and lifecycle wiring. The framework ships three canonical shapes, all built on the same WorkspaceTemplate foundation. Capability mixins compose the behavior; you don't build new shells from scratch.
| Shape | Use case | Key class |
|---|---|---|
| List (default) | Master-detail entity surfaces (Areas, Equipment, Activities, …) | EntityWorkspace<T> |
| Singleton | Single-instance entities (Settings, P&D Module Config) | SingletonEntityWorkspace<T> |
| Grouped | Tabbed shells hosting multiple child workspaces (Inbox) | GroupedEntityWorkspace |
List shape — EntityWorkspace<T>
EntityWorkspace<T> is a cdx_panes.Workspace with header / body / dock slots driven by EntityWorkspaceConfig. It owns the EntityListController<T> and the dock controller, syncs URL ↔ selection ↔ panel mode, and dispatches lifecycle callbacks. StandardRouteBuilder<T> returns this shell by default; consumers do not normally instantiate it directly.
EntityWorkspace<Course>(
config: const EntityWorkspaceConfig(),
panel: navigationShell, // detail content for the docked panel
selectionMode: EntitySelectionMode.navigate,
baseFilter: someAlwaysAppliedFilter, // optional
onAfterMutation: (ctx, event) {
/* Cross-entity refresh, analytics, … */
},
);Composition (mixins, all with the same State):
class _EntityWorkspaceState<T extends EntityBase>
extends WorkspaceTemplateState<EntityWorkspace<T>, T>
with
WithRouterAwareness<EntityWorkspace<T>, T>,
WithListController<EntityWorkspace<T>, T>,
WithDetail<T>,
WithDraftIsolation<EntityWorkspace<T>, T>,
WithBatchActions<EntityWorkspace<T>, T> { /* ... */ }Each mixin owns one capability. New shapes pick the mixins they need and compose through WorkspaceTemplate; reserve EntityShellLayout / MasterDetailScaffold for the existing standard shell path.
Singleton shape — SingletonEntityWorkspace<T>
For app-wide settings or any entity that exists as a single instance: no list, no row selection, no detail dock. The body is the active route's content.
URL convention:
/<base>→ summary detail layout/<base>/edit→ editor/<base>/<tab>→ that tab's detail layout
SingletonEntityWorkspace<Settings>(
navigationShell: shellRouteChild,
onAfterMutation: (ctx, event) { /* ... */ },
)Grouped shape — GroupedEntityWorkspace
A tabbed shell whose tabs are independent child workspaces. Canonical use case: the Inbox, with tabs like My Approvals, My Investigations, each hosting its own filtered EntityWorkspace<T>.
GroupedEntityWorkspace(
config: GroupedEntityWorkspaceConfig(
basePath: '/lms/inbox',
tabs: [approvalsTab, investigationsTab, breakdownsTab],
),
)The body is an IndexedStack so switching tabs preserves each child's scroll position, sort, filter, and selection.
StandardRouteBuilder — Slot-Based Customization
StandardRouteBuilder<T> assembles the route tree for a single entity (list / dashboard / new / detail tabs / edit / custom routes). It exposes its work as slots. Override buildSingleRoute(slot, config) or buildMultipleRoutes(slot, config) and dispatch on the slot constant — unknown slots fall through to super.
Slot constants (always reference the constant, never the string literal):
| Slot | Returns | Notes |
|---|---|---|
createRoute | single GoRoute | null when no editor |
editRoute | single GoRoute | null when no editor |
dashboardRoute | single GoRoute | null when no dashboard layout |
viewTabsRoute | list of GoRoutes | One per detail tab; empty when no detail layouts |
customRoutesSlot | list of GoRoutes | Built from the constructor's customRoutes |
singletonMainRoute | single GoRoute | Used when isSingleton: true |
singletonEditRoute | single GoRoute | Used when isSingleton: true and editor present |
singletonTabsRoute | list of GoRoutes | Used when isSingleton: true |
Replace just the create route
class ActivityRouteBuilder extends StandardRouteBuilder<Activity> {
@override
GoRoute? buildSingleRoute(String slot, EntityConfiguration<Activity> config) {
if (slot == StandardRouteBuilder.createRoute) {
return GoRoute(
path: 'new',
pageBuilder: (context, state) => DialogPage(
child: ActivityCreationWizard(/* ... */),
),
);
}
return super.buildSingleRoute(slot, config);
}
}Read-only entity (no create / edit)
class ReadOnlyRouteBuilder<T extends EntityBase>
extends StandardRouteBuilder<T> {
@override
GoRoute? buildSingleRoute(String slot, EntityConfiguration<T> config) {
if (slot == StandardRouteBuilder.createRoute ||
slot == StandardRouteBuilder.editRoute) {
return null;
}
return super.buildSingleRoute(slot, config);
}
}Replace the shell
When the workspace shape needs to change (e.g. mounting a custom dock layout), override buildShellLayout instead of rebuilding the route tree. EntityWorkspaceRouteBuilder<T> does exactly this — same slot helpers, different shell.
EntityCustomRoute
EntityCustomRoute adds extra routes alongside the standard tab + edit routes. Two scopes:
| Constructor | URL | Builder | Use case |
|---|---|---|---|
EntityCustomRoute(path: 'preview', ...) | <prefix>/:id/preview | (context, entityId) | Per-entity sub-pages — preview, related view |
EntityCustomRoute.topLevel(path: 'kanban', ...) | <prefix>/kanban | (context) | Collection-level pages — kanban, calendar |
display controls the page wrapper:
CustomRouteDisplay | Desktop | Mobile |
|---|---|---|
fullWidth | NoTransitionPage (full-page) | ModalSheetPage |
dialog | DialogPage (CDX DialogShell) | ModalSheetPage |
StandardRouteBuilder<LogTemplate>(
customRoutes: [
EntityCustomRoute(
path: 'preview',
title: 'Preview',
icon: FluentIcons.eye_24_regular,
display: CustomRouteDisplay.dialog,
builder: (context, entityId) => PreviewContent(entityId: entityId),
),
EntityCustomRoute.topLevel(
path: 'kanban',
title: 'Kanban Board',
icon: FluentIcons.board_24_regular,
display: CustomRouteDisplay.fullWidth,
builder: (context) => const ActivityKanbanPage(),
),
],
);Dialog routes are push-only — direct URL entry redirects to the parent (per-entity) or to / (top-level). Push them via:
EntityNavigation.showCustom<LogTemplate>(context, entityId, 'preview');
// or
context.push(path, extra: const {'pushed': true});EntityProvider
EntityProvider<T> is the inherited widget that exposes the entity configuration, the list controller, and the lifecycle hooks to descendants. Workspaces mount it; consumers read from it.
final config = EntityProvider.configOf<Course>(context);
final metadata = EntityProvider.metadataOf<Course>(context);
final api = EntityProvider.apiOf<Course>(context);
final controller = EntityProvider.listControllerOf<Course>(context);
// Nullable variants
final maybeCfg = EntityProvider.maybeConfigOf<Course>(context);
final maybeCtrl = EntityProvider.maybeListControllerOf<Course>(context);| Accessor | Returns |
|---|---|
configOf<T>() | EntityConfiguration<T> |
metadataOf<T>() | EntityMetadata |
apiOf<T>() | EntityApi<T> |
listControllerOf<T>() | EntityListController<T> |
buildersOf<T>() | RouteComponentBuilders<T>? |
hooksOf<T>() | RouteLifecycleHooks<T>? |
EntityListView
EntityListView<T> is the master widget rendered inside the list workspace. It owns search, filters, layout switching, selection, batch actions, pagination, and the drafts toggle.
EntityListView<Course>(
listLayouts: courseConfig.layouts.list,
showFilters: true,
showHeader: true,
)| Parameter | Purpose |
|---|---|
listLayouts | Available list layouts (table, grid, …) |
selectedEntity | Override for the selected entity |
onSelectionChanged | Single-selection callback |
onMultiSelectionChanged | Multi-selection callback (batch actions) |
showSidePanel | Embed a docked detail panel |
panelMode | view or edit |
showFilters / showHeader | UI toggles |
Mobile breakpoint behavior: the workspace replaces the side panel with a bottom sheet automatically.
EntityView
EntityView<T> is a generic data-state widget for any future / stream / cached query.
Cached query stream (most common)
EntityView<List<Course>>(
query: controller.queryStream,
emptyWidget: const CourseEmptyState(),
onRetry: controller.refreshList,
builder: (context, courses) => CourseTable(courses: courses),
)The query stream emits QueryStatus<T>:
QueryLoading(initial) → loading widgetQueryLoading(refetch with cached data) → content + progress barQuerySuccess→ contentQueryError(with cached data) → stale content + error bannerQueryError(no cached data) → error widget
One-shot future
EntityView<Course>(
future: courseApi.getOne('course-123'),
builder: (context, course) => CourseDetail(course: course),
)Constructor
const EntityView({
Future<T>? future,
Stream<T>? stream,
T? data,
Stream<QueryStatus<T>>? query,
required Widget Function(BuildContext, T) builder,
Widget? loadingWidget,
Widget Function(BuildContext, Object?)? errorBuilder,
Widget? emptyWidget,
VoidCallback? onRetry,
bool Function(T data)? isEmpty,
bool showEntityContext = true,
bool showCreate = true,
bool showRetry = true,
});Exactly one of future, stream, data, or query must be provided. For non-list types, supply isEmpty so the widget can route to the empty state correctly.
SectionCard and EntityCardBuilder
Detail tabs assemble their content from SectionCard — the canonical card primitive used throughout the entity surfaces.
SectionCard
Three construction modes:
// 1. Arbitrary child
SectionCard(
icon: FluentIcons.info_24_regular,
title: 'Notes',
child: Text(course.notes),
);
// 2. Field/value pairs (formatter-driven)
SectionCard.fields(
icon: FluentIcons.book_24_regular,
title: 'Course Details',
fields: {
'Level': course.level,
'Status': SectionField(
value: course.status,
isHighlighted: true,
),
'Instructor': SectionField.widget(
EntityLink.of<Trainer>(course.instructorId),
),
},
);
// 3. Stateful (loading / error / empty / content)
SectionCard.stateful(
icon: FluentIcons.history_24_regular,
title: 'Versions',
state: state,
contentBuilder: (versions) => VersionList(versions: versions),
);Field rendering routes through FieldFormatter.instance, so dates, durations, references, and enums all get consistent formatting.
EntityCardBuilder
Pre-canned SectionCard variants for the universal cards every entity needs:
EntityCardBuilder.basicInfo(
fields: {'Name': course.name, 'Level': course.level},
);
EntityCardBuilder.auditInfo(
createdAt: course.createdAt,
updatedAt: course.updatedAt,
versionNumber: course.versionNumber,
);
EntityCardBuilder.statusInfo(statusFields: {
'Active': course.isActive,
'Status': course.status,
});Use these instead of hand-rolling the same card layout across every entity.
Detail Tabs
Detail tabs are EntityLayout<T> instances:
class CourseOverviewTab extends EntityLayout<Course> {
const CourseOverviewTab()
: super(
schemaType: 'course.layout.overview',
identifier: 'overview',
title: 'Overview',
icon: FluentIcons.book_24_regular,
);
@override
Widget build(BuildContext context, Course course) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
EntityCardBuilder.basicInfo(fields: {
'Name': course.name,
'Level': course.level,
'Duration': '${course.durationMinutes} min',
}),
EntityCardBuilder.auditInfo(
createdAt: course.createdAt,
updatedAt: course.updatedAt,
versionNumber: course.versionNumber,
),
],
),
);
}
}Each detail tab gets a route under the entity's :id — /<prefix>/:id/<tab-id>. Bare /<prefix>/:id redirects to the first tab.
Embedded Workspaces
visibility: const {} in the entity annotation tells the plugin to skip auto-routing; the host shell mounts the entity workspace itself via getConfig<T>().buildRoutes(). Use this for entities that live inside a parent workspace (Settings, Inbox tabs).
There must be one canonical EntityConfiguration per embedded entity, registered at the embedded URL. Don't register the same entity twice with different paths.
Helper Widgets
| Widget | Purpose |
|---|---|
EntityProvider<T> | Inherited config + controller |
EntityListView<T> | Full list surface |
EntityView<T> | Generic data-state widget |
EntityDetailView<T> | Renders a single entity through a detail layout |
EntityLink.of<T>(id) | Renders a referenced entity's name with cache-driven resolution |
ActiveStatusDot | Tiny indicator for isActive |
EntityPanelHeader | Standard chrome above the detail dock |
EntityFilterUrlSync<T> | Mirrors filter / search / sort to the URL |
Always check this list before writing a custom widget — the recurring drift in this codebase is reinventing one of these.
Navigation From Code
Never hardcode entity URLs. Resolve them through the entity config:
final route = vyuh.entity?.getConfig<Course>()?.route;
if (route != null) {
context.go(route.view(courseId));
context.go(route.create(queryParameters: {'level': 'beginner'}));
context.go(route.list(drafts: true, queryParameters: {'select': id}));
}For per-entity custom routes:
EntityNavigation.showCustom<Course>(context, courseId, 'preview');Next Steps
- Forms and Editors — single- and multi-part editors
- Search and Filters — the filter UI rendered inside
EntityListView - Drafts and Versioning — the drafts toggle and version tabs
- Custom Services — services consumed by detail tabs and cards