MagicStarterManager
On this page
Introduction#
MagicStarterManager is the central singleton that holds all customization registrations for the magic_starter plugin. It acts as a configuration hub where host apps register custom user models, team resolvers, navigation items, header builders, logout callbacks, and view overrides. Saying what a notification type looks like is not one of its seams anymore; that lives on magic_notifications's own NotificationViewRegistry now (see Notifications).
The manager lives at lib/src/magic_starter_manager.dart and is instantiated once by MagicStarterServiceProvider during the register phase.
Singleton Pattern#
The manager is bound into the Magic IoC container under the key 'magic_starter' by the service provider:
app.singleton('magic_starter', () => MagicStarterManager());
All access goes through the MagicStarter facade, which resolves the singleton from the container:
static MagicStarterManager get manager {
if (Magic.bound('magic_starter')) {
return Magic.make<MagicStarterManager>('magic_starter');
}
// Falls back to a shared default manager instead of throwing.
return _fallbackManager ??= MagicStarterManager();
}
Unbound Fallback and Testing#
Every accessor on the MagicStarter facade delegates through manager, so any code that reads a theme (e.g. MSCard via MagicStarter.cardTheme) previously threw "Service [magic_starter] is not registered" when rendered without MagicStarterServiceProvider bound — for instance, in a standalone widget test or a /preview catalog entry.
MagicStarter.manager now checks Magic.bound('magic_starter') first. When unbound it falls back to a shared, lazily-created default MagicStarterManager() (its 10 sub-themes already hold const defaults, so every theme accessor resolves) instead of throwing. The fallback also emits a one-time kDebugMode warning — "MagicStarterManager not bound; using defaults. Call MagicStarter... to configure." — so a genuine forgot-to-bind bug in a real app still surfaces during development. A manager bound via the container always wins over the fallback.
For tests that need a specific manager configuration (custom theme, team resolver, etc.), bind one explicitly with setUpMagicStarterForTests():
import 'package:magic_starter/magic_starter.dart';
setUp(() {
MagicApp.reset();
Magic.flush();
setUpMagicStarterForTests(); // binds a default MagicStarterManager()
});
Pass a pre-configured instance to bind it instead:
setUpMagicStarterForTests(
manager: MagicStarterManager()..useCardTheme(...),
);
setUpMagicStarterForTests() wraps the same Magic.singleton('magic_starter', () => ...) idiom used throughout the test suite; tests that only rely on default theme values can now skip binding altogether and rely on the fallback.
User Model Registration#
By default the manager creates MagicStarterAuthUser instances from API data. Host apps override this to use their own Authenticatable subclass:
MagicStarter.useUserModel((data) => User.fromMap(data));
Internally the manager stores a UserModelFactory — a function that takes Map<String, dynamic> and returns Authenticatable:
UserModelFactory userFactory = (data) => MagicStarterAuthUser.fromMap(data);
The factory is invoked via MagicStarter.createUser(data) whenever the plugin needs to hydrate a user model from an API response.
Team Resolver#
The team resolver bridges the plugin's team UI with app-specific team models through three callbacks:
MagicStarter.useTeamResolver(
currentTeam: () => User.current.currentTeam?.toMagicStarterTeam(),
allTeams: () => User.current.allTeams
.map((t) => t.toMagicStarterTeam())
.toList(),
onSwitch: (id) => TeamController.instance.switchTeam(id),
);
The callbacks are wrapped in MagicStarterTeamResolverConfig:
| Callback | Signature | Purpose |
|---|---|---|
currentTeam |
MagicStarterTeam? Function() |
Returns the active team (null if none) |
allTeams |
List<MagicStarterTeam> Function() |
Returns all teams the user belongs to |
onSwitch |
Future<void> Function(dynamic teamId) |
Handles team switching logic |
Navigation Configuration#
Host apps register navigation items for the authenticated app layout — sidebar, bottom bar, and profile dropdown:
MagicStarter.useNavigation(
mainItems: [
MagicStarterNavItem(
icon: Icons.dashboard,
labelKey: 'nav.dashboard',
path: '/',
),
],
systemItems: [
MagicStarterNavItem(
icon: Icons.people_outline,
labelKey: 'nav.members',
path: '/teams/members',
),
],
bottomItems: [
MagicStarterNavItem(
icon: Icons.dashboard_outlined,
labelKey: 'nav.dashboard',
path: '/',
),
],
profileMenuItems: [
MagicStarterNavItem(
icon: Icons.notifications_outlined,
labelKey: 'nav.notifications',
path: '/notifications',
),
],
);
The items are stored in MagicStarterNavigationConfig:
| Property | Required | Description |
|---|---|---|
mainItems |
Yes | Primary sidebar/drawer items (Dashboard, Monitors, etc.) |
systemItems |
No | Secondary items below the main group (Settings, Members) |
bottomItems |
No | Mobile bottom navigation bar items (subset of main) |
profileMenuItems |
No | Extra links in the profile dropdown, between "Profile Settings" and logout |
Each MagicStarterNavItem takes icon, labelKey and path, plus these optional fields:
| Property | Default | Description |
|---|---|---|
activeIcon |
icon |
Icon drawn on the bottom bar while the item is active |
badgeCount |
null |
A ValueListenable<int?> drawn as a count badge on the item; see A live count on a destination |
badgeLabelKey |
'nav.badge_count' |
Translation key of the sentence a screen reader gets for the badge |
Navigation Theme#
MagicStarterNavigationTheme overrides the default Wind UI text-primary tokens used for active nav items, the brand/logo, bottom navigation, and user avatar colors — without requiring any view overrides.
MagicStarter.useNavigationTheme(
MagicStarterNavigationTheme(
activeItemClassName:
'active:text-amber-500 active:bg-amber-500/10 dark:active:text-amber-400 dark:active:bg-amber-400/10',
brandClassName:
'text-lg font-bold text-amber-500 dark:text-amber-400',
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',
avatarTextClassName: 'text-sm font-bold text-amber-600 dark:text-amber-400',
dropdownAvatarClassName: 'bg-gradient-to-tr from-amber-500 to-amber-300',
dropdownAvatarTextClassName: 'text-sm font-bold text-white',
),
);
All fields are optional — omitted fields fall back to the current defaults.
| Field | Default | Controls |
|---|---|---|
activeItemClassName |
active:text-primary active:bg-primary/10 dark:active:bg-primary/10 |
Sidebar/drawer active nav item |
hoverItemClassName |
hover:bg-gray-100 dark:hover:bg-gray-800 |
Sidebar/drawer hover state |
brandClassName |
text-lg font-bold text-primary |
Brand/logo text style (supports gradient tokens) |
brandBuilder |
null |
Custom brand widget builder (image, SVG, etc.) — overrides brandClassName when set |
compactBrandBuilder |
null |
Brand builder for the compact, icon-only sidebar; falls back to brandBuilder when unset |
bottomNavActiveClassName |
active:text-primary |
Bottom nav active icon and label |
avatarClassName |
bg-primary/10 dark:bg-primary/10 |
Sidebar user menu avatar background |
avatarTextClassName |
text-sm font-bold text-primary |
Sidebar user menu avatar initial and placeholder glyph colour |
dropdownAvatarClassName |
bg-gradient-to-tr from-primary to-gray-200 |
Profile dropdown trigger avatar gradient |
dropdownAvatarTextClassName |
text-sm font-bold text-white |
Profile dropdown trigger avatar initial and placeholder glyph colour |
Both avatars draw the account's photo when profile_photo_url carries one, the first letter of the account's name when it does not, and a person glyph when there is no name to take a letter from: before the session is known, or for an account that never set one.
The theme is stored on MagicStarterManager as navigationTheme and reset to defaults by manager.reset(). The active theme is read at widget build time, so useNavigationTheme() can be called at any point before the layout is first painted.
Modal Theme#
MagicStarterModalTheme overrides the default Wind UI tokens used for all modal dialogs — confirmation dialogs, password prompts, and two-factor modals. Works the same way as NavigationTheme: register once, all modals pick up the tokens at build time.
MagicStarter.useModalTheme(
MagicStarterModalTheme(
containerClassName: 'rounded-2xl bg-white dark:bg-gray-900',
titleClassName: 'text-lg font-semibold text-gray-900 dark:text-white',
descriptionClassName: 'text-sm text-gray-500 dark:text-gray-400',
primaryButtonClassName: 'bg-primary hover:bg-primary/90 text-white',
dangerButtonClassName: 'bg-red-600 hover:bg-red-700 text-white',
warningButtonClassName: 'bg-amber-500 hover:bg-amber-600 text-white',
inputClassName: 'rounded-lg border border-gray-300 dark:border-gray-600',
maxWidth: 480,
),
);
All fields are optional — omitted fields fall back to the current defaults.
| Field | Default | Controls |
|---|---|---|
containerClassName |
Default dialog container | Outer dialog container background, border-radius, border |
headerClassName |
Default header | Dialog header section (sticky top) |
bodyClassName |
Default body | Scrollable body section |
footerClassName |
Default footer | Dialog footer section (sticky bottom) |
titleClassName |
Default title | Dialog title text style |
descriptionClassName |
Default description | Dialog description/subtitle text style |
primaryButtonClassName |
Default primary | Primary action button (confirm, save) |
secondaryButtonClassName |
Default secondary | Secondary action button (cancel, dismiss) |
dangerButtonClassName |
Default danger | Danger action button (delete, destroy) |
warningButtonClassName |
Default warning | Warning action button |
errorClassName |
Default error | Inline error message text style |
inputClassName |
Default input | Text input fields inside dialogs |
maxWidth |
448.0 |
Maximum dialog width in logical pixels (a dialog only) |
presentation |
MagicStarterModalPresentation.dialog |
Whether the starter's modals open as a dialog, as a bottom sheet, or as a sheet below dialogBreakpoint and a dialog from it (adaptive) |
dialogBreakpoint |
'lg' |
The Wind breakpoint adaptive swaps to a dialog from; read by adaptive only |
headlessBodyClassName |
'pt-6' |
Top padding of the body of an MSBottomSheet drawn as a dialog with no title and no description (an overlay route under adaptive), where the sheet had its drag handle |
Presentation#
presentation: MagicStarterModalPresentation.adaptive opens every modal the starter opens, and every MSBottomSheet.show, as a bottom sheet below dialogBreakpoint and as a centered MSDialog (at most maxWidth) from it: a phone gets the sheet, a desktop window the dialog, from one setting. The screen width is read when a modal opens, so one that is already open keeps its form through a resize; MSBottomSheet.show(dialogBreakpoint:) is ignored, and a breakpoint the Wind theme does not carry throws a StateError naming MagicStarterModalTheme.dialogBreakpoint. Overlay routes (RouteDefinition.overlay) follow too: the provider installs a resolver on magic's router (MagicRoute.useOverlayPresentation), an MSBottomSheet built in a dialog route draws the dialog shell, and under dialog or sheet an overlay route stays the sheet it has always been.
presentation: MagicStarterModalPresentation.sheet opens the starter's modals as the same bottom sheet MSBottomSheet draws, at every width (the plan upgrade prompt, which opens through magic's MagicFeedback.showCustomDialog, stays a dialog): MSDialog.show, MSConfirmDialog.show (and its MagicStarterConfirmDialog alias), MagicStarterPasswordConfirmDialog.show, MagicStarterStepUpDialog.show and MagicStarterTwoFactorModal.show, so the sessions, team settings, two factor, step up and support attachment flows too. MSBottomSheet.show(dialogBreakpoint:) then ignores its breakpoint and stays a sheet.
MagicStarter.useModalTheme(
MagicStarter.modalTheme.copyWith(
presentation: MagicStarterModalPresentation.sheet,
),
);
- Return values do not change:
true/falsefrom a confirm, the proof map ornullfrom the step up. The two factor wizard resolvestrueonce a code was accepted, however it is closed, andfalsewhen closed before that; closed while a code is being checked, it resolves only when thatonConfirmcall settles, with no time limit. Cancel, a scrim tap and back are off during the check. - The confirm, password confirm and step up modals cannot be closed by a scrim tap, as their dialogs cannot, and as sheets they refuse a drag down too and draw no drag handle. Back cancels them while idle; while the password confirm or step up request is in flight, back is off like their Cancel button.
MSDialog.showand the two factor wizard close on a scrim tap or a drag. - A sheet is full width on a phone. On a wide screen it is capped at 640 px by Flutter's Material 3 default, which holds only with
useMaterial3and nobottomSheetTheme.constraintsof the host's;maxWidthapplies to the dialog only. The sheet lifts clear of the keyboard and the home indicator. - In both presentations the footer, with the actions, stays fixed. The header stays pinned above the scrolling body while it takes at most half of the room the body scrolls in, measured at layout, and scrolls away with the body when it would take more (a landscape phone, where a sheet already has about 313 px, the keyboard up, a large text size). Crossing that line rebuilds nothing, so a focused field keeps its focus and the body its scroll offset. A body passed with
scrollable: falsekeeps the header fixed and gets the height left between header and footer. A pinned header holds still while the body bounces or stretches under it; a header past the half mark moves with the body, overscroll included. On a sheet whose content overflows, a drag that starts on the header scrolls the content, so only the handle and the footer drag the sheet down; when the content fits, a drag anywhere on the sheet drags it. - The setting is read when a modal opens. An
MSDialogwidget draws the sheet panel when it is built inside a bottom sheet route, and its dialog shell otherwise: a dialog a host opens with its ownshowDialogkeeps the shell, and its ownshowModalBottomSheet(builder: (_) => MSDialog(...))gets the sheet panel. useWindTheme()anduseTheme()reset the modal theme,presentationincluded, so set it after them.
The theme is stored on MagicStarterManager as modalTheme and reset to defaults by manager.reset(). The active theme is read at widget build time, so useModalTheme() can be called at any point before a dialog is shown.
Sidebar Footer Builder#
Add a custom widget between the navigation items and the user menu in both the desktop sidebar and mobile drawer:
MagicStarter.useSidebarFooter((context) {
return MyVersionBadge();
});
The builder receives the current BuildContext and should return any widget — a version badge, environment indicator, support link, etc.
When sidebarFooterBuilder is null (the default), no extra content is rendered between navigation and the user menu.
Header Builder#
Replace the default app layout header with a custom widget:
MagicStarter.useHeader((context, isDesktop) {
return AppHeader(showMenuButton: !isDesktop);
});
The builder receives the current BuildContext and a bool isDesktop flag so the header can adapt between desktop sidebar and mobile drawer layouts.
When headerBuilder is null, the app layout renders its built-in header with team selector, notification badge, and user profile dropdown.
Logout Callback#
Override the default logout behavior to add custom cleanup steps:
MagicStarter.useLogout(() async {
await Notify.logoutPush();
await SocialAuth.signOut();
await Auth.logout();
MagicRoute.to('/auth/login');
});
When set, the app layout's logout button calls this callback instead of MagicStarterAuthController.instance.logout(). This is useful for apps that need to unregister push tokens, sign out of social providers, or perform other cleanup before navigating to the login screen.
Login Callback#
Run custom logic after a fresh sign-in:
MagicStarter.useLogin(() async {
await Analytics.identify(Auth.user());
});
MagicStarterServiceProvider registers an internal listener on magic's AuthLogin event (dispatched at the end of BaseGuard.startSession) that calls this callback, unawaited, and logs rather than rethrows on failure. It fires for every fresh sign-in path this package ships (password, two-factor challenge, social, guest, and phone OTP) and deliberately not for a cold-boot restore of a stored session or a team switch: both go through Auth.restore(), which dispatches AuthRestored, and only once an API sync confirms the user (never offline, never on a failed sync, never without a userEndpoint configured); neither path ever dispatches AuthLogin.
When onLogin is null (the default), nothing runs.
Guest Claim Callback#
Run custom logic once a guest claim settles:
MagicStarter.useGuestClaimed((GuestClaimOutcome outcome) async {
if (outcome == GuestClaimOutcome.claimed) await Library.refresh();
});
Also accepted as the onGuestClaimed argument of MagicStarter.bootstrap(). With the guest_auth feature on, the provider's AuthLogin and AuthRestored listeners run MagicStarterGuestClaim.instance.claimIfPending() for a non-guest user and call this callback with every outcome but none (claimed, refused, promoted), each of which marks the first sign-in to a real account after a guest session on this device. It runs unawaited and a failure is logged, not rethrown; a sign-in and a restore that share one claim call it once. See Guest Claim for the flow.
When onGuestClaimed is null (the default), nothing runs.
Locale Application#
MagicStarterServiceProvider also applies a signed-in user's saved locale attribute to the running app automatically, so a Turkish account does not land on an English shell until its next cold boot. Configured with magic_starter.localization.apply_user_locale (default true):
Map<String, dynamic> get magicStarterConfig => {
'magic_starter': {
'localization': {'apply_user_locale': false}, // opt out
// ...
},
};
A named static listener on Auth.stateNotifier, plus one immediate "once, now" read in boot() for the ordinary cold-boot case, reads Auth.user()?.get<String>('locale') and hands any non-empty value to MagicStarterManager.applyLocale(), the same method _applySavedLanguage calls after a profile save. applyLocale() holds a single pending target: whichever call reaches it first for a given locale wins, and a same-frame second call for the same target is a no-op, so a profile save (which itself calls Auth.restore() and so wakes this listener too) still issues exactly one Lang.setLocale() call however long the catalogue load takes.
When Auth.check() is false, the user carries no locale, or the toggle is off, nothing happens.
Unified Theme#
MagicStarterTheme wraps all 10 sub-theme objects into a single configuration. Host apps can set the entire visual identity in one call, then selectively override individual sub-themes afterward:
MagicStarter.useTheme(
MagicStarterTheme(
navigation: MagicStarterNavigationTheme(
activeItemClassName: 'active:text-amber-500 active:bg-amber-500/10',
),
form: MagicStarterFormTheme(
inputClassName: 'rounded-xl border-2 border-zinc-700',
),
card: MagicStarterCardTheme(
surfaceClassName: 'bg-zinc-50 dark:bg-zinc-900',
),
),
);
// Override just one sub-theme after unified set
MagicStarter.useAuthTheme(
MagicStarterAuthTheme(cardClassName: 'rounded-3xl bg-zinc-900 p-8'),
);
The unified theme supports copyWith() for partial overrides:
final customTheme = MagicStarter.theme.copyWith(
form: MagicStarterFormTheme(inputClassName: 'rounded-xl'),
);
MagicStarter.useTheme(customTheme);
The manager maintains bidirectional sync: the theme getter constructs a MagicStarterTheme from all 10 individual fields, while the theme setter distributes each sub-theme to its respective field. Both useTheme() and individual use*Theme() methods work together seamlessly.
| Sub-theme | Class | Facade getter/setter |
|---|---|---|
| Navigation | MagicStarterNavigationTheme |
useNavigationTheme() / navigationTheme |
| Modal | MagicStarterModalTheme |
useModalTheme() / modalTheme |
| Form | MagicStarterFormTheme |
useFormTheme() / formTheme |
| Auth | MagicStarterAuthTheme |
useAuthTheme() / authTheme |
| Card | MagicStarterCardTheme |
useCardTheme() / cardTheme |
| Page Header | MagicStarterPageHeaderTheme |
usePageHeaderTheme() / pageHeaderTheme |
| Layout | MagicStarterLayoutTheme |
useLayoutTheme() / layoutTheme |
| Dropdown | MagicStarterDropdownTheme |
useDropdownTheme() / dropdownTheme |
| Button | MagicStarterButtonTheme |
useButtonTheme() / buttonTheme |
| Field | MagicStarterFieldTheme |
useFieldTheme() / fieldTheme |
All sub-theme classes live in lib/src/configuration/magic_starter_theme.dart.
Form Theme#
MagicStarterFormTheme overrides Wind UI tokens for form inputs, labels, placeholders, buttons, links, and checkboxes across all auth and profile forms.
MagicStarter.useFormTheme(
MagicStarterFormTheme(
inputClassName: 'w-full px-4 py-4 rounded-xl bg-zinc-900 border border-zinc-700 text-white',
primaryButtonClassName: 'w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold',
),
);
useFormTheme() assigns the whole struct, so every field you leave out goes back to its default, including the ones useWindTheme() derived. To change one string and keep the rest, copy the active theme. MagicStarterModalTheme has the same copyWith:
MagicStarter.useFormTheme(
MagicStarter.formTheme.copyWith(inputClassName: 'w-full py-[11px] ...'),
);
MagicStarter.useModalTheme(
MagicStarter.modalTheme.copyWith(inputClassName: 'w-full py-[11px] ...'),
);
Auth Theme#
MagicStarterAuthTheme overrides Wind UI tokens for the auth form card, title, subtitle, error banner, social divider, and link styles on login/register/forgot/reset pages.
MagicStarter.useAuthTheme(
MagicStarterAuthTheme(
cardClassName: 'rounded-3xl bg-zinc-900 border border-zinc-700 p-8',
titleClassName: 'text-3xl font-black text-white text-center',
),
);
Card Theme#
MagicStarterCardTheme overrides Wind UI tokens for MSCard variant backgrounds, border radius, padding, and title styles.
MagicStarter.useCardTheme(
MagicStarterCardTheme(
surfaceClassName: 'bg-zinc-900 border border-zinc-700',
borderRadius: 'rounded-xl',
),
);
Page Header Theme#
MagicStarterPageHeaderTheme overrides Wind UI tokens for the MSPageHeader container, title, subtitle, action container and compact bar, plus two layout switches.
MagicStarter.usePageHeaderTheme(
MagicStarterPageHeaderTheme(
titleClassName: 'text-3xl font-black text-white',
),
);
inlineActions, and the one combination that overflows#
The header stacks below sm and lays out as a row above it. An app that wants a
row at EVERY width, so a phone header keeps its action beside the title instead
of dropping it under the subtitle, overrides containerClassName:
MagicStarter.usePageHeaderTheme(
const MagicStarterPageHeaderTheme(
containerClassName: 'w-full flex flex-row items-start justify-between gap-3 ...',
// Required with the override above, and easy to miss.
inlineActions: true,
),
);
Setting the container alone overflows. inlineActions does two things: it
selects containerInlineClassName, and it gives the title row flex-1 min-w-0
instead of sm:flex-1. Without the second half the title column is
flex-initial, a loose fit, so a long title takes its intrinsic width and runs
past the actions rather than shrinking. line-clamp-2 on the title does not
help, for the same reason truncate does not without a constrained box.
MSPageHeader.inlineActions is bool? and defaults to this field, so a single
screen can still opt out with inlineActions: false. MSPageScaffold does not
expose the argument, which is why the theme is the only reachable switch for a
scaffold consumer.
Large title#
largeTitle: true makes every MSPageHeader a large title and every
MSPageScaffold collapse it into a compact bar on scroll, the starter's own
pages included. A widget argument still wins (size: on the header,
largeTitle: on the scaffold).
MagicStarter.usePageHeaderTheme(
const MagicStarterPageHeaderTheme(
largeTitle: true,
largeTitleClassName: 'text-4xl lg:text-3xl font-bold text-fg',
compactBarClassName: 'px-2 bg-white/80 dark:bg-gray-900/80 border-b border-color-border',
compactBarOpaqueClassName: 'px-2 bg-surface-container border-b border-color-border',
compactTitleClassName: 'text-base font-semibold text-fg truncate',
compactBarBlurSigma: 24,
),
);
The bar is glass: compactBarClassName should be translucent, and
compactBarBlurSigma blurs what scrolls under it (0 adds no blur layer).
Under high contrast (MediaQuery.highContrast, iOS Increase Contrast) it paints
compactBarOpaqueClassName with no blur. A semantic alias carries no opacity
step, so MagicStarterTheme.fromWind sets both fills to the opaque
bg-surface-container; a host after glass passes its own translucent fill.
Host-owned page chrome#
manager.pageChrome is a MagicStarterPageChrome, a ChangeNotifier that lives
as long as the manager (reset() clears it, it does not replace it). A host that
draws the page title and back control in its own bar registers a predicate:
MagicStarter.useHostPageChrome((context) => MediaQuery.sizeOf(context).width < 768);
Wherever it answers true, MSPageHeader draws only its actions (right aligned,
nothing at all without them, no heading role) and reports a
MagicStarterPageChromeEntry (title, subtitle, backLabel, backFallback,
value equality) keyed by its own state; MSPageScaffold then builds neither the
large title nor the compact bar. The predicate gets a context inside the page and
is asked again when its dependencies (the MediaQuery it reads) or the header's
widget change. A header reports only while its ModalRoute is the current one:
it withdraws when a route is pushed over it and the moment its own route is
popped, before the exit transition ends, and reports again when the page above
is popped. So current names the top page, and is null when that page has no
MSPageHeader. A header under no route reports whenever the predicate answers
true.
Every starter page follows it, the two magic_notifications screens included:
the starter mounts the inbox and the preferences with a headerBuilder that
draws their header as an MSPageHeader (lib/src/routes/notification_routes.dart),
so the inbox reports its title and keeps its mark-all-as-read action, and the
preferences report the settings hub as their backFallback.
| Member | Contract |
|---|---|
hostOwns(context) |
The predicate's answer; false while none is registered |
current |
The entry of the owner whose first report is the most recent; an update keeps its place, so the older of two pages mounted together under an instant swap neither takes over nor clears the newer one when it withdraws |
report(owner, entry) / withdraw(owner) |
Called by the header from didChangeDependencies, didUpdateWidget and dispose, and withdraw also when its route is popped; it reports only while its route is current |
| listeners | Notified after the frame in which current changed, never during a build; a change undone within the frame notifies nobody |
The host's bar listens and goes back with the fallback the page reported:
ListenableBuilder(listenable: MagicStarter.pageChrome, builder: (context, _) {
final MagicStarterPageChromeEntry? entry = MagicStarter.pageChrome.current;
return TopBar(title: entry?.title, onBack: () => MagicRoute.back(fallback: entry?.backFallback));
});
Layout Theme#
MagicStarterLayoutTheme overrides Wind UI class names and dimensions for the app layout shell: sidebar, header, the content area and its background, drawer background, brand bar, and bottom navigation. It also carries the two breakpoints and the compact width that decide when the sidebar appears and when it renders as an icon rail.
MagicStarter.useLayoutTheme(
MagicStarterLayoutTheme(
sidebarWidth: 280,
sidebarClassName: 'h-full flex flex-col bg-zinc-900 border-r border-zinc-700',
contentBackgroundLightColor: 'zinc',
contentBackgroundLightShade: 50,
),
);
Page Geometry#
pageContainerClassName is the Wind geometry every page in the app shares: the width cap, the horizontal edge margins, and the vertical rhythm. MSPageContainer reads it, and MSPageScaffold composes MSPageContainer, so every page in this package and every page in the host app resolve to the same numbers.
// In AppServiceProvider.boot()
MagicStarter.manager.pageContainerClassName =
'max-w-6xl px-4 sm:px-5 lg:px-8 pt-6 sm:pt-8 pb-24';
The geometry belongs to the host, not to this package. Magic Starter's account pages render inside the host's own shell (see Layout Theme and the layout.app view key), so a width chosen here is a guess about someone else's layout. While each surface carried its own answer, the settings pages centred 64px further out per side than the host's pages and the team pages capped at nothing at all, all inside identical chrome.
Carry the whole geometry in the one string. A cap that agrees while the padding does not still reads as two different pages.
It defaults to MagicStarterManager.defaultPageContainerClassName (max-w-7xl px-4 lg:px-8 pt-6 sm:pt-8 pb-16), so an app that configures nothing still gets a complete, capped, edge-padded page.
Per-page tuning goes through MSPageContainer's className, which is appended after the shared geometry:
MSPageContainer(
className: 'pb-0', // This page ends with its own footer bar.
child: pageBody,
)
View Registry Integration#
The manager holds a private MagicStarterViewRegistry instance, exposed via the view getter:
final MagicStarterViewRegistry _viewRegistry = MagicStarterViewRegistry();
MagicStarterViewRegistry get view => _viewRegistry;
The facade provides a shortcut: MagicStarter.view delegates to manager.view. All view and layout registration, lookup, and building flows through this single registry instance.
Default Views#
registerDefaultViews() is called in the constructor and populates the registry with plugin-provided views. It uses a "register if absent" pattern — existing keys are never overwritten:
void _registerDefault(String key, Widget Function() builder) {
if (!_viewRegistry.has(key)) {
_viewRegistry.register(key, builder);
}
}
The following default views are registered:
| Key | View | Condition |
|---|---|---|
auth.login |
MagicStarterLoginView |
Always |
auth.register |
MagicStarterRegisterView |
Always |
auth.forgot_password |
MagicStarterForgotPasswordView |
Always |
auth.reset_password |
MagicStarterResetPasswordView |
Always |
auth.verify_email |
MagicStarterVerifyEmailView |
hasEmailVerificationFeatures() |
auth.two_factor_challenge |
MagicStarterTwoFactorChallengeView |
hasTwoFactorFeatures() |
auth.otp_verify |
MagicStarterOtpVerifyView |
hasPhoneOtpFeatures() |
profile.settings |
MagicStarterProfileSettingsView |
Always |
teams.create |
MagicStarterTeamCreateView |
hasTeamFeatures() |
teams.settings |
MagicStarterTeamSettingsView |
hasTeamFeatures() |
teams.invitation_accept |
MagicStarterTeamInvitationAcceptView |
hasTeamFeatures() |
notifications.list and notifications.preferences are NOT among them: registerDefaultViews() no longer registers them, because there is no field left to guard with hasNotificationFeatures(). Those two keys live on magic_notifications's own Notify.view, which registerMagicStarterNotificationRoutes() re-registers wrapped in this package's page geometry; see Notifications.
Default layouts:
| Key | Layout |
|---|---|
layout.guest |
MagicStarterGuestLayout |
layout.app |
MagicStarterAppLayout |
MagicStarter Facade#
MagicStarter is a static utility class (private constructor MagicStarter._()) that delegates every call to the manager singleton. It provides the public API that host apps interact with:
| Facade Method | Manager Property |
|---|---|
MagicStarter.useUserModel(factory) |
manager.userFactory = factory |
MagicStarter.createUser(data) |
manager.userFactory(data) |
MagicStarter.useTeamResolver(...) |
manager.teamResolver = config |
MagicStarter.useNavigation(...) |
manager.navigationConfig = config |
MagicStarter.useTheme(theme) |
manager.theme = theme (sets all 10 sub-themes) |
MagicStarter.useNavigationTheme(theme) |
manager.navigationTheme = theme |
MagicStarter.useModalTheme(theme) |
manager.modalTheme = theme |
MagicStarter.useFormTheme(theme) |
manager.formTheme = theme |
MagicStarter.useAuthTheme(theme) |
manager.authTheme = theme |
MagicStarter.useCardTheme(theme) |
manager.cardTheme = theme |
MagicStarter.usePageHeaderTheme(theme) |
manager.pageHeaderTheme = theme |
MagicStarter.useHostPageChrome(owns) |
manager.pageChrome.ownsWhen = owns |
MagicStarter.pageChrome |
manager.pageChrome |
MagicStarter.useLayoutTheme(theme) |
manager.layoutTheme = theme |
MagicStarter.useDropdownTheme(theme) |
manager.dropdownTheme = theme |
MagicStarter.useButtonTheme(theme) |
manager.buttonTheme = theme |
MagicStarter.useFieldTheme(theme) |
manager.fieldTheme = theme |
MagicStarter.useSidebarFooter(builder) |
manager.sidebarFooterBuilder = builder |
MagicStarter.useHeader(builder) |
manager.headerBuilder = builder |
MagicStarter.useLogout(callback) |
manager.onLogout = callback |
MagicStarter.useLogin(callback) |
manager.onLogin = callback |
MagicStarter.useGuestClaimed(callback) |
manager.onGuestClaimed = callback |
MagicStarter.useSocialAuth(socialAuth) |
manager.socialAuth = socialAuth |
MagicStarter.useLocaleOptions(locales) |
manager.localeOptions = options |
MagicStarter.useGuestAuthEntry(builder) |
manager.guestAuthEntryBuilder = builder (stored only: no starter screen reads it yet) |
MagicStarter.useNewsletterLabel(label) |
manager.newsletterLabel = label |
MagicStarter.view |
manager.view |
MagicStarter.isReady |
manager.isReady |
The reset() method on the manager restores all properties to their defaults, clears the view registry, and re-registers default views — used in test setUp() blocks for isolation.
Related#
- MagicStarterServiceProvider — bootstrap entry point that binds the manager and registers Gate abilities
- View Registry — string-keyed view factory for overridable UI
- Magic Framework — IoC Container — singleton and factory binding reference