search ESC

Searching…

No results for "".

Type at least 2 characters to search.

Docs

UI Helpers

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.

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.

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

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

Typed Snackbars

Use typed helpers for semantic styling:

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

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

Dialogs

Custom Dialogs

Display any widget in a centered dialog:

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

Magic.closeDialog();

Confirmation Dialogs

Ask the user to confirm an action:

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

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

Magic.loading();

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

Close Loading

Magic.closeLoading();

Pattern: Wrap Async Operations

Future 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

class OrderController extends MagicController with MagicStateMixin {
  Future placeOrder(Map 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):

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:

Map 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',
    },
  },
};

[!TIP] Use Wind UI utility classes in your config for consistent styling across all UI helpers.

MagicBuilder

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

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

The two required parameters are:

Parameter Type Purpose
listenable ValueListenable 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:

Builder(
  builder: (context) {
    final theme = Theme.of(context);
    return MagicBuilder(
      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:

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

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

[!TIP] For E2E drivability, prefer MagicBuilder over setState on the parent widget. Targeted subtree rebuilds keep interactive element identity stable so dusk agents do not lose their references mid-action.

MagicSelector

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

MagicSelector(
  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:

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

MagicSelector(
  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:

// 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(
  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(
  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.

[!NOTE] MagicSelector does not replace MagicBuilder. Use MagicBuilder when the source already is a ValueListenable, such as MagicFormData.processingListenable; use MagicSelector when the source is the controller itself.

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.

class UserProfileView extends MagicView {
  @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:

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

[!NOTE] MagicTitle updates the browser tab title on web and integrates with route-level title observers. It does not replace the title parameter on MagicRoute.page(). Use MagicRoute.page() titles for static route titles and MagicTitle for dynamic or data-dependent ones.

MagicResponsiveView

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

class DashboardView extends MagicResponsiveView {
  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 instead:

class AdminView extends MagicResponsiveViewExtended {
  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:

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() 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.

Future _submit() async {
  if (!form.validate()) return;

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

Granular Rebuilds with processingListenable

Use form.processingListenable together with MagicBuilder 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:

MagicBuilder(
  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.

[!TIP] This pattern is the scaffolded default in generated view stubs.

Data Loading Helpers

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

fetchList

fetchList() 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.

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

Signature:

Future fetchList(
  String url,
  E Function(Map) fromMap, {
  String dataKey = 'data',
  Map? query,
  Map? 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.

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

Signature:

Future fetchOne(
  String url,
  T Function(Map) fromMap, {
  String dataKey = 'data',
  Map? query,
  Map? 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:

class UserProfileView extends MagicView {
  @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.