View Registry
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:
final Map<String, MagicStarterViewBuilder> _builders = {};
final Map<String, MagicStarterLayoutBuilder> _layouts = {};
final Map<String, MagicStarterModalBuilder> _modals = {};
The builder typedefs:
typedef MagicStarterViewBuilder = Widget Function();
typedef MagicStarterLayoutBuilder = Widget Function(Widget child);
typedef MagicStarterModalBuilder = Widget Function();
Registering Views#
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#
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#
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#
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#
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:
throw StateError('No view builder registered for key "$key".');
Clearing the Registry#
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:
// 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:
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.
MSPageScaffoldstill puts a gap around a zero-size child, so guard the group withhasRows; 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
MagicStarterAppVersionyourself, as above, to close the page the way the hub does.
Overriding Layouts#
Replace the default layout wrapper for auth or authenticated pages:
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.
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:
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#
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:
// 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.
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:
// 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.
// 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:
setUp(() {
MagicApp.reset();
Magic.flush();
// Re-bind manager (constructor re-registers defaults)
Magic.singleton('magic_starter', () => MagicStarterManager());
});
To test with a custom view:
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:
test('throws StateError for unregistered key', () {
MagicStarter.view.clear();
expect(
() => MagicStarter.view.make('auth.login'),
throwsStateError,
);
});
Related#
- MagicStarterManager — central singleton that holds the view registry and registers default views
- MagicStarterServiceProvider — bootstrap entry point that instantiates the manager
- Route Registration — how routes consume the view registry
- Magic Framework — IoC Container — singleton and factory binding reference