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.

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