Skip to content

State Views ​

CDX UI ships six state-aware widgets that every CDX screen composes from. The goal: stop dropping a raw CircularProgressIndicator (or an ad-hoc empty state) into production screens.

Live examples ​

Open the full States example.

ContentStateView ​

The one renderer for content that loads. Hold a ContentState<T> in a controller or store (one named constructor per ContentStatus) and hand it to ContentStateView; it renders every status with LoadingState, EmptyState and ErrorState, and keeps data that is still valid on screen under a progress strip or a dismissible banner.

dart
ContentStateView<List<Area>>(
  state: controller.state, // ContentState<List<Area>>
  builder: (context, areas) => AreasList(areas: areas),
  onRetry: controller.reload,   // failed / unauthorized
  onRefresh: controller.reload, // stale / offline banner action
  onRemedy: controller.handle,  // remedies of a StructuredException
)
StatusConstructorRenders
idleContentState.idle()nothing
loadingContentState.loading(label:)LoadingState
refreshingContentState.refreshing(data)content under a LineProgress strip
readyContentState.ready(data)content
emptyContentState.empty(title:, message:)EmptyState
partialContentState.partial(data, warnings:)content under a warning banner
staleContentState.stale(data, message:)content under a banner with Refresh
offlineContentState.offline(data:, message:)cached content under a banner with Reconnect
unauthorizedContentState.unauthorized(error)ErrorState titled "Access required"
failedContentState.failed(error, title:, message:)ErrorState, with the remedies of a StructuredException

The strip and banners overlay the content, so moving between ready, refreshing, partial, stale and offline never moves the content or rebuilds its subtree (focus and scroll position survive a refresh). loadingBuilder, emptyBuilder and errorBuilder replace a default state when a surface needs its own.

AsyncView ​

Single declarative wrapper for Future / Stream / direct data sources. Internally backed by raw MobX observables and a reactive Observer, so a stream that emits new values will rebuild in place. It renders through ContentStateView, so its loading, error, empty and content outcomes are the same ones a ContentState produces.

dart
AsyncView<List<Area>>(
  future: api.getAll(),
  contentBuilder: (context, areas) => AreasList(areas: areas),
  // Optional overrides — defaults to LoadingState / EmptyState / ErrorState
  loadingBuilder: (_) => const LoadingState(message: 'Loading areas…'),
  emptyBuilder: (_) => const EmptyState(title: 'No areas', subtitle: 'Add your first area to get started.'),
  errorBuilder: (context, error, retry) => ErrorState(error: error, onRetry: retry),
  isEmpty: (areas) => areas.isEmpty,             // optional override
  onRetry: () => store.reload(),
)

Source modes ​

dart
AsyncView<T>(future: ...)   // one-shot Future
AsyncView<T>(stream: ...)   // ongoing Stream — re-renders on every emission
AsyncView<T>(data: ...)     // direct value (useful with MobX observables)

Exactly one of future, stream, data must be provided.

Empty detection ​

By default, AsyncView treats null, empty List, empty Map, empty Set, and empty Iterable as empty. Override via isEmpty for custom shapes.

LoadingState ​

Centered CircularProgress with optional primary message + subtitle.

dart
LoadingState(message: 'Saving…', subtitle: 'This may take a few seconds.')

EmptyState ​

Centered icon + title + subtitle + optional action widget.

dart
EmptyState(
  icon: FluentIcons.mail_inbox_24_regular,
  title: 'No notifications',
  subtitle: 'You are all caught up.',
  action: Button.outlined(label: 'Refresh', onPressed: store.refresh),
)

Defaults: mail_inbox_24_regular icon, 'No data' title.

ErrorState ​

Centered tappable error icon + title + message + optional retry button. Tapping the icon opens a diagnostics dialog with the error type, full message, timestamp, and a copy-to-clipboard stack trace section.

dart
ErrorState(
  error: snapshot.error,
  stackTrace: snapshot.stackTrace,
  errorTimestamp: DateTime.now().toUtc(),
  onRetry: store.refresh,
  title: 'Could not load areas',
  subtitle: 'Try again or contact support if the problem persists.',
)

The diagnostics dialog renders even when onRetry is null — users can still inspect and copy details.

Pass remedies (from vyuh_errors, typically StructuredException.remedies) and onRemedy to show suggested ways out: a Hint renders as text, every other remedy with a label as a button that calls onRemedy. message replaces the message resolved from error.

dart
ErrorState(
  error: error,
  remedies: const [Hint('Check the VPN.'), Refresh(label: 'Reload')],
  onRemedy: controller.handle,
)

RouteErrorView ​

Full-page error surface for GoRouter.errorBuilder and shell-level exception handlers. Generic — every parameter is opt-in.

dart
GoRouter(
  errorBuilder: (context, state) => RouteErrorView(
    title: 'Page not found',
    subtitle: state.error?.toString() ?? 'The page you requested does not exist.',
    onHomePressed: () => context.go('/'),
    homeLabel: 'Back to Home',
  ),
  ...
);

In non-release builds, when error is non-null the view also renders a collapsible "Error details" panel with the raw error text.

Field formatting ​

State views often render formatted values (dates, numbers, durations, entity references). The cdx_field_formatting package ships FieldFormatter.instance — a singleton dispatch registry that gives every CDX surface the same formatted output.

dart
import 'package:cdx_field_formatting/cdx_field_formatting.dart';

// Settings (locale, date pattern, currency, timezone)
FieldFormatter.instance.updateSettings(FormatSettings(
  locale: 'en-US',
  dateFormat: 'MMM d, y',
  timeFormat: 'h:mm a',
));

// Typed convenience
FieldFormatter.instance.formatDate(area.createdAt);
FieldFormatter.instance.formatDateTime(area.updatedAt);
FieldFormatter.instance.formatNumber(123456.789);
FieldFormatter.instance.formatCurrency(42.5, 'USD');
FieldFormatter.instance.formatBytes(1234567);
FieldFormatter.instance.formatDuration(const Duration(minutes: 90));

// Field-name dispatch — dispatches via aliases or regex patterns
FieldFormatter.instance.formatField('created_at', area.createdAt);  // matches /_at$/ → datetime
FieldFormatter.instance.formatField('is_active', area.isActive);    // matches /^is_/ → bool

// Widget version (for JSON / DateTime / icon-value formatters)
FieldFormatter.instance.formatFieldWidget(context, 'created_at', area.createdAt);

Built-in field-name patterns include: _at$ → datetime, _on$ → date, _date$ → date, _time$ → time, _by$ → user_id, _count$ → integer, _percent$ → percent, ^is_ → bool, ^has_ → bool, and more — see Field Formatting for the full package surface.

Every formatter reads FormatSettings reactively (via MobX), so a single updateSettings call live-updates every formatted value in the app inside an Observer.

  • Cards — SectionCard.stateful for in-card async states
  • Theming — error / surface tokens
  • Layout — MasterDetailScaffold hosts AsyncView in master + detail