Views and Layouts
How a page-level view binds to its controller, how the app and guest shells wrap it, and how a host replaces any screen or shell through the view registry.
On this page
Introduction#
The view layer separates rendering from state: views render, controllers hold state and business logic. Every page-level view extends MagicStatefulView<ControllerType>, which binds the view to its controller singleton and provides lifecycle hooks. Two layout shells wrap the views: MagicStarterAppLayout for authenticated pages and MagicStarterGuestLayout for auth pages. The views are built with Wind widgets (WDiv, WText, WButton and the rest); Icons is the only Material import.
All views and layouts are registered by string key in a registry, so every screen is overridable by the host app.
Quick Reference#
Replace a view or a layout by registering a builder under its key, in your own provider's boot():
MagicStarter.view.register('auth.login', () => const MyLoginView());
MagicStarter.view.registerLayout('layout.app', (child) => MyAppShell(child: child));
| View keys | Registered when |
|---|---|
auth.login, auth.register, auth.forgot_password, auth.reset_password |
Always |
auth.verify_email |
email_verification |
auth.two_factor_challenge |
two_factor |
auth.otp_verify |
phone_otp |
auth.handoff |
handoff |
settings.hub, profile.profile, settings.appearance, settings.security.password |
Always |
settings.language |
extended_profile |
settings.timezone |
timezones |
settings.newsletter |
newsletter |
settings.changelog |
releases |
settings.security.two_factor |
two_factor |
settings.security.sessions |
sessions |
settings.security.connected_accounts |
social_login |
teams.create, teams.settings, teams.invitation_accept |
teams |
teams.billing |
billing |
support.index, support.create, support.thread |
support |
support.feedback |
support and support_feedback |
support.chat, support.chat_panel |
support and support_chat |
| Layout keys | Layout |
|---|---|
layout.app |
The authenticated shell with sidebar and navigation |
layout.guest |
The centred shell for auth pages |
profile.settings is a long single-page profile view the package can publish (starter:publish) but no default registers. notifications.list and notifications.preferences are not on MagicStarter.view either: those screens belong to magic_notifications and live on its own Notify.view; see Notifications.
MagicStatefulView#
Page-level views extend MagicStatefulView<ControllerType> with a state class that extends MagicStatefulViewState:
class ProjectsView extends MagicStatefulView<ProjectController> {
const ProjectsView({super.key});
@override
State<ProjectsView> createState() => _ProjectsViewState();
}
class _ProjectsViewState
extends MagicStatefulViewState<ProjectController, ProjectsView> {
@override
void onInit() {
controller.clearErrors();
controller.setEmpty();
}
@override
Widget build(BuildContext context) {
return controller.renderState(
(_) => _buildList(),
onEmpty: _buildList(),
onError: (message) => _buildList(errorMessage: message),
);
}
}
The base class gives you the controller singleton as controller, plus the onInit() and onClose() hooks. Only full-page views that need a controller binding use it; components such as MSUserProfileDropdown extend StatelessWidget or StatefulWidget directly.
View Lifecycle#
onInit() runs once when the state is initialised: clear errors, set the initial state or trigger a fetch. onClose() runs when the state is disposed: dispose form data and local notifiers.
@override
void onClose() => form.dispose();
A view with several forms disposes every MagicFormData in onClose(), and field names must not collide across forms in the same view. A view that runs while its route is being built should not notify: the profile and team controllers expose resetQuietly() for that.
State Rendering#
controller.renderState() picks a builder from the controller's MagicStateMixin state. The first positional argument builds the success state (it receives the state value):
| Parameter | When | Typical use |
|---|---|---|
onLoading |
A request is in flight | A skeleton |
onEmpty |
No data loaded yet | The empty form or a placeholder |
onError |
The controller called setError() |
The form with an error banner |
Read controller.isLoading at the top of a build method to disable a submit button:
WButton(
onTap: controller.isLoading ? null : _submit,
className: 'w-full py-3 rounded-lg bg-primary text-white',
child: WText(controller.isLoading ? trans('common.loading') : trans('auth.login')),
)
Form Handling#
Forms are MagicFormData instances with empty-string defaults for text fields and typed defaults for the rest:
late final form = MagicFormData(
{
'email': '',
'password': '',
'remember_me': false,
},
controller: controller,
);
Read a text field with form.get('email') and a typed one with form.value<bool>('remember_me'). Render fields with WFormInput, bound to the form's field controller:
WFormInput(
label: trans('attributes.email'),
controller: form['email'],
type: InputType.email,
validator: rules([Required(), Email()], field: 'email'),
)
Validate, then hand the values to the controller:
Future<void> _submit() async {
if (!form.validate()) return;
await controller.doLogin(
email: form.get('email'),
password: form.get('password'),
rememberMe: form.value<bool>('remember_me'),
);
}
Read route values a controller call needs (a reset token, say) in the view's didChangeDependencies through MagicRouter.instance.queryParameterOf(context, 'token') or pathParameterOf(context, ...), which answer for the page the view sits in.
AppLayout#
MagicStarterAppLayout is the authenticated shell: a sidebar with brand, team selector, navigation and user menu on a wide screen, and a header, drawer and bottom navigation on a narrow one. The MagicStarterManager constructor registers it under layout.app as a default.
It starts notification polling when it mounts (with notifications on, through Notify.startPolling()), mounts the update and campaign banners when their features are on, and rebuilds on Auth.stateNotifier and on the static MagicStarterAppLayout.refreshNotifier. Nothing in the package bumps that notifier, since the layout already rebuilds on Auth.stateNotifier; only a host that needs an extra rebuild increments its value.
Customise it before the layout first paints, ideally in AppServiceProvider.boot():
// Replace the mobile header.
MagicStarter.useHeader((context, isDesktop) {
return MyCustomHeader(showMenuButton: !isDesktop);
});
// Navigation destinations.
MagicStarter.useNavigation(
mainItems: [
MagicStarterNavItem(
icon: Icons.dashboard_outlined,
labelKey: 'nav.dashboard',
path: '/',
),
],
);
// A widget between the navigation items and the user menu.
MagicStarter.useSidebarFooter((context) => MyVersionBadge());
// Colours and brand.
MagicStarter.useNavigationTheme(
MagicStarterNavigationTheme(
activeItemClassName:
'active:text-amber-500 active:bg-amber-500/10 dark:active:text-amber-400 dark:active:bg-amber-400/10',
brandBuilder: (context) => Image.asset('assets/logo.png', height: 28),
bottomNavActiveClassName: 'active:text-amber-500 dark:active:text-amber-400',
avatarClassName: 'bg-amber-500/10 dark:bg-amber-400/10',
),
);
useNavigation also takes systemItems, bottomItems and profileMenuItems. See Manager for the themes.
Theme setters and useWindTheme#
MagicStarter.useWindTheme(windTheme) derives the colours of every sub-theme from your Wind palette, and it lands them only on the fields you have not set yourself. So a useLayoutTheme, useNavigationTheme or other use*Theme() call made before it is kept field by field. A setter called after it assigns its whole struct: build that struct from the active theme with copyWith (below), or every field it omits, the derived colours included, goes back to its stock default.
The rule is per field. A field counts as yours when its current value differs both from its sub-theme's default constructor value and from what the previous useWindTheme call derived for it. Your fields are kept; every other field takes the derived value. Two things follow from that:
- A second
useWindThemewith another palette re-skins what the first one derived, and still leaves your fields alone, including a field whose value a palette in between happened to derive too. - A field set to exactly its stock default, or to exactly what the previous
useWindTheme()derived for it, reads as unset, and the nextuseWindTheme()replaces it; to keep such a value, set a different one, or set it again afteruseWindTheme()withcopyWith.
useTheme(MagicStarterTheme(...)) is not a merge: it replaces every sub-theme whole.
Each setter assigns its whole struct, so MagicStarterLayoutTheme(navigationBreakpoint: 'sm') puts every other layout field back on its default, including the sidebar, header and background colours useWindTheme derived. A one-field override is current.copyWith(...), which every sub-theme has:
MagicStarter.useLayoutTheme(
MagicStarter.layoutTheme.copyWith(navigationBreakpoint: 'sm'),
);
MagicStarter.useNavigationTheme(
MagicStarter.navigationTheme.copyWith(
focusItemClassName: 'focus:ring-2 focus:ring-primary',
),
);
useWindTheme also derives the drawer and content area backgrounds (drawerBackground* and contentBackground*) from the bg-surface role the sidebar and header use. Those fields are wColor keys rather than classNames, so each half of the role (bg-ink-50, dark:bg-[#0E0F11]) becomes a colour name, or a hex, and a shade; where the role names two backgrounds for one brightness, the last one is read, as Wind paints it. A half that does not resolve, an opacity step or a colour the Wind theme does not carry, keeps the stock key. A colour and its shade count as one field: set either and the pair is yours.
Where the sidebar starts, and where it grows labels#
Three fields on MagicStarterLayoutTheme decide the shell's shape. The first two are breakpoints, named as keys of the Wind theme's screens map (sm, md, lg, xl, 2xl by default) rather than as pixel counts; the third is a width in logical pixels:
| Field | Default | What it decides |
|---|---|---|
navigationBreakpoint |
'lg' |
The breakpoint from which the sidebar replaces the drawer and the bottom bar |
sidebarExpandedBreakpoint |
'lg' |
The breakpoint from which the sidebar carries labels beside its icons |
sidebarCompactWidth |
80 |
The width of the sidebar while it is compact |
Between the two breakpoints the sidebar renders compact: icons only, with every text label dropped (the app name, the item labels, the system section's name, and the user's name and email, which falls back to the avatar trigger the mobile header mounts). What a host supplies itself, brandBuilder and sidebarFooterBuilder, is not reshaped, since only the host knows whether its own widget fits an icon column. A host whose wordmark does not fit sets MagicStarterNavigationTheme.compactBrandBuilder to a glyph, and the compact rail shows that instead.
A name the Wind theme does not carry throws a StateError naming the field and listing the valid keys, because wScreenIs answers false for an unknown key and a typo would otherwise pin the shell to its narrow form with nothing to read. The defaults leave the two breakpoints equal, so the compact form never fires until a host lowers one of them:
// An icon rail from 640 px, labels from 1024 px.
MagicStarter.useLayoutTheme(
const MagicStarterLayoutTheme(
navigationBreakpoint: 'sm',
sidebarExpandedBreakpoint: 'lg',
),
);
Keep sidebarCompactWidth at 80 or more: the compact team selector (mx-3 p-2 around a w-8 avatar) plus the sidebar's border-r overflows a narrower rail.
Letting the viewer collapse the sidebar#
| Field | Default | What it decides |
|---|---|---|
sidebarCollapsible |
false |
Whether a toggle above the user menu collapses the labelled sidebar to its compact form and back |
sidebarCollapsedByDefault |
false |
Whether a collapsible sidebar starts compact before the viewer has chosen |
The toggle appears only at or above sidebarExpandedBreakpoint; below it the sidebar is compact because the window cannot spare sidebarWidth. sidebarCollapsedByDefault is ignored unless the sidebar is collapsible. The viewer's choice is remembered through Magic's Cache under magic_starter.sidebar_collapsed, written with a ten year TTL because the cache has no forever and its own default would hand the viewer the default back the next morning. A host that binds no cache keeps the choice in memory for the life of the shell, and a remembered choice wins over sidebarCollapsedByDefault.
The toggle reads nav.collapse_sidebar and nav.expand_sidebar, both in the install stub; a host with its own language files adds them.
// Icons only until the viewer asks for labels, with a glyph for the rail.
MagicStarter.useLayoutTheme(
const MagicStarterLayoutTheme(
sidebarCollapsible: true,
sidebarCollapsedByDefault: true,
),
);
MagicStarter.useNavigationTheme(
MagicStarterNavigationTheme(
brandBuilder: (context) => const MyLogoLockup(),
compactBrandBuilder: (context) => const MyLogoGlyph(),
),
);
A focus ring for a keyboard or a remote#
MagicStarterNavigationTheme.focusItemClassName is applied to every navigation item the shell draws (sidebar, drawer and bottom bar). Each token carries the focus: prefix; Wind lights them when the item's WAnchor holds primary focus. It defaults to '', which is no ring, and an app driven by arrow keys or a television remote wants it set:
MagicStarter.useNavigationTheme(
const MagicStarterNavigationTheme(
focusItemClassName: 'focus:ring-2 focus:ring-primary focus:ring-offset-2',
),
);
Names for a screen reader#
Every control the shell draws has a name in the semantics tree, which is what a screen reader announces and what a semantics-driven test tool finds a control by. A labelled sidebar row, a drawer row and a bottom bar item are named by their label. The compact rail drops the label it draws, so its items carry that label as their name all the same, with a badge's count sentence after it. A sidebar item scrolled out of view stays in the tree, named and flagged hidden, and the list offers the scroll actions that bring it in.
The icon-only controls read their names from the catalogue: nav.open_menu for the phone header's menu button, nav.close_menu for the drawer's close button, nav.notifications for the bell (the bell in the sidebar takes the same name), nav.account_menu for the avatar that opens the account menu (on the phone header and on the compact rail), and nav.team_menu for the compact rail's team switcher, which carries the current team's name as its value. The install stub ships the five keys; an app installed before them gets the English names (Open menu, Close menu, Notifications, Account menu, Team menu) rather than the raw keys until it adds them to its own language files. What a host supplies names itself: a triggerBuilder handed to MSUserProfileDropdown, a brandBuilder, a sidebarFooterBuilder and a sidebar.team_selector view.
A live count on a destination#
A MagicStarterNavItem takes a badgeCount, a ValueListenable<int?>. The shell listens to it, so the host moves the number by updating the notifier and never rebuilds the navigation config:
// Owned by whatever loads the number: a controller, a repository, a stream.
final ValueNotifier<int?> unticked = ValueNotifier<int?>(null);
MagicStarter.useNavigation(
mainItems: [
MagicStarterNavItem(
icon: Icons.shopping_cart_outlined,
labelKey: 'nav.shopping',
path: '/shopping',
badgeCount: unticked,
badgeLabelKey: 'nav.shopping_open',
),
],
);
unticked.value = 4; // the badge reads 4
unticked.value = 0; // and is gone
The badge is hidden while the value is null or not above zero, and reads 99+ above 99. The sidebar and the drawer draw it at the trailing edge of the row, the compact rail and the bottom bar on the corner of the icon; the profile menu ignores it. On the compact rail the pill overhangs the icon by 16 px, so a sidebarCompactWidth under 52 would cut it.
A badge is not colour alone. A screen reader gets a sentence in place of the number, added to the item's own label: badgeLabelKey names a catalogue line with both plural forms and a :count placeholder, read with the real count even above 99. It defaults to nav.badge_count, which the install stub ships ("One item|:count items"); name your own key when the number counts something specific:
{
"nav": {
"shopping_open": "One line to buy|:count lines to buy"
}
}
An item holding a notifier is not const, so the list that carries it is final. Recolour the badge through the navigation theme, with a dark: peer on every colour (a semantic alias such as bg-primary already carries one):
MagicStarter.useNavigationTheme(
const MagicStarterNavigationTheme(
badgeClassName: 'bg-primary text-on-primary',
),
);
The box the route child is handed#
The shell wraps the route child in one WDiv, and two fields on MagicStarterLayoutTheme own it:
| Field | Default | What it decides |
|---|---|---|
contentClassName |
'flex-1 min-h-0' |
The content area's own className |
contentScrollPrimary |
false |
Whether that area attaches to the ambient PrimaryScrollController |
The default is a box of bounded height that does not scroll, so each routed page scrolls itself. Every view this package ships already does, through MSPageScaffold (which brings its own SingleChildScrollView(primary: false)) or, for the invitation card, its own scroll view. A page of your own that is taller than the window needs its own vertical scroll: put it through MSPageScaffold, or wrap it:
MagicRoute.page(
'/reports',
// `primary: false`, as MSPageScaffold does: the page owns its controller.
() => const SingleChildScrollView(primary: false, child: ReportsView()),
);
Set the two fields together: Wind reads scrollPrimary only where it builds a scroll view, so beside a non-scrolling className it is inert. Two primary: true scrollables in one tree contend for the single controller.
Hiding the bottom nav on a page#
Wrap the page, not the route group, in MagicStarterHideBottomNav:
MagicRoute.page(
'/chat/:id',
() => MagicStarterHideBottomNav(child: ChatView()),
).stacked();
The phone's bottom bar collapses in step with that page's transition and returns as it leaves, an iOS back swipe included. The page stays in the same shell as the rest of the app, so nothing is rebuilt on the way in. A page registers once magic's route middleware has mounted it, so the bar starts collapsing a frame or two into the transition.
A host layout that draws its own bottom nav joins in with MagicStarterBottomNavScope at the layout's root and its nav inside MagicStarterBottomNavSlot:
class MyAppShell extends StatelessWidget {
const MyAppShell({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return MagicStarterBottomNavScope(
child: Column(
children: [
Expanded(child: child),
MagicStarterBottomNavSlot(child: MyBottomNav()),
],
),
);
}
}
The slot's height is the nav's times how much of it is showing, so the content above grows into the room the bar gives up. Read that fraction with MagicStarterBottomNavScope.visibilityOf(context) (0 to 1). Place the scope inside the layout, never around it: magic tells shells apart by the layout widget's runtime type.
An immersive route#
MagicStarterHideChrome drops all of the chrome (sidebar, drawer, header and bottom bar, plus the safe-area inset), so a player, a viewfinder or a map paints the whole window:
MagicRoute.group(
layout: (child) => MagicStarterHideChrome(
child: MagicStarter.view.makeLayout('layout.app', child: child),
),
layoutId: 'app.immersive',
routes: () {
MagicRoute.page('/player', () => const PlayerView());
},
);
The group needs an id of its own, and a separate id is a separate shell: entering the route builds this layout and leaving it builds the app shell again, so the layout's state (the sidebar's collapsed choice, notification polling, the auth listeners) is rebuilt on each crossing. That suits an immersive route. A page that only wants the bottom bar gone belongs in the app shell under MagicStarterHideBottomNav.
GuestLayout#
MagicStarterGuestLayout is a minimal centred wrapper for authentication pages (login, register, forgot password, reset password). It constrains content to 480 px and provides scrolling. It is registered under layout.guest, uses wColor() for theme-aware backgrounds, and pads the content with p-4 lg:p-8.
Guest routes use RouteTransition.none, so there is no animation between auth screens.
Page Chrome#
The layout shell owns the sidebar, the header and the content box. What is left is page chrome: how wide the content column is, how far it sits from the edges, and where the title goes. MSPageScaffold and MSPageContainer own that, and every authenticated page goes through one of them.
MSPageScaffold is the full treatment. Give it a title and a list of sections, and it renders the page surface, its own vertical scroll, the shared content geometry, an MSPageHeader and a gap-6 column for the sections:
MSPageScaffold(
title: trans('teams.settings'),
subtitle: trans('teams.settings_subtitle'),
children: [
MSCard(title: 'General', child: generalForm),
MSCard(title: 'Members', child: memberList),
],
)
A sub-page adds a back affordance, and a top-level page can put an action next to the title:
MSPageScaffold(
title: trans('profile.settings'),
backLabel: trans('magic_starter.nav.settings'),
backFallback: MagicStarterConfig.settingsHubRoute(),
actions: [saveButton],
children: [profileForm],
)
MSPageContainer is the geometry alone: width cap, edge margins, vertical rhythm and a horizontal safe-area guard. Reach for it when the page brings its own header or scrolls its own way, which is the usual shape in a host app:
MSPageContainer(
children: [
MSPageHeader(title: 'Monitors', actions: [newMonitorButton]),
monitorTable,
],
)
Neither component decides the numbers. They read MagicStarter.manager.pageContainerClassName, which the host sets once (see Page Geometry), so a starter account page and a host domain page line up inside the same shell. See Components for MSCard, MSPageHeader and the rest.
Wind UI System#
The views use Wind widgets and Tailwind-style className strings: WDiv for containers, WText for text, WButton, WAnchor, WIcon, WFormInput, WPopover and so on. For long class lists, use a triple-quoted string:
WDiv(
className: '''
rounded-2xl bg-surface
border border-color-border
shadow-sm p-6
''',
child: content,
)
Use Wind widgets in views, not Container, Text or ElevatedButton. Icons.* is the one Material reference, for icon data.
Dark Mode#
Wind supports the dark: prefix. A raw colour token needs its dark: peer in the same className; a semantic alias such as bg-primary, bg-surface or text-fg-muted already carries one:
WText('Hello', className: 'text-gray-900 dark:text-white')
WText('Hello', className: 'text-fg-muted')
The profile dropdown toggles the theme with context.windTheme.toggleTheme(), and context.windIsDark reads the current mode.
View Registry#
All views are registered by string key in MagicStarterViewRegistry. The host overrides a screen by registering under the same key; the key table is in the Quick Reference:
// Override with a custom view.
MagicStarter.view.register(
'auth.login',
() => const MyCustomLoginView(),
);
// Build a view by key.
final widget = MagicStarter.view.make('auth.login');
// Inject content into a view that exposes a slot.
MagicStarter.view.slot('settings.hub', 'footer', (context) => const MyAboutSection());
make(key) throws a StateError when the key is not registered, so register views before routes reference them. The registry also answers has(key) and clear(), and buildSlot() is what a view calls to render a slot. Register slots before the view is built.
Layout Registry#
Layouts follow the same pattern with a child parameter for wrapping content:
// Override with a custom layout.
MagicStarter.view.registerLayout(
'layout.app',
(child) => MyCustomAppLayout(child: child),
);
// Build a layout around content.
final widget = MagicStarter.view.makeLayout(
'layout.guest',
child: loginView,
);
makeLayout hands the builder a child already wrapped in a route-keyed KeyedSubtree, so a replaced layout keeps the remount behaviour the default layouts carry. To test a view in isolation, register a minimal layout: MagicStarter.view.registerLayout('layout.guest', (child) => child);.
Overriding the App Shell#
Every account, profile, team and settings route renders through the layout.app key. The MagicStarterManager constructor registers MagicStarterAppLayout under it, so a fresh install already renders in a working shell.
An app that already has its own navigation chrome does not want starter routes rendering inside a second, different shell on top of its own. Re-register layout.app with your own shell and every starter route renders inside it:
MagicStarter.view.registerLayout(
'layout.app',
(child) => MyAppShell(child: child),
);
Call this from your own AppServiceProvider's boot(), alongside MagicStarter.useNavigation() or MagicStarter.useHeader(). registerLayout() always overwrites what is registered under the key, and the default is registered synchronously the first time the magic_starter singleton resolves, before your code runs, so your override wins whether you call it first or last in boot.
Your own authenticated routes join the same shell. Magic merges route groups into one go_router ShellRoute only when they carry the same layoutId, so give your group MagicStarterLayoutIds.app:
MagicRoute.group(
layout: (child) => MagicStarter.view.makeLayout('layout.app', child: child),
middleware: ['auth'],
layoutId: MagicStarterLayoutIds.app,
routes: () {
MagicRoute.page('/', () => const DashboardView());
},
);
A group with another id, or none, becomes a second shell, and every crossing between it and the starter's profile, team, notification or support pages rebuilds and re-animates the whole layout. layout.app is the only authenticated-shell key the starter resolves: override it in place, do not add a second key.
Feature-Gated Rendering#
Use conditional spreads to show or hide UI based on feature flags at build time:
WDiv(
className: 'flex flex-col gap-4',
children: [
_buildEmailField(),
_buildPasswordField(),
if (MagicStarterConfig.hasRegistrationFeatures())
WAnchor(
onTap: () => MagicRoute.to('${MagicStarterConfig.authPrefix()}/register'),
child: WText(trans('auth.register_link'), className: 'text-sm text-primary'),
),
if (MagicStarterConfig.hasSocialLoginFeatures() &&
MagicStarter.socialAuth != null) ...[
const MSSocialDivider(),
MagicStarterSocialButtons(
socialAuth: MagicStarter.socialAuth!,
onSelected: controller.doSocialSignIn,
isLoading: controller.isLoading,
busyProvider: controller.pendingSocialProvider,
),
],
],
)
Feature checks in views are read-only queries against the config. A view never toggles a feature; that is starter:configure or a config edit.
Zero Business Logic#
A view contains no business logic:
- No async work:
_submit()calls a controller method and returns. - No state decisions: the controller decides what happens after a submit (navigation, error handling).
- No HTTP calls: all API communication lives in controllers through
Http.post(),Http.get()and the rest. - No direct navigation: controllers use the
NavigatesRoutesmixin, and views never callcontext.go(). - Local UI state only: a password visibility toggle, a phone/email switch, a section spinner in a local
ValueNotifier<bool>.
// Correct: the view delegates to the controller.
Future<void> _submit() async {
if (!form.validate()) return;
await controller.doLogin(
email: form.get('email'),
password: form.get('password'),
);
}
// Wrong: the view makes the API call.
Future<void> _submit() async {
final response = await Http.post('/auth/login', data: payload);
}
Reusable Widgets#
These widgets take plain callbacks and no internal controller, so a host app uses them directly. All are exported from package:magic_starter/magic_starter.dart. The MS components (MSCard, MSPageHeader, MSAvatar and the rest) are covered in Components.
MagicStarterConfirmDialog is a confirmation dialog with variant-driven styling, an alias of MSConfirmDialog. It returns true when confirmed and false when cancelled or dismissed, and shows a loading state while onConfirm runs:
final confirmed = await MagicStarterConfirmDialog.show(
context,
title: trans('teams.remove_member_label'),
description: trans('teams.confirm_remove_member'),
confirmLabel: trans('teams.remove'),
variant: ConfirmDialogVariant.danger,
onConfirm: () async {
await TeamService.removeMember(memberId);
},
);
if (confirmed) _refreshMembers();
ConfirmDialogVariant is primary, danger or warning; the button classNames come from MagicStarter.manager.modalTheme.
MagicStarterPasswordConfirmDialog is a password gate. Its onConfirm returns null on success or an error string to show inline; the dialog stays open on an error and closes with true on success. Without an onConfirm it closes with true as soon as the user confirms. For a gate that picks the right proof for the account, use confirmIdentity (see Confirming Identity).
final confirmed = await MagicStarterPasswordConfirmDialog.show(
context,
title: trans('projects.delete_title'),
onConfirm: (password) async {
// Return null to confirm, or an error string on failure.
return await ProjectService.delete(id, password: password);
},
);
MagicStarterTwoFactorModal is the two-factor setup wizard: the QR code and a code input, then the recovery codes. It resolves true once onConfirm accepts a code:
final enabled = await MagicStarterTwoFactorModal.show(
context,
setupData: {
'secret': 'JBSWY3DPEHPK3PXP', // shown for manual entry
'qr_svg': qrSvg, // raw SVG string
'recovery_codes': recoveryCodes, // list of strings
},
onConfirm: (code) async {
return await TwoFactorService.confirmSetup(code);
},
);
The other widgets a host can place:
| Widget | What it is |
|---|---|
MagicStarterAuthFormCard |
A centred card (480 px) for auth-adjacent screens such as an invitation or onboarding, with a title, a subtitle, an optional error banner and a theme toggle |
MagicStarterTimezoneSelect |
A searchable timezone dropdown over GET /timezones; see Profile |
MSTeamSelector |
The current-team switcher; needs a registered team resolver (see Teams) |
MSUserProfileDropdown |
The avatar menu with user info, profile links, the theme toggle and logout; accepts a custom triggerBuilder |
MSSocialDivider |
The "Or continue with" divider for auth forms |
MagicStarterHideBottomNav, MagicStarterBottomNavScope, MagicStarterBottomNavSlot |
Collapse the phone's bottom navigation per page; see Hiding the bottom nav on a page |
MagicStarterHideChrome |
Hides the whole shell chrome for an immersive route; see An immersive route |
MagicStarterLayoutIds |
The layoutId values of the starter's route groups; MagicStarterLayoutIds.app is the one a host's own group must share |
The notification bell is not here: it moved to magic_notifications as NotificationDropdown; see Notifications.
Gotchas#
| Mistake | Fix |
|---|---|
| Registering a layout under a new key for your shell | layout.app is the only authenticated-shell key the starter resolves. Re-register it. |
Giving your own authenticated group another layoutId |
Use MagicStarterLayoutIds.app, or crossing into a starter page rebuilds the whole layout. |
| A page taller than the window that does not scroll | The content box does not scroll by default. Use MSPageScaffold or wrap the page in a SingleChildScrollView(primary: false). |
Calling MagicStarter.view.make(key) for a key nothing registered |
It throws a StateError. profile.settings is not registered by default, and a key behind a feature flag exists only with the flag on. |
A breakpoint name the Wind theme does not carry in MagicStarterLayoutTheme |
It throws a StateError listing the valid keys. Use sm, md, lg, xl, 2xl or your own screens keys. |
Calling useNavigationTheme() or useModalTheme() after the first paint |
Call them in AppServiceProvider.boot(), before the layout or a dialog first renders. |
Related Documentation#
- Components: the
MSdesign-system components. - Notifications: the notification screens on
Notify.view. - Profile: the settings hub and its sub-pages.
- Teams: the team selector and team views.
- Manager: the themes, locale options and page geometry.
- View Registry: how the registry is built.
- Configuration: the feature flags that decide which views register.