Configuration
On this page
Table of Contents#
- Overview
- Full Config Map Reference
- notifications.push
- Asking for permission
- Describing the user
- notifications.database
- notifications.mail
- notifications.soft_prompt
- Environment Variables
- Runtime Config Access
Overview#
All configuration lives in a single Dart map returned by notificationConfig, typically at lib/config/notifications.dart. The Magic Framework's ConfigRepository merges this map at boot time so that Config.get(key) resolves values at any point in the application lifecycle.
Full Config Map Reference#
Map<String, dynamic> get notificationConfig => {
'notifications': {
'push': {
'driver': 'onesignal',
'app_id': const String.fromEnvironment('ONESIGNAL_APP_ID'),
// 'safari_web_id': 'web.onesignal.auto.xxx', // web only, optional
'service_worker_path': 'OneSignalSDKWorker.js',
'service_worker_scope': '/onesignal/',
'notify_button_enabled': false,
'self_test_enabled': false,
'auto_request_on_login': false,
'reprompt_after_hours': 0,
'fallback_to_settings': true,
'share_user_attributes': false,
},
'database': {
'enabled': true,
'polling_interval': 30,
},
'mail': {
'enabled': false,
},
'soft_prompt': {
'enabled': true,
'title': 'Stay Updated',
'message': 'Get notified about important events.',
},
},
};
notifications.push#
Controls the push notification channel and its driver.
| Key | Type | Default | Description |
|---|---|---|---|
driver |
String |
'onesignal' |
Push driver identifier. 'onesignal' is the only built-in driver. |
app_id |
String |
'' |
OneSignal App ID (UUID format). Must be non-empty for push to function. |
safari_web_id |
String? |
null |
Safari Web Push ID for Safari browser support. Optional — omit for non-Safari targets. |
service_worker_path |
String? |
'OneSignalSDKWorker.js' |
Web only. Path to the OneSignal service worker script. A Flutter web build already registers flutter_service_worker.js at the root scope, so OneSignal needs its own worker rather than sharing that one. |
service_worker_scope |
String? |
'/onesignal/' |
Web only. The scope OneSignal registers its worker under. Whichever of the two registrations lands second wins the root scope, so pointing OneSignal at a scope of its own (rather than /) keeps it from silently losing to Flutter's worker. |
notify_button_enabled |
bool |
false |
Whether to show the floating OneSignal notification bell widget on web. |
auto_request_on_login |
bool |
false |
Whether the package may raise the OS permission prompt by itself, once, after an identity is declared through Notify.initializePush(userId). Ships off, and an absent key is off: an app that upgrades without touching its config keeps asking on its own terms. See Asking for permission below for when it actually fires and why web needs care. |
reprompt_after_hours |
int |
0 |
How long before the app's OWN reminder may be shown again to somebody who turned it down. 0 (and an absent key) means never. Read by NotificationManager.pushPromptAdvice(); this package never stores the decline timestamp. |
fallback_to_settings |
bool |
true |
Mobile only. Whether requestPermission() on an already-denied device opens the app's settings page instead of resolving silently. On by default, which is what the OneSignal driver always did; turn it off for an app that would rather ask once and drop it. Inert on web, which has no such API. |
share_user_attributes |
bool |
false |
Whether the attributes this app describes its users with (an email address, and whatever it puts in a tag) are sent to the push platform at all. Ships off, and an absent key is off: this is personal data leaving for a third party, so an adopter opts in deliberately. See Describing the user below. |
self_test_enabled |
bool |
false |
Gates PushChannel.send(), the client-triggered surface that asks the backend to push a test notification to the caller's own devices. Ships off: it is a new authenticated capability whose only effect is making the platform emit a real push, and that is worth switching on deliberately once an app actually calls for it, not something worth having live from day one. Turning it on takes both halves: the backend carries the matching magic-starter.onesignal.self_test_enabled switch, also off by default, and answers 501 while it is off, so setting only this key changes which side refuses. |
Asking for permission#
There are two separate cadences here, and conflating them is the mistake this section exists to prevent.
The OS prompt is a one-shot. Once a person has answered it, no code can
raise it again: on the web Notification.requestPermission() against a denied
origin resolves immediately with denied and shows nothing, and iOS and
Android behave the same way. The only route back is the browser's site
settings or the OS Settings app. So an automatic request is worth exactly one
shot, on a device that has never been asked.
The app's own reminder is not a one-shot. It is your UI, and it may appear
on whatever cadence you configure, including on a device whose OS permission
is denied: on mobile its button opens the app's settings page (see
fallback_to_settings), which is a real route back.
The automatic request#
With auto_request_on_login: true, the package raises the platform request
once, from want() (which Notify.initializePush(userId) calls), and only
when:
- the OS has never asked on this device (
PushDriver.canRaisePermissionRequest()), - the device is not already subscribed,
- and it has not already been raised in this launch.
It is never raised from reconcilePushIdentity(), which runs on every
auth-state change and on a signed-out boot: a dialog from there arrives with
nothing in front of it explaining what it is for.
The reminder, and what its button can do#
Ask the package rather than deriving it:
final advice = await Notify.manager.pushPromptAdvice(
declinedAt: await myOwnStorage.readPushPromptDeclinedAt(),
);
if (!advice.show) return const SizedBox.shrink();
return switch (advice.action) {
// A real dialog will appear: Notify.requestPushPermission().
PushPromptAction.request => MyEnableRow(),
// The prompt is spent, but this platform routes the same call to Settings.
PushPromptAction.openSettings => MyOpenSettingsRow(),
// Nowhere to send the tap. Say where the switch lives instead.
PushPromptAction.instructions => MyBlockedInstructionsRow(),
PushPromptAction.none => const SizedBox.shrink(),
};
advice.show already accounts for reachability(), reprompt_after_hours,
soft_prompt.enabled, and the timestamp you passed in. advice.reachability
is the same four-state read, carried so a status row does not need a second
platform call.
The decline timestamp stays yours. A decline is your UI's event, recorded wherever you already keep device state; a copy inside this package would be a second answer to drift out of sync with the first.
Describing the user#
OneSignal segments and personalises on what it knows about a person: an email subscription, and tags. This package owns the transport and the identity lifecycle; your app owns the values, because "email, first name, last name" is one product's answer and the next app has different fields or is not permitted to send an address at all.
Register the description once, at boot, next to wherever you register the driver:
Notify.describePushUserUsing((String externalId) {
final user = Auth.user();
if (user == null) return null; // nothing to describe for this identity
return PushUserAttributes(
email: user.email,
tags: <String, String>{
'first_name': user.firstName,
'last_name': user.lastName,
'locale': user.locale,
},
);
});
externalId is the same string you passed to Notify.initializePush(), so an
app that keeps its user state elsewhere can look the right person up. The
resolver is called on every login and every account switch, so nothing
re-registers per login and no login path has to remember to push a profile
after it. Returning null says there is nothing to describe, which is the
answer for a guest or for somebody who has not consented.
Nothing is sent until share_user_attributes is true. An app that registers
no description behaves exactly as it did before this existed.
What an account switch does#
Everything this package wrote is removed before the login or logout
that moves the device on, while the SDK still points at the person it was
written for. The SDK's own migration guide is the reason
(onesignal_flutter, MIGRATION_GUIDE.md): a login to an id that does not
exist yet creates the user and applies the operations already performed under
the device-scoped user to it, and a logout leaves exactly such a
device-scoped user behind. A removal issued after the switch therefore follows
the next person onto their record, and on a shared device that is the whole
problem.
The cost is that the previous person's tags come off their OneSignal record when they leave this device, and the resolver puts them straight back on their next login anywhere. The alternative is leaving a name and an email address attached to a subscription that has moved to somebody else.
Only what this package wrote is ever removed. A tag set from the OneSignal dashboard, from your backend, or from another client is not this device's to delete.
Two things to get right#
A tag written from a client is user-tamperable. Anybody holding the app can call the SDK from a browser console and write whatever they like under any key. That is fine for choosing an audience and fine for personalising a message. It is not fine for anything a backend later trusts: a plan tier, an entitlement, a role, a quota. Those belong in a server-side tag write over OneSignal's REST API, from the system that already owns the fact. This package ships no such path and will not; it has no server credentials and no business holding any.
An email address and a name are personal data going to a third party. That
is what share_user_attributes gates, and why it ships off. It gates the whole
seam rather than the email alone, because this package cannot tell one tag from
another: {'first_name': 'Ada'} is as personal as an address and
{'plan': 'pro'} is not, and both arrive here as two strings.
Platform parity#
Both drivers implement the same four calls (setTags, removeTags,
addEmail, removeEmail) against the same v16 user model, so a description
behaves identically on mobile and on the web. The one difference is a browser's
to make: the web SDK is whatever script your web/index.html loads, so the
driver checks that User.addEmail exists before it uses it and reports through
the log when it does not, instead of accepting an address that went nowhere.
A custom PushDriver inherits removeTags (a loop over removeTag) and
inherits an addEmail that sends nothing and says so in the log, so a platform
with no email channel is honest rather than quiet.
notifications.database#
Controls the in-app (database) notification channel and its polling behavior.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
true |
Whether the database channel is active. |
polling_interval |
int |
30 |
Seconds between GET /notifications requests. Valid range: 5–600. |
NotificationPoller uses the default interval of 30 seconds when no config override is applied. Change it with:
dart run magic_notifications configure --polling-interval 60
notifications.mail#
Controls the mail notification channel.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
false |
Whether mail notifications are active. Mail dispatch is handled server-side. |
notifications.soft_prompt#
The package does not ship a soft-prompt dialog. It ships the reachability
read the dialog would need to decide whether to show itself at all:
PushDriver.reachability() on the resolved driver (see
Drivers) answers unavailable, blocked,
off, or on without triggering the OS permission dialog. Build the actual
prompt UI in the host app, gated on that read, using these config values for
its copy:
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
true |
Whether the host app's own soft prompt should show before Notify.requestPushPermission(). NotificationManager.pushPromptAdvice() honours it (a false here answers show: false), so a call site using that API does not have to check it a second time. |
title |
String |
'Stay Updated' |
Suggested dialog title text. |
message |
String |
'Get notified about important events.' |
Suggested dialog body text. |
A call site that needs the raw state still reads reachability() directly, but
one deciding whether to interrupt somebody should ask
NotificationManager.pushPromptAdvice() instead: it folds this key, the
re-prompt interval and the platform's route back into one answer. See
Asking for permission.
final reachability = await NotificationManager().pushDriver.reachability();
if (reachability == PushReachability.blocked) {
// The OS will not re-prompt. Point at the platform setting instead.
}
Environment Variables#
| Variable | Used In | Description |
|---|---|---|
ONESIGNAL_APP_ID |
notifications.push.app_id |
OneSignal App ID injected at compile time via String.fromEnvironment. |
Set in .env (or your CI secrets):
ONESIGNAL_APP_ID=12345678-1234-1234-1234-123456789012
Build with the variable injected:
flutter build apk --dart-define-from-file=.env
flutter build web --dart-define-from-file=.env
Runtime Config Access#
After the Magic Framework boots, any config value is accessible via Config.get:
// Read the push App ID
final appId = Config.get<String>('notifications.push.app_id');
// Read the polling interval (returns int)
final interval = Config.get<int>('notifications.database.polling_interval');
// Read soft prompt enabled flag
final softPromptEnabled = Config.get<bool>('notifications.soft_prompt.enabled');
Config.get returns null when the key is absent; use the typed overload with a default:
final enabled = Config.get<bool>('notifications.database.enabled') ?? true;
Related