Views and Layouts
On this page
Introduction#
Magic Starter's view layer follows a strict separation of concerns: views handle rendering, controllers handle 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 — AppLayout for authenticated pages and GuestLayout for auth pages — wrap all views. The entire UI is built with Wind UI components exclusively, with no direct Material widget usage beyond Icons.* references.
All views and layouts are registered in a string-keyed registry, making every screen overridable by the host application.
MagicStatefulView#
Page-level views extend MagicStatefulView<ControllerType> with a state class that extends MagicStatefulViewState:
class MagicStarterLoginView
extends MagicStatefulView<MagicStarterAuthController> {
const MagicStarterLoginView({super.key});
@override
State<MagicStarterLoginView> createState() => _MagicStarterLoginViewState();
}
class _MagicStarterLoginViewState extends MagicStatefulViewState<
MagicStarterAuthController, MagicStarterLoginView> {
@override
void onInit() {
controller.clearErrors();
controller.setEmpty();
}
@override
Widget build(BuildContext context) {
return controller.renderState(
(_) => _buildForm(),
onEmpty: _buildForm(),
onError: (message) => _buildForm(errorMessage: message),
);
}
}
The MagicStatefulViewState base class provides access to the controller singleton via controller and lifecycle hooks via onInit() and onClose().
View Lifecycle#
Two lifecycle hooks are available in MagicStatefulViewState:
onInit() — Called once when the state is initialized. Use it to clear errors, set initial state, or trigger data fetches:
@override
void onInit() {
controller.clearErrors();
controller.setEmpty();
}
onClose() — Called when the state is disposed. Use it to clean up form data or local notifiers:
@override
void onClose() => form.dispose();
State Rendering#
Views delegate state-based rendering to controller.renderState(), which selects the appropriate builder based on the controller's current MagicStateMixin state:
@override
Widget build(BuildContext context) {
return controller.renderState(
(_) => _buildForm(),
onEmpty: _buildForm(),
onError: (message) => _buildForm(errorMessage: message),
);
}
The first positional argument handles the success state (receives the state value). Named parameters:
| Parameter | When | Typical use |
|---|---|---|
onEmpty |
No data loaded yet | Show empty form or placeholder |
onError |
Controller called setError() |
Show form with error banner |
Extract loading state at the top of your build method for conditional UI:
Widget _buildForm({String? errorMessage}) {
final isLoading = controller.isLoading;
return WDiv(
className: 'flex flex-col gap-4 p-6',
children: [
if (errorMessage != null)
WText(errorMessage, className: 'text-sm text-red-500'),
// ... form fields
WButton(
onTap: isLoading ? null : _submit,
className: 'w-full py-3 rounded-lg bg-primary text-white',
child: WText(isLoading ? trans('common.loading') : trans('auth.login')),
),
],
);
}
Form Handling#
Forms are declared as MagicFormData instances with empty string defaults for text fields and typed defaults for non-string fields:
late final form = MagicFormData(
{
'email': '',
'phone': '',
'password': '',
'remember_me': false,
},
controller: controller,
);
Access values with type-safe extraction:
// String fields
final email = form.get('email');
// Non-string fields — use value<T>()
final rememberMe = form.value<bool>('remember_me');
Form fields render with WFormInput:
WFormInput(
form: form,
name: 'email',
label: trans('auth.email'),
keyboardType: TextInputType.emailAddress,
)
Validate and submit:
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'),
);
}
AppLayout#
MagicStarterAppLayout is the authenticated shell used for all protected pages. It provides a responsive layout with:
- Desktop: Sidebar with brand, team selector, navigation items, and user menu
- Mobile: Hamburger menu with drawer, header bar, and bottom navigation
MagicStarter.view.registerLayout(
'layout.app',
(child) => MagicStarterAppLayout(child: child),
);
The layout automatically starts notification polling when mounted (if notifications are enabled) and rebuilds on auth state changes via MagicStarterAppLayout.refreshNotifier and Auth.stateNotifier.
Key customization points:
// Custom header — replaces the default mobile header
MagicStarter.useHeader((context, isDesktop) {
return MyCustomHeader(showMenuButton: !isDesktop);
});
// Custom navigation items
MagicStarter.useNavigation(
mainItems: [
MagicStarterNavItem(
icon: Icons.dashboard_outlined,
labelKey: 'nav.dashboard',
path: '/',
),
],
systemItems: [...],
bottomItems: [...],
);
// Sidebar footer — custom widget between nav items and user menu
MagicStarter.useSidebarFooter((context) {
return MyVersionBadge();
});
// Custom logo/brand — replaces default app name text
// MagicStarter.useNavigationTheme(brandBuilder: ...) handles this
// Custom navigation colors 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',
),
);
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' |
Breakpoint from which the sidebar replaces the drawer and the bottom bar |
sidebarExpandedBreakpoint |
'lg' |
Breakpoint from which the sidebar carries labels beside its icons |
sidebarCompactWidth |
80 |
Width in logical pixels of the sidebar while it is compact |
Between the two breakpoints the sidebar renders compact: sidebarCompactWidth wide, icons only, and every text label dropped, because a label at that width would be clipped rather than shortened. That is the app name in the brand bar, the nav item labels, the system section's name, and the user's name and email, which falls back to the same avatar trigger the mobile header mounts. What a host supplies itself, brandBuilder and sidebarFooterBuilder, is not reshaped: only the host knows whether its own widget fits an icon column. The rail's brand bar does centre the brand on the icon line, by appending justify-around to brandBarClassName, which with one child centres it while leaving Wind to bound a brand wider than the rail. 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. wScreenIs answers false for an unknown key, so a typo would otherwise pin the shell to its narrow form at every width with nothing to read.
The shipped 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',
),
);
Take care lowering sidebarCompactWidth. Its floor is the compact team selector rather than the nav icon: MSTeamSelector's compact trigger is mx-3 p-2 around a w-8 avatar, which is 72 logical pixels, and sidebarClassName's own border-r takes one more out of the box. 72 was measured overflowing by exactly 1 pixel, which is why the default is 80.
Letting the viewer collapse the sidebar#
Two more fields hand the compact form to the viewer on a window wide enough for labels:
| 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, so a toggle there would promise an expansion the shell will not make. sidebarCollapsedByDefault is ignored unless the sidebar is collapsible: a default the viewer cannot undo would be a rail they are stuck with, and sidebarExpandedBreakpoint already expresses that.
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 of an hour 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. A remembered choice wins over sidebarCollapsedByDefault; changing the default moves only the viewers who never touched the toggle.
The toggle reads nav.collapse_sidebar and nav.expand_sidebar, and names itself with the second through semanticLabel while it is icon-only. Both keys ship in the install stub; a host installed before 0.0.34 adds them to its own language files.
// 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: the sidebar and drawer items beside their active and hover classNames, and the bottom bar's items too, so a small window is lit the same way a wide one is. Each token carries the focus: prefix; the item already sits inside a WAnchor, so Wind lights them the moment it holds primary focus. It defaults to '', which is the shipped shell's no-ring behaviour, 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',
),
);
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: the badge appears, changes and goes away on its own.
// 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; it takes no room from the item, so nothing moves when it shows. On the compact rail the pill overhangs the icon by 16 px, so a sidebarCompactWidth under 52 would cut it at the edge of the rail; the default is 80. The profile menu ignores it.
A badge is not colour alone. The number is drawn, and a screen reader gets a sentence in place of it, 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; items without a badge stay const.
The look is the neutral MSBadge. Recolour it through the navigation theme, whose tokens are appended to the badge's own, 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 fill-shaped screen, an h-full column whose body takes the slack and scrolls inside itself (a television guide, a media catalogue), lays out under it as well.
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 claims nothing and is inert; it matters the moment the className scrolls. 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 two widgets: 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) when the layout needs it, for instance to hand the collapsed bar's share of the bottom inset back to its content, as MagicStarterAppLayout does. 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, 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: () { ... },
);
No sidebar, no drawer, no header and no bottom bar. The route also gets the window with no safe-area inset, since a surface that sizes itself wants the whole of it.
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 is intended for an immersive route, which has no chrome to carry across. A page that only wants the bottom bar gone belongs in the app shell under MagicStarterHideBottomNav.
GuestLayout#
MagicStarterGuestLayout is a minimal centered wrapper for authentication pages (login, register, forgot password, reset password). It constrains content to 480px max width and provides scrolling:
MagicStarter.view.registerLayout(
'layout.guest',
(child) => MagicStarterGuestLayout(child: child),
);
The layout uses wColor() for theme-aware background colors and wraps content in WDiv(className: 'p-4 lg:p-8') for responsive padding.
Page Chrome#
The layout shell owns the sidebar, the header, and the scroll region. What is left is page chrome: how wide the content column is, how far it sits from the edges, and where the page title goes. Two components 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, a unified 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('notifications.title'),
actions: [markAllAsReadButton],
children: [MSCard(noPadding: true, child: notificationList)],
)
MSPageScaffold(
title: trans('profile.settings'),
backLabel: trans('magic_starter.nav.settings'),
backFallback: MagicStarterConfig.settingsHubRoute(),
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). That is what makes a starter account page and a host domain page line up inside the same shell.
Wind UI System#
All Magic Starter views use Wind UI components exclusively. The framework provides Tailwind-like utility classes through a className property:
| Component | Purpose | Example |
|---|---|---|
WDiv |
Container/layout | WDiv(className: 'flex flex-col gap-4 p-6') |
WText |
Typography | WText('Title', className: 'text-lg font-bold text-gray-900') |
WFormInput |
Form field | WFormInput(form: form, name: 'email') |
WButton |
Interactive button | WButton(onTap: submit, className: 'bg-primary text-white') |
WIcon |
Icon display | WIcon(Icons.mail_outline, className: 'text-xl text-gray-500') |
WAnchor |
Tap target | WAnchor(onTap: () => ..., child: ...) |
WSpacer |
Spacing | WSpacer(className: 'h-4') |
WPopover |
Overlay dropdown | WPopover(triggerBuilder: ..., contentBuilder: ...) |
For long class lists, use triple-quoted strings:
WDiv(
className: '''
rounded-2xl bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
shadow-sm p-6
''',
child: ...,
)
Dark Mode#
Always pair light and dark mode classes. Wind UI supports the dark: prefix for dark mode variants:
WText(
'Hello',
className: 'text-gray-900 dark:text-white',
)
WDiv(
className: '''
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
''',
)
Common pairings used throughout Magic Starter:
| Light | Dark |
|---|---|
bg-white |
dark:bg-gray-800 |
bg-gray-50 |
dark:bg-gray-900 |
text-gray-900 |
dark:text-white |
text-gray-500 |
dark:text-gray-400 |
text-gray-400 |
dark:text-gray-500 |
border-gray-200 |
dark:border-gray-700 |
border-gray-100 |
dark:border-gray-700 |
hover:bg-gray-100 |
dark:hover:bg-gray-800 |
View Registry#
All views are registered by string key in MagicStarterViewRegistry. The host application can override any screen by re-registering under the same key:
// Register a view
MagicStarter.view.register(
'auth.login',
() => const MagicStarterLoginView(),
);
// Override with custom view
MagicStarter.view.register(
'auth.login',
() => const MyCustomLoginView(),
);
// Build a view by key
final widget = MagicStarter.view.make('auth.login');
Built-in view keys:
| Key | View |
|---|---|
auth.login |
Login page |
auth.register |
Registration page |
auth.forgot_password |
Forgot password page |
auth.reset_password |
Reset password page |
auth.verify_email |
Email verification page (the mailed link) |
auth.otp_verify |
OTP verification page |
auth.two_factor_challenge |
Two-factor challenge page |
profile.settings |
Profile settings page |
teams.create |
Team creation page |
teams.settings |
Team settings page |
teams.invitation_accept |
Team invitation acceptance page |
Layout Registry#
Layouts follow the same registry pattern with a child parameter for wrapping content:
// Register a layout
MagicStarter.view.registerLayout(
'layout.app',
(child) => MagicStarterAppLayout(child: child),
);
// Override with custom layout
MagicStarter.view.registerLayout(
'layout.app',
(child) => MyCustomAppLayout(child: child),
);
// Build a layout wrapping content
final widget = MagicStarter.view.makeLayout(
'layout.guest',
child: loginView,
);
Built-in layout keys:
| Key | Layout |
|---|---|
layout.app |
Authenticated layout with sidebar/nav |
layout.guest |
Centered guest layout for auth pages |
Overriding the App Shell#
All account, profile, team, and settings routes render through the layout.app key. Out of the box, MagicStarterServiceProvider registers MagicStarterAppLayout under that key, so a fresh install already renders those routes in a working shell (sidebar/nav on desktop, drawer + bottom nav on mobile) with no extra setup.
An app that already has its own navigation chrome (its own sidebar, its own bottom nav) does not want starter routes rendering inside a second, different shell on top of its own. That is the case this seam exists for: re-register layout.app with your own shell and every starter route (login-gated pages, profile settings, team management, notifications) renders inside it instead:
MagicStarter.view.registerLayout(
'layout.app',
(child) => MyAppShell(child: child),
);
Call this from your own AppServiceProvider's boot() method, alongside other startup customization such as MagicStarter.useNavigation() or MagicStarter.useHeader().
registerLayout() always overwrites whatever is currently registered under the key: the default registration in MagicStarterManager is conditional (if (!hasLayout(key))), but registerLayout() itself is not. Since the default is registered synchronously the first time the magic_starter singleton is resolved (before your own code runs), your override always lands after it and always wins, whether you call registerLayout() early or late in your app's boot sequence. There is no ordering to get wrong.
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 (and build its layout through makeLayout if you like):
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.
Feature-Gated Rendering#
Use conditional spreads to show or hide UI sections based on feature flags at build time:
WDiv(
className: 'flex flex-col gap-4',
children: [
_buildEmailField(),
_buildPasswordField(),
if (MagicStarterConfig.hasRegistrationFeatures()) ...[
WSpacer(className: 'h-2'),
WAnchor(
onTap: () => MagicRoute.to(MagicStarterConfig.registerPath()),
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,
),
],
],
)
Zero Business Logic#
Views must contain zero business logic. All rules:
- No async operations in views —
_submit()calls a controller method and returns - No state decisions — the controller decides what happens after a form submit (navigation, error handling)
- No HTTP calls — all API communication lives in controllers via
Http.post(),Http.get(), etc. - No direct navigation — controllers use
NavigatesRoutesmixin; views never callcontext.go() - Local UI state only — password visibility toggles (
_obscurePassword), phone/email toggle in "both" mode, section-level loading spinners via localValueNotifier<bool>fields
// CORRECT: view delegates to controller
Future<void> _submit() async {
if (!form.validate()) return;
await controller.doLogin(
email: form.get('email'),
password: form.get('password'),
);
}
// WRONG: view makes API call
Future<void> _submit() async {
final response = await Http.post('/auth/login', data: {...}); // Never do this
}
Related Links:
Reusable Widgets#
Magic Starter exports standalone UI widgets that consumer apps can import and use directly without duplicating them locally. None of these widgets depend on internal controllers — they accept plain callbacks.
All widgets are exported from package:magic_starter/magic_starter.dart.
MSPageHeader#
Full-width page header with responsive sm:flex-row layout and a border-b separator. All parameters beyond title are optional:
MSPageHeader(
title: trans('projects.title'),
subtitle: trans('projects.manage_subtitle'), // optional
leading: const BackButton(), // optional
actions: [ // optional
WButton(onTap: _onCreate, child: WText(trans('projects.new'))),
],
)
Detail view with status badge and always-inline layout:
MSPageHeader(
title: 'Task Details',
leading: Icon(Icons.arrow_back),
titleSuffix: StatusBadge(status: 'done'),
inlineActions: true,
actions: [WButton(onTap: () {}, child: WText('Edit'))],
)
| Parameter | Type | Required | Description |
|---|---|---|---|
title |
String |
✅ | Main heading text |
subtitle |
String? |
— | Secondary line below the title |
leading |
Widget? |
— | Widget placed before the title (e.g. back button) |
actions |
List<Widget>? |
— | Row of trailing action widgets (e.g. buttons) |
titleSuffix |
Widget? |
— | Optional widget rendered inline after the title (e.g. status badge). Stays on the same row as the title text. |
subtitleSuffix |
Widget? |
- | Optional widget on the subtitle line, right after the subtitle (e.g. a pill beside a URL). Wraps under the subtitle when the line cannot hold both; renders nothing without a subtitle. |
inlineActions |
bool |
— | When true, forces single-row layout on all screen sizes (no mobile stacking). Useful for detail views where actions must stay inline with the title. |
MSCard#
Card wrapper with an optional title slot, noPadding mode for full-bleed content, and three visual variants:
| Variant | Background | Border | Shadow |
|---|---|---|---|
CardVariant.surface (default) |
bg-white dark:bg-gray-800 |
✅ border-gray-200 |
— |
CardVariant.inset |
bg-gray-50 dark:bg-gray-900 |
✅ border-gray-200 |
— |
CardVariant.elevated |
bg-white dark:bg-gray-800 |
— | ✅ shadow-md |
// Default padded surface card with title
MSCard(
title: 'Team Members',
child: memberList,
)
// Full-bleed elevated card (e.g. data table)
MSCard(
variant: CardVariant.elevated,
noPadding: true,
child: dataTable,
)
// Inset danger-zone card
MSCard(
variant: CardVariant.inset,
title: 'Danger Zone',
child: deleteButton,
)
When noPadding is true and a title is provided, the title automatically receives px-6 pt-6 pb-3 spacing so it aligns with full-bleed row content that uses px-6.
MagicStarterConfirmDialog#
Generic confirmation dialog with variant-driven styling. Uses MagicStarterDialogShell internally for sticky header/footer layout. All classNames are read from MagicStarter.manager.modalTheme at build time.
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();
| Parameter | Type | Default | Description |
|---|---|---|---|
title |
String |
required | Dialog title text |
description |
String? |
null |
Optional description below the title |
confirmLabel |
String? |
trans('common.confirm') |
Confirm button label |
cancelLabel |
String? |
trans('common.cancel') |
Cancel button label |
variant |
ConfirmDialogVariant |
.primary |
Button styling variant |
onConfirm |
Future<void> Function()? |
null |
Async action on confirm — dialog shows loading state |
Variants:
| Variant | Use case | Confirm button style |
|---|---|---|
ConfirmDialogVariant.primary |
Neutral confirmations | theme.primaryButtonClassName |
ConfirmDialogVariant.danger |
Destructive actions (delete, remove, revoke) | theme.dangerButtonClassName |
ConfirmDialogVariant.warning |
Caution actions (leave, archive) | theme.warningButtonClassName |
Returns true when confirmed, false when cancelled or dismissed.
MagicStarterPasswordConfirmDialog#
Standalone password-confirmation dialog. Pass an onConfirm callback that returns null on success or an error string to display inline. The dialog stays open on error; it closes automatically on success and returns true.
final confirmed = await MagicStarterPasswordConfirmDialog.show(
context,
title: trans('projects.delete_title'),
description: trans('projects.delete_description'),
onConfirm: (password) async {
// Return null to confirm, or an error string on failure.
return await ProjectService.delete(id, password: password);
},
);
if (confirmed) _removeProject();
Use with no onConfirm if you only need a confirmation gate (e.g. before a local-only destructive action):
final confirmed = await MagicStarterPasswordConfirmDialog.show(context);
MagicStarterTwoFactorModal#
Multi-step 2FA wizard modal. Step 1 displays the QR code and OTP input; Step 2 displays recovery codes with a copy button. The modal advances to Step 2 only when onConfirm returns true.
final success = await MagicStarterTwoFactorModal.show(
context,
setupData: {
'secret': '...',
'qr_svg': '...', // raw SVG string
'recovery_codes': [...], // list of strings
},
onConfirm: (code) async {
return await TwoFactorService.confirmSetup(code);
},
);
setupData key |
Type | Description |
|---|---|---|
secret |
String |
Manual entry key shown below the QR code |
qr_svg |
String |
Raw SVG markup rendered via WSvg with preserve-colors |
recovery_codes |
List<dynamic> |
Backup codes displayed on Step 2 |
The modal can also be used for standalone re-authentication (e.g. before a sensitive action) by supplying minimal setupData with only the fields the flow needs.
Other Exported Widgets#
| Widget | Description |
|---|---|
MagicStarterAuthFormCard |
Centered card wrapper (max 480 px) for auth-adjacent screens — invite accept, onboarding, etc. Accepts title, subtitle, optional errorMessage, and a theme-toggle button. |
MagicStarterTimezoneSelect |
Searchable timezone dropdown backed by GET /timezones?search=.... Debounces search at 300 ms and always includes the pre-selected value in options. Pages through the endpoint on scroll and resets its cursor when the menu reopens, since the reopen restores the unfiltered list; a response whose list the reopen already replaced is dropped rather than written. |
MSTeamSelector |
Current-team switcher dropdown. Requires MagicStarter.teamResolver to be registered. compact mode hides the team name label. |
MSAvatar |
A photo with a fallback, clipped to whatever shape className asks for. Owns no size, shape or colour, because those differ per surface; the fallback is a WIDGET so a host keeps its own initials rule. Falls back on an empty url and on a load error. |
MSUserProfileDropdown |
Circular avatar menu showing signed-in user info, profile links, theme toggle, and logout. Supports a custom triggerBuilder. |
MSSocialDivider |
Horizontal "Or continue with" divider for auth forms. No parameters — pure presentation. |
MagicStarterHideBottomNav |
Wraps a page and collapses the phone's bottom navigation in step with that page's route transition. Does nothing without a MagicStarterBottomNavScope above it. See Hiding the bottom nav on a page. |
MagicStarterBottomNavScope |
A layout's root, so the bar and the routed page share it. visibilityOf(context) reads how much of the bar is showing, 0 to 1. |
MagicStarterBottomNavSlot |
Wraps a layout's bottom nav; its height follows the scope's visibility. |
MagicStarterLayoutIds |
The layoutId values of the starter's route groups; MagicStarterLayoutIds.app is the authenticated shell a host's own group must share. See Overriding the App Shell. |
MagicStarterHideChrome |
InheritedWidget that signals MagicStarterAppLayout to hide the whole shell chrome: sidebar, drawer, header and bottom bar, plus the safe-area inset and the shell's scroll container. Wrap a route group's layout with it, read with MagicStarterHideChrome.of(context). See An immersive route. |