Skip to content

View Registry

View raw .md
On this page

Introduction#

MagicStarterViewRegistry is a string-keyed view factory that makes every screen in the magic_starter plugin overridable by the host app. Instead of hard-wiring view constructors in routes, all views and layouts are resolved through this registry at runtime — allowing host apps to swap any screen without touching routes or controllers.

The registry lives at lib/src/ui/magic_starter_view_registry.dart and is held as a private field on MagicStarterManager.

MagicStarterViewRegistry Class#

The registry maintains three internal maps — one for view builders, one for layout builders, and one for modal builders:

dart
final Map<String, MagicStarterViewBuilder> _builders = {};
final Map<String, MagicStarterLayoutBuilder> _layouts = {};
final Map<String, MagicStarterModalBuilder> _modals = {};

The builder typedefs:

dart
typedef MagicStarterViewBuilder = Widget Function();
typedef MagicStarterLayoutBuilder = Widget Function(Widget child);
typedef MagicStarterModalBuilder = Widget Function();

Registering Views#

dart
void register(String key, MagicStarterViewBuilder builder)

Stores a view builder under the given key. If the key already exists, the previous builder is replaced.

Registering Layouts#

dart
void registerLayout(String key, MagicStarterLayoutBuilder builder)

Stores a layout builder under the given key. Layout builders receive a Widget child and wrap it in a layout shell (sidebar, header, footer, etc.).

Registering Modals#

dart
void registerModal(String key, MagicStarterModalBuilder builder)

Stores a modal builder under the given key. Modal builders return a widget that is displayed inside a dialog shell. If the key already exists, the previous builder is replaced.

Checking Existence#

dart
bool has(String key)       // true when a view builder exists for key
bool hasLayout(String key) // true when a layout builder exists for key
bool hasModal(String key)  // true when a modal builder exists for key

Used internally by registerDefaultViews() to implement the "register if absent" pattern.

Building Widgets#

dart
Widget make(String key)
Widget makeLayout(String key, {required Widget child})
Widget makeModal(String key)

All three methods throw StateError when the requested key is not registered:

dart
throw StateError('No view builder registered for key "$key".');

Clearing the Registry#

dart
void clear()

Removes all view, layout, and modal builders. Used in test teardowns and by MagicStarterManager.reset().

Default View Keys#

These are registered by MagicStarterManager.registerDefaultViews() at construction time:

Auth views (always registered):

Key View
auth.login MagicStarterLoginView
auth.register MagicStarterRegisterView
auth.forgot_password MagicStarterForgotPasswordView
auth.reset_password MagicStarterResetPasswordView

Auth views (feature-gated):

Key View Feature Flag
auth.verify_email MagicStarterVerifyEmailView hasEmailVerificationFeatures()
auth.two_factor_challenge MagicStarterTwoFactorChallengeView hasTwoFactorFeatures()
auth.otp_verify MagicStarterOtpVerifyView hasPhoneOtpFeatures()
auth.handoff MagicStarterHandoffView hasHandoffFeatures()
settings.security.connected_accounts MagicStarterConnectedAccountsView hasSocialLoginFeatures(); the hub row also needs a bridge from MagicStarter.useSocialAuth

Settings views (feature-gated):

Key View Feature Flag
settings.changelog MagicStarterChangelogView hasReleasesFeatures()

Profile views (always registered):

Key View
profile.settings MagicStarterProfileSettingsView

Team views (require hasTeamFeatures()):

Key View
teams.create MagicStarterTeamCreateView
teams.settings MagicStarterTeamSettingsView
teams.invitation_accept MagicStarterTeamInvitationAcceptView

Support views (mounted by registerMagicStarterSupportRoutes() when hasSupportFeatures()):

Key View
support.index MagicStarterSupportIndexView
support.create MagicStarterSupportCreateView
support.thread MagicStarterSupportThreadView

Default Layout Keys#

Key Layout Purpose
layout.app MagicStarterAppLayout Authenticated pages — sidebar, header, bottom nav
layout.guest MagicStarterGuestLayout Auth pages — centered card, minimal chrome

Default Modal Keys#

Key Modal Purpose
modal.confirm MagicStarterConfirmDialog Generic confirmation with ConfirmDialogVariant (primary/danger/warning)
modal.password_confirm MagicStarterPasswordConfirmDialog Password-confirmation prompt with inline error handling
modal.two_factor MagicStarterTwoFactorModal Multi-step 2FA wizard (QR code, OTP confirm, recovery codes)

Overriding Views#

Register your custom view builder using the MagicStarter.view accessor:

dart
// In AppServiceProvider.boot()
MagicStarter.view.register(
  'auth.login',
  () => const MyCustomLoginView(),
);

Because registerDefaultViews() uses "register if absent" logic, overrides registered before the manager is instantiated take precedence automatically. Overrides registered after construction simply replace the existing builder.

Composing a Settings Page#

The settings hub (settings.hub, routed at /settings) is three sections: Account, Security and Preferences. Each is a public widget that carries its own header, rows and gating, so a host that wants one settings page with sections of its own registers its view under settings.hub and places the starter's groups beside its sections, without copying a feature or ability check:

dart
class AppSettingsView extends StatelessWidget {
  const AppSettingsView({super.key});

  @override
  Widget build(BuildContext context) {
    return MSPageScaffold(
      title: trans('magic_starter.nav.settings'),
      children: [
        MSSettingsSection(
          header: 'Playback',
          children: [
            MSSettingsNavRow(title: 'Player', to: '/settings/player'),
          ],
        ),
        MagicStarterSettingsAccountGroup(),
        if (MagicStarterSettingsSecurityGroup.hasRows)
          MagicStarterSettingsSecurityGroup(),
        MagicStarterSettingsPreferencesGroup(),
        if (MagicStarterConfig.hasReleasesFeatures())
          const MagicStarterAppVersion(),
      ],
    );
  }
}

// In AppServiceProvider.boot()
MagicStarter.view.register('settings.hub', () => const AppSettingsView());
Widget Rows and their gates
MagicStarterSettingsAccountGroup Profile (always); for a guest (denied starter.delete-account) also the upgrade and sign-in rows
MagicStarterSettingsSecurityGroup Two-factor (two_factor and starter.manage-two-factor), Password (starter.update-password), Browser sessions (sessions), Connected accounts (social_login and a MagicStarter.useSocialAuth bridge)
MagicStarterSettingsPreferencesGroup Appearance (always), Notifications (notifications), Language (extended_profile), Timezone (timezones), Newsletter (newsletter and starter.manage-newsletter), What's new (releases)
  • The Security group renders a zero-size box when none of its rows is reachable. MSPageScaffold still puts a gap around a zero-size child, so guard the group with hasRows; the other two always have a row.
  • The groups read the user, the abilities and the flags when they build and listen to none of them. Leave them non-const when the page is rebuilt on an auth change, or the Profile subtitle and the two-factor value keep what they read first.
  • The version caption is not part of a group: place MagicStarterAppVersion yourself, as above, to close the page the way the hub does.

Overriding Layouts#

Replace the default layout wrapper for auth or authenticated pages:

dart
MagicStarter.view.registerLayout(
  'layout.guest',
  (child) => MyGuestLayout(child: child),
);

The layout builder receives the view widget as child and must render it somewhere in the layout tree.

dart
MagicStarter.view.registerLayout(
  'layout.app',
  (child) => MyAppLayout(child: child),
);

The child arrives route-keyed#

makeLayout wraps child in a KeyedSubtree keyed on the current route path before the builder sees it, so your layout renders it wherever you like and keeps the per-route remount the default layouts carry. You do not add the key yourself and should not strip it.

It matters because a layout is persistent: one element holds one child slot that every route in the group takes turns in. Without the key that slot is reused across routes, and swapping one scrollable view for another tears down render objects in a confused order under accumulated navigation. The guest layout has the sibling of the same problem: under RouteTransition.none the outgoing and incoming routes are briefly mounted together, so a login to register move reparents the previous page's element tree instead of unmounting it.

The wrap goes around the child rather than around your layout's result, so your navigation chrome is NOT torn down on a route change.

Overriding Modals#

Replace the default modal for any registered key:

dart
MagicStarter.view.registerModal(
  'modal.confirm',
  () => const MyCustomConfirmDialog(),
);

Modal builders follow the same "register if absent" pattern as views and layouts. Overrides registered before the manager is instantiated take precedence automatically.

Builder Slots#

Slots allow host apps to inject custom widgets into specific sections of plugin views without overriding the entire view. Each view defines named insertion points (slots) that accept a Widget Function(BuildContext) builder.

Registering a Slot#

dart
MagicStarter.view.slot('auth.login', 'header', (context) {
  return WText('Welcome back!', className: 'text-2xl font-bold text-center');
});

MagicStarter.view.slot('profile.settings', 'afterSection:info', (context) {
  return MyCustomBillingSection();
});

The slot() method takes three arguments: the view key, the slot name, and a builder function. Internally, slots are stored under the compound key 'viewKey.slotName' (e.g. 'auth.login.header').

Checking and Building Slots#

Views check for registered slots at build time:

dart
// Inside a view's build method
final headerSlot = MagicStarter.view.buildSlot('auth.login', 'header', context);
if (headerSlot != null) ...[headerSlot, const WSpacer(className: 'h-4')],

buildSlot() returns null when no slot is registered, so views can safely use conditional spreads.

dart
bool hasSlot(String viewKey, String slot)
Widget? buildSlot(String viewKey, String slot, BuildContext context)

Available Slots#

Each view defines its own slot names. Slot registration must happen before the view is built (ideally in AppServiceProvider.boot()). Slots are cleared by registry.clear() and re-registered views do not re-populate slots.

Route Integration#

Routes resolve views and layouts through the registry rather than constructing widgets directly:

dart
// Inside route registration
final view = MagicStarter.view.make('auth.login');
final page = MagicStarter.view.makeLayout('layout.guest', child: view);

This indirection is what makes the entire UI swappable. Route files never import view classes — they only reference string keys. The registry resolves the correct widget at runtime, whether it is the plugin default or a host app override.

dart
// Typical route registration pattern
void registerMagicStarterAuthRoutes(MagicRouter router) {
  router.group(MagicStarterConfig.authPath(), (router) {
    router.route('/login', (context) {
      final view = MagicStarter.view.make('auth.login');
      return MagicStarter.view.makeLayout('layout.guest', child: view);
    });
  });
}

Testing#

Call MagicStarter.view.clear() in setUp() to reset the registry between tests:

dart
setUp(() {
  MagicApp.reset();
  Magic.flush();
  // Re-bind manager (constructor re-registers defaults)
  Magic.singleton('magic_starter', () => MagicStarterManager());
});

To test with a custom view:

dart
test('uses custom login view when overridden', () {
  MagicStarter.view.register(
    'auth.login',
    () => const Text('Custom Login'),
  );

  final widget = MagicStarter.view.make('auth.login');
  expect(widget, isA<Text>());
});

To verify a missing key throws:

dart
test('throws StateError for unregistered key', () {
  MagicStarter.view.clear();
  expect(
    () => MagicStarter.view.make('auth.login'),
    throwsStateError,
  );
});
esc

Search index did not load.

No match for "".

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