Skip to content

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:

dart
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;
}
SlotWhat it renders
listTables, grids, mobile compact rows — any view that takes a list of entities
detailsTabs on the detail page (Overview, Audit, Versions, Workflow, …)
itemsSingle-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
comparisonsMulti-entity rendering primitives (audit delta, future trend matrix). Looked up via getComparison(id)
summaryOptional summary card shown above the detail tabs
dashboardOptional aggregate dashboard at /<prefix>/dashboard
analyticsAggregate views (charts, heatmaps, trends)

Layouts marked with an Authorize policy are filtered automatically before render via EntityLayouts.filterByPermission.

dart
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.

ShapeUse caseKey class
List (default)Master-detail entity surfaces (Areas, Equipment, Activities, …)EntityWorkspace<T>
SingletonSingle-instance entities (Settings, P&D Module Config)SingletonEntityWorkspace<T>
GroupedTabbed 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.

dart
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):

dart
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
dart
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>.

dart
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):

SlotReturnsNotes
createRoutesingle GoRoutenull when no editor
editRoutesingle GoRoutenull when no editor
dashboardRoutesingle GoRoutenull when no dashboard layout
viewTabsRoutelist of GoRoutesOne per detail tab; empty when no detail layouts
customRoutesSlotlist of GoRoutesBuilt from the constructor's customRoutes
singletonMainRoutesingle GoRouteUsed when isSingleton: true
singletonEditRoutesingle GoRouteUsed when isSingleton: true and editor present
singletonTabsRoutelist of GoRoutesUsed when isSingleton: true

Replace just the create route ​

dart
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) ​

dart
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:

ConstructorURLBuilderUse 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:

CustomRouteDisplayDesktopMobile
fullWidthNoTransitionPage (full-page)ModalSheetPage
dialogDialogPage (CDX DialogShell)ModalSheetPage
dart
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:

dart
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.

dart
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);
AccessorReturns
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.

The default list surface uses entity-system layouts backed by CDX UI view packages: Data Tables, Kanban Boards, Tree Views, and Calendar Views. Use entity layouts when the surface is part of an entity workspace; import the CDX UI view packages directly when building a standalone tool.

dart
EntityListView<Course>(
  listLayouts: courseConfig.layouts.list,
  showFilters: true,
  showHeader: true,
)
ParameterPurpose
listLayoutsAvailable list layouts (table, grid, …)
selectedEntityOverride for the selected entity
onSelectionChangedSingle-selection callback
onMultiSelectionChangedMulti-selection callback (batch actions)
showSidePanelEmbed a docked detail panel
panelModeview or edit
showFilters / showHeaderUI 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) ​

dart
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 widget
  • QueryLoading (refetch with cached data) → content + progress bar
  • QuerySuccess → content
  • QueryError (with cached data) → stale content + error banner
  • QueryError (no cached data) → error widget

One-shot future ​

dart
EntityView<Course>(
  future: courseApi.getOne('course-123'),
  builder: (context, course) => CourseDetail(course: course),
)

Constructor ​

dart
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:

dart
// 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:

dart
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:

dart
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 ​

WidgetPurpose
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
ActiveStatusDotTiny indicator for isActive
EntityPanelHeaderStandard 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.

Never hardcode entity URLs. Resolve them through the entity config:

dart
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:

dart
EntityNavigation.showCustom<Course>(context, courseId, 'preview');

Next Steps ​