Skip to content

UI Helpers

View raw .md

Magic provides context-free UI feedback utilities, reactive widget builders, declarative page-title management, responsive layouts, form-processing helpers, and data-loading shortcuts that integrate with the controller state lifecycle.

On this page

Introduction#

Magic provides context-free UI feedback utilities through the Magic facade. Show snackbars, dialogs, confirmations, loading overlays, and toasts from anywhere—controllers, services, or callbacks—without passing BuildContext.

dart
// No context needed!
Magic.success('Done', 'User created successfully');
Magic.confirm(title: 'Delete?', message: 'This cannot be undone');
Magic.loading();

This is a game-changer for Flutter developers. No more passing context down through your widget tree just to show a simple notification.

Snackbars#

Show notification messages at the bottom of the screen.

Basic Snackbar#

dart
Magic.snackbar('Title', 'Message');

Typed Snackbars#

Use typed helpers for semantic styling:

dart
Magic.success('Success', 'Operation completed');  // Green
Magic.error('Error', 'Something went wrong');     // Red
Magic.info('Info', 'New update available');       // Blue
Magic.warning('Warning', 'Low storage space');    // Amber

With Custom Duration#

dart
Magic.snackbar(
  'Custom',
  'Message',
  type: 'info',
  duration: Duration(seconds: 5),
);

Dialogs#

Custom Dialogs#

Display any widget in a centered dialog:

dart
Magic.dialog(
  WDiv(
    className: 'p-6 bg-white rounded-xl max-w-md',
    children: [
      WText('Custom Dialog', className: 'text-xl font-bold'),
      WText('Any content goes here.', className: 'text-gray-600 mt-2'),
      WDiv(
        className: 'flex justify-end gap-4 mt-6',
        children: [
          WButton(
            onTap: () => Magic.closeDialog(),
            className: 'px-4 py-2 text-gray-600',
            child: WText('Cancel'),
          ),
          WButton(
            onTap: () {
              // Handle action
              Magic.closeDialog();
            },
            className: 'px-4 py-2 bg-primary text-white rounded-lg',
            child: WText('Confirm'),
          ),
        ],
      ),
    ],
  ),
);

Close Dialog#

dart
Magic.closeDialog();

Pass a value to answer the future Magic.dialog<T> returned; without one it completes with null, as a barrier tap does:

dart
final String? name = await Magic.dialog<String>(RenameForm());

// Inside RenameForm:
Magic.closeDialog('Living room');

Confirmation Dialogs#

Ask the user to confirm an action:

dart
final confirmed = await Magic.confirm(
  title: 'Delete Item',
  message: 'Are you sure you want to delete this item?',
  confirmText: 'Delete',
  cancelText: 'Cancel',
);

if (confirmed == true) {
  await deleteItem();
}

Dangerous Actions#

Use isDangerous: true for destructive confirmations (styled in red):

dart
final confirmed = await Magic.confirm(
  title: 'Delete Account',
  message: 'This action cannot be undone. All your data will be permanently removed.',
  confirmText: 'Delete Forever',
  isDangerous: true,
);

if (confirmed == true) {
  await deleteAccount();
  MagicRoute.to('/goodbye');
}

Loading Overlay#

Show a blocking loading overlay during async operations.

Show Loading#

dart
Magic.loading();

// With message
Magic.loading(message: 'Please wait...');

Close Loading#

dart
Magic.closeLoading();

Pattern: Wrap Async Operations#

dart
Future<void> submitForm() async {
  Magic.loading(message: 'Saving...');
  try {
    await api.save(data);
    Magic.success('Saved', 'Your changes have been saved');
  } catch (e) {
    Magic.error('Error', e.toString());
  } finally {
    Magic.closeLoading();
  }
}

Controller Pattern#

dart
class OrderController extends MagicController with MagicStateMixin<Order> {
  Future<void> placeOrder(Map<String, dynamic> data) async {
    Magic.loading(message: trans('orders.processing'));
    
    try {
      final response = await Http.post('/orders', data: data);
      
      if (response.successful) {
        setSuccess(Order.fromMap(response.body));
        Magic.success(
          trans('common.success'),
          trans('orders.placed'),
        );
        MagicRoute.to('/orders/${response['id']}');
      } else {
        Magic.error(trans('common.error'), response.errorMessage ?? '');
      }
    } finally {
      Magic.closeLoading();
    }
  }
}

Toast Messages#

Show brief, non-intrusive messages (centered, pill-shaped):

dart
Magic.toast('Item added to cart');

// Custom duration
Magic.toast('Copied!', duration: Duration(seconds: 1));

Toasts are ideal for quick confirmations that don't require user interaction.

Configuration#

Customize appearance via config/view.dart:

dart
Map<String, dynamic> get viewConfig => {
  'view': {
    // Snackbar styling
    'snackbar': {
      'duration': 4000,
      'style': {
        'success': 'bg-green-500 text-white p-4 rounded-lg',
        'error': 'bg-red-500 text-white p-4 rounded-lg',
        'info': 'bg-blue-500 text-white p-4 rounded-lg',
        'warning': 'bg-amber-500 text-white p-4 rounded-lg',
      },
    },
    
    // Dialog container
    'dialog': {
      'class': 'bg-white rounded-xl p-6 shadow-2xl w-80 max-w-md',
    },
    
    // Confirmation dialog
    'confirm': {
      'container_class': 'bg-white rounded-xl p-6 shadow-2xl w-80',
      'title_class': 'text-lg font-bold text-gray-900',
      'message_class': 'text-gray-600 mt-2',
      'button_cancel_class': 'px-4 py-2 text-gray-600',
      'button_confirm_class': 'px-4 py-2 bg-primary text-white rounded-lg',
      'button_danger_class': 'px-4 py-2 bg-red-500 text-white rounded-lg',
    },
    
    // Loading overlay
    'loading': {
      'container_class': 'bg-white rounded-xl p-6 shadow-2xl',
      'spinner_class': 'text-primary',
      'text_class': 'text-gray-600 text-sm mt-4',
    },
    
    // Toast
    'toast': {
      'duration': 2000,
      'class': 'bg-gray-800 text-white px-6 py-3 rounded-full shadow-lg',
    },
  },
};

MagicBuilder#

MagicBuilder<T> is a concise reactive widget that rebuilds a subtree whenever a ValueListenable<T> changes. It wraps ValueListenableBuilder but drops the unused context and child parameters so the builder closure stays focused on the value.

dart
MagicBuilder<int>(
  listenable: controller.counterNotifier,
  builder: (count) => WText('$count'),
)

The two required parameters are:

Parameter Type Purpose
listenable ValueListenable<T> The notifier to observe
builder Widget Function(T value) Called on every change with the new value

When you also need BuildContext inside the builder, wrap the MagicBuilder in a Builder:

dart
Builder(
  builder: (context) {
    final theme = Theme.of(context);
    return MagicBuilder<bool>(
      listenable: controller.enabledNotifier,
      builder: (enabled) => Switch(
        value: enabled,
        activeColor: theme.colorScheme.primary,
        onChanged: (_) => controller.toggle(),
      ),
    );
  },
)

The classic use case is a multi-section view where only one section depends on a notifier:

dart
class MonitorShowView extends MagicStatefulView<MonitorController> {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Rebuilds only when checksNotifier fires
        MagicBuilder<List<MonitorCheck>>(
          listenable: controller.checksNotifier,
          builder: (checks) => _buildStatsSection(checks),
        ),

        // Rebuilds only when the toggle changes
        MagicBuilder<bool>(
          listenable: controller.realTimeEnabledNotifier,
          builder: (enabled) => Switch(
            value: enabled,
            onChanged: (_) => controller.toggleRealTime(),
          ),
        ),
      ],
    );
  }
}

MagicSelector#

MagicSelector<C, T> rebuilds one subtree when one part of a controller changes, and leaves it alone the rest of the time. Reach for it when MagicBuilder cannot help, which is whenever the thing you want to watch is a plain field on a MagicController rather than a ValueListenable.

dart
MagicSelector<GuideController, String>(
  controller: controller,
  selector: (GuideController c) => c.countLabel,
  builder: (String label) => Text(label),
)

What it is for#

refreshUI() notifies every listener, and MagicStatefulViewState answers by calling setState on the whole view. That is the right default: a controller does not know which of its fields a screen reads, and a view that rebuilds is always correct.

It stops being cheap on a screen where one field changes often and most of the screen does not care. A search field is the worked example. Every keystroke is a notification, and one keystroke on a real screen was measured rebuilding 220 styled containers, almost none of which could have looked different.

How it avoids the rebuild#

It caches the widget the builder returned and, while the selected value compares equal, returns that same instance. Element.updateChild short circuits when the new widget is == to the mounted one, so an identical instance ends the descent there and the subtree is never visited.

That is what makes it work under a parent that rebuilds anyway. A widget that merely skipped its own setState would still be rebuilt from above, which is the situation inside every MagicStatefulView.

The contract#

builder must be a pure function of the value it is handed. A cached child cannot see anything else the closure captured:

dart
// WRONG: `total` is captured and nothing here watches it, so the line reads
// a stale total for as long as `count` happens not to move.
MagicSelector<C, int>(
  controller: c,
  selector: (C c) => c.count,
  builder: (int count) => Text('$count of $total'),
)

Select both instead. A Dart record has value equality, so it compares by content and the cache still holds:

dart
MagicSelector<C, (int, int)>(
  controller: c,
  selector: (C c) => (c.count, c.total),
  builder: ((int, int) v) => Text('${v.$1} of ${v.$2}'),
)

Reading an InheritedWidget inside the cached subtree needs no selection. Theme.of, MediaQuery.of and WindTheme.of register their own dependency, and the framework rebuilds a dependent element directly rather than through its parent.

The word doing the work there is inside. A lookup written in the enclosing build and captured by the closure is the captured-total hole wearing different clothes, and a dark-mode toggle is a likelier way to meet it:

dart
// WRONG: `context` belongs to the view's build, so a theme change rebuilds the
// view, the cache is served, and this subtree keeps the old theme.
MagicSelector<C, int>(
  controller: c,
  selector: (C c) => c.count,
  builder: (int n) => WDiv(className: WindTheme.of(context).surface),
)

// Right: the lookup happens inside the built subtree, which registers its own
// dependency.
MagicSelector<C, int>(
  controller: c,
  selector: (C c) => c.count,
  builder: (int n) => Builder(
    builder: (BuildContext inner) =>
        WDiv(className: WindTheme.of(inner).surface),
  ),
)

Equality#

Plain ==, deliberately. A selector that returns a freshly built List or Map never matches its own cache, because Dart gives collections identity equality, and the subtree then rebuilds on every notification exactly as it would have without the widget.

Deep comparison was the alternative and is worse where it matters: walking a ten thousand element list on every keystroke costs more than the rebuild it prevents. Select a scalar, a record, or an object whose identity is stable across notifications.

MagicTitle#

MagicTitle is a declarative title-override widget. Wrap any part of the widget tree with it to push a page title through TitleManager while the widget is mounted. The configured app-name suffix is applied automatically.

dart
class UserProfileView extends MagicView<UserController> {
  @override
  Widget build(BuildContext context) {
    return MagicTitle(
      title: 'Edit Profile',
      child: Column(
        children: [
          // page content
        ],
      ),
    );
  }
}

The constructor accepts two required parameters:

Parameter Type Purpose
title String The page title to display
child Widget The subtree to render

MagicTitle calls TitleManager.instance.setOverride(title) in initState, updates it in didUpdateWidget when title changes, and clears the override in dispose. This makes it safe for data-dependent titles that resolve after the route is already mounted:

dart
MagicTitle(
  title: controller.isSuccess ? controller.rxState!.name : 'Loading...',
  child: _buildContent(),
)

MagicResponsiveView#

MagicResponsiveView<T extends MagicController> is an abstract view base class that dispatches to a different widget method depending on the current screen width. Breakpoints are read from the active WindThemeData.screens so they stay consistent with your Wind utility classes.

Subclass it and override the layout methods you need:

dart
class DashboardView extends MagicResponsiveView<DashboardController> {
  const DashboardView({super.key});

  @override
  Widget phone(BuildContext context) => MobileDashboard();

  @override
  Widget tablet(BuildContext context) => TabletDashboard();

  @override
  Widget desktop(BuildContext context) => DesktopDashboard();
}

The dispatch order is:

Condition Method called
width < 320 px watch(context)
width < sm (default 640 px) phone(context)
width < lg (default 1024 px) tablet(context)
width >= lg desktop(context)

Only phone is abstract; the others fall back up the chain by default (watch calls phone, tablet calls phone, desktop calls tablet). Override only the breakpoints where your layout actually differs.

Extended Breakpoints#

For fine-grained control over all five Wind breakpoints, extend MagicResponsiveViewExtended<T> instead:

dart
class AdminView extends MagicResponsiveViewExtended<AdminController> {
  const AdminView({super.key});

  @override
  Widget xs(BuildContext context) => MobileAdminView();   // < 320 px

  @override
  Widget sm(BuildContext context) => PhoneAdminView();    // >= 320, < sm

  @override
  Widget lg(BuildContext context) => TabletAdminView();   // >= md, < lg

  @override
  Widget xxl(BuildContext context) => WideAdminView();    // >= xl
}

Methods that you do not override cascade to the next smaller breakpoint, so you only implement the layouts where behavior changes.

Context Helpers#

The MagicResponsiveContext extension on BuildContext exposes breakpoint checks for use inside any build method:

dart
Widget build(BuildContext context) {
  if (context.isDesktop) {
    return Row(children: [_sidebar(), _content()]);
  }
  return _content();
}

Available getters:

Getter Type Meaning
screenWidth double MediaQuery.of(context).size.width
screenHeight double MediaQuery.of(context).size.height
isPhone bool Width is below the sm breakpoint
isTablet bool Width is between sm and lg
isDesktop bool Width is at or above lg
activeBreakpoint String Current Wind breakpoint name (e.g. 'md')
isAtLeast(String) bool True if screen is at or above the given breakpoint

Advanced MagicFormData#

The basics of MagicFormData are covered in the Forms page. This section documents the processing-state API that powers submit flows.

Processing State#

process<T>() wraps an async action with automatic processing-state management. It sets isProcessing to true before the action and back to false when it completes (whether it succeeds or throws). If process() is called again while already running, it throws a StateError to prevent duplicate submissions.

dart
Future<void> _submit() async {
  if (!form.validate()) return;

  await form.process(() => controller.updateProfile(
    name: form.get('name'),
    email: form.get('email'),
  ));
}
Member Type Description
process<T>(Future<T> Function() action) Future<T> Runs action with automatic isProcessing tracking
isProcessing bool true while process() is executing
processingListenable ValueListenable<bool> Notifier backing isProcessing; use with MagicBuilder

Granular Rebuilds with processingListenable#

Use form.processingListenable together with MagicBuilder<bool> to rebuild only the submit button while the rest of the form stays static. This keeps the widget tree stable and preserves dusk locators during the async operation:

dart
MagicBuilder<bool>(
  listenable: form.processingListenable,
  builder: (isProcessing) => WButton(
    semanticLabel: 'Save profile',
    isLoading: isProcessing,
    onTap: isProcessing ? null : _submit,
    child: WText(trans('common.save')),
  ),
)

Passing null to onTap while isProcessing is true also prevents double-tap submissions because form.process() would throw a StateError on a concurrent call regardless.

Data Loading Helpers#

Controllers that mix in MagicStateMixin<T> gain two convenience methods for the most common data-fetch patterns. Both methods handle the full loading/success/error/empty lifecycle automatically.

fetchList#

fetchList<E>() fetches a paginated or plain list from a URL and maps each item through a fromMap factory. It is generic over the element type E and expects the controller's state type T to be List<E>.

dart
class UserController extends MagicController with MagicStateMixin<List<User>> {
  @override
  void onInit() {
    super.onInit();
    fetchList<User>('/api/users', User.fromMap);
  }
}

Signature:

dart
Future<void> fetchList<E>(
  String url,
  E Function(Map<String, dynamic>) fromMap, {
  String dataKey = 'data',
  Map<String, dynamic>? query,
  Map<String, String>? headers,
})
Parameter Default Description
url required The endpoint to GET
fromMap required Factory that maps a JSON object to type E
dataKey 'data' Key in the response payload that holds the list
query null Optional query-string parameters
headers null Optional request headers

The helper sets setLoading() before the request. On success it calls setSuccess(items). If the list is absent or empty it calls setEmpty(). On a failed response it calls setError(message).

fetchOne#

fetchOne() fetches a single resource and maps it through a fromMap factory.

dart
class UserController extends MagicController with MagicStateMixin<User> {
  @override
  void onInit() {
    super.onInit();
    fetchOne('/api/users/1', User.fromMap);
  }
}

Signature:

dart
Future<void> fetchOne(
  String url,
  T Function(Map<String, dynamic>) fromMap, {
  String dataKey = 'data',
  Map<String, dynamic>? query,
  Map<String, String>? headers,
})

The parameters are identical to fetchList. The helper sets setLoading(), then on a successful response calls setSuccess(fromMap(data[dataKey])). On failure or a malformed payload it calls setError(message).

Rendering State with renderState#

After either fetch helper resolves, use renderState to build the UI declaratively against rxStatus:

dart
class UserProfileView extends MagicView<UserController> {
  @override
  Widget build(BuildContext context) {
    return controller.renderState(
      (user) => UserCard(user: user),
      onLoading: const Center(child: CircularProgressIndicator()),
      onError: (msg) => WText('Error: $msg', className: 'text-red-500'),
      onEmpty: WText('No user found.', className: 'text-gray-400'),
    );
  }
}

renderState uses AnimatedBuilder on the controller so the view rebuilds automatically whenever status or state changes. All four callbacks are optional; omitted states fall back to built-in default widgets.

esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.