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.
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
)| Status | Constructor | Renders |
|---|---|---|
idle | ContentState.idle() | nothing |
loading | ContentState.loading(label:) | LoadingState |
refreshing | ContentState.refreshing(data) | content under a LineProgress strip |
ready | ContentState.ready(data) | content |
empty | ContentState.empty(title:, message:) | EmptyState |
partial | ContentState.partial(data, warnings:) | content under a warning banner |
stale | ContentState.stale(data, message:) | content under a banner with Refresh |
offline | ContentState.offline(data:, message:) | cached content under a banner with Reconnect |
unauthorized | ContentState.unauthorized(error) | ErrorState titled "Access required" |
failed | ContentState.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.
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
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.
LoadingState(message: 'Saving…', subtitle: 'This may take a few seconds.')EmptyState
Centered icon + title + subtitle + optional action widget.
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.
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.
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.
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.
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.