Preferences
How a person's notification opt-ins are modelled, which backend endpoints the shipped preferences screen talks to, and the push prompt component that asks for permission.
On this page
Overview#
Two things share the word "preferences" in this package, and they take different shapes:
| Piece | Shape | Used by |
|---|---|---|
NotificationPreference model |
Flat: global toggles plus per-type overrides | PushChannel, through Notifiable.notificationPreference. You load and attach it yourself. |
Preferences screen (NotificationPreferencesView) |
A type by channel matrix, read from and written to the backend | People changing their settings |
NotificationPreference represents opt-in and opt-out decisions across channels: global toggles (push, email, in-app) and per-type overrides (typePreferences). Its isEnabled method combines both layers to answer "should this notification be delivered through this channel?". The package fetches nothing into this model.
NotificationPreference Model#
import 'package:magic_notifications/magic_notifications.dart';
class NotificationPreference {
final bool pushEnabled;
final bool emailEnabled;
final bool inAppEnabled;
final Map<String, ChannelPreference> typePreferences;
const NotificationPreference({
this.pushEnabled = true,
this.emailEnabled = true,
this.inAppEnabled = true,
this.typePreferences = const {},
});
}
Properties#
| Property | Type | Default | Description |
|---|---|---|---|
pushEnabled |
bool |
true |
Global push toggle |
emailEnabled |
bool |
true |
Global email toggle, for a backend that honours it; the package has no mail channel |
inAppEnabled |
bool |
true |
Global in-app (database) toggle |
typePreferences |
Map<String, ChannelPreference> |
{} |
Per-notification-type channel overrides |
Deserialisation#
import 'package:magic_notifications/magic_notifications.dart';
NotificationPreference deserialise() {
final pref = NotificationPreference.fromMap({
'push_enabled': true,
'email_enabled': false,
'in_app_enabled': true,
'type_preferences': {
'monitor_down': {'push': true, 'email': true, 'in_app': true},
'monitor_up': {'push': false, 'email': false, 'in_app': true},
},
});
return pref;
}
A missing toggle reads as true.
Serialisation#
import 'package:magic_notifications/magic_notifications.dart';
Map<String, dynamic> serialise(NotificationPreference pref) {
final map = pref.toMap();
// {
// 'push_enabled': true,
// 'email_enabled': false,
// 'in_app_enabled': true,
// 'type_preferences': {...},
// }
return map;
}
copyWith#
import 'package:magic_notifications/magic_notifications.dart';
NotificationPreference enableEmail(NotificationPreference pref) {
final updated = pref.copyWith(emailEnabled: true);
return updated;
}
ChannelPreference Model#
class ChannelPreference {
final bool push;
final bool email;
final bool inApp;
const ChannelPreference({
this.push = true,
this.email = true,
this.inApp = true,
});
}
ChannelPreference holds the per-type override for each channel. It deserialises from one entry of the type_preferences map:
import 'package:magic_notifications/magic_notifications.dart';
ChannelPreference monitorDownPreference() {
final monitorDown = ChannelPreference.fromMap({
'push': true,
'email': true,
'in_app': false,
});
return monitorDown;
}
Global Toggles vs Per-Type Preferences#
isEnabled(notificationType, channel) applies a two-stage gate:
import 'package:magic_notifications/magic_notifications.dart';
abstract class PreferenceGate {
Map<String, ChannelPreference> get typePreferences;
bool _isGlobalChannelEnabled(String channel);
bool _isTypeChannelEnabled(ChannelPreference preference, String channel);
bool isEnabled(String notificationType, String channel) {
// Stage 1: global toggle
final globalEnabled = _isGlobalChannelEnabled(channel);
if (!globalEnabled) return false;
// Stage 2: type-specific override
final typePref = typePreferences[notificationType];
if (typePref == null) return true; // default: enabled
return _isTypeChannelEnabled(typePref, channel);
}
}
Channel alias resolution:
| Argument | Maps to |
|---|---|
'push' |
pushEnabled / ChannelPreference.push |
'mail' or 'email' |
emailEnabled / ChannelPreference.email |
'database' or 'in_app' |
inAppEnabled / ChannelPreference.inApp |
| anything else | false |
Example: global push is true but monitor_up.push is false, so isEnabled('monitor_up', 'push') returns false and PushChannel skips delivery.
PushChannel.send runs this check when the Notifiable has a non-null notificationPreference:
import 'package:magic_notifications/magic_notifications.dart';
Future<void> send(Notifiable notifiable, Notification notification) async {
final preference = notifiable.notificationPreference;
if (preference != null) {
if (!preference.isEnabled(notification.type, 'push')) return;
}
}
API Endpoints#
The preferences screen needs two endpoints from the backend:
| Method | Path | Description |
|---|---|---|
GET |
/notification-preferences |
The type by channel matrix for the signed-in user |
PUT |
/notification-preferences |
Change one cell, or a batch of them |
magic-starter-laravel implements both. It differs from the contract only on the inbox: its unread-count shape and its per_page query key do not match what the client reads and sends, and a backend written to the contract is not affected. See Laravel Backend Setup.
GET /notification-preferences Response#
A type by channel matrix. Each cell carries enabled and locked; a locked cell is one the backend refuses to change (a security mail, an account alert), and the screen renders its switch disabled rather than letting a tap earn a 422. The channel names are whatever your backend offers; the screen has labels for mail, database, push and sms, and capitalises any other.
{
"data": {
"monitor_down": {
"label": "Monitor down",
"channels": {
"mail": { "enabled": true, "locked": false },
"database": { "enabled": true, "locked": true },
"push": { "enabled": false, "locked": false }
}
}
},
"meta": { "push_provisioned": true }
}
meta.push_provisioned is the backend saying whether it has a push app_id at all. When it is false the push row still renders, with a hint under it, because the preference is a real choice even while nothing can deliver it yet. A response without the flag leaves the hint off.
PUT /notification-preferences Request Body#
One cell, which is what a switch in the matrix sends:
{ "type": "monitor_down", "channel": "push", "is_enabled": false }
Or a batch, which is what the bulk row above the matrix sends:
{
"preferences": [
{ "type": "monitor_down", "channel": "push", "is_enabled": false },
{ "type": "monitor_up", "channel": "push", "is_enabled": false }
]
}
UI Integration#
The package ships NotificationPreferencesView and registers it under notifications.preferences. Mount it through the view registry, and replace or wrap it by registering your own builder under the same key:
import 'package:flutter/widgets.dart' hide Notification;
import 'package:magic_notifications/magic_notifications.dart';
// Once, at boot: wrap the default with a back route.
void registerPreferencesView() {
Notify.view.register('notifications.preferences', () {
return const NotificationPreferencesView(backRoute: '/settings');
});
}
// In the page that hosts it:
class NotificationPreferencesPage extends StatelessWidget {
const NotificationPreferencesPage({super.key});
@override
Widget build(BuildContext context) {
return Notify.view.make('notifications.preferences');
}
}
contentClassName takes '' when the screen sits inside a page container that already carries the app's padding. Translation Keys lists every key the screen needs.
Push Prompt Component#
PushPrompt, PushPromptHost and PushOffNotice (lib/src/ui/components/push_prompt/) are the package's own push-permission soft prompt: a row asking for push before the platform's one-shot prompt is spent, built on NotificationManager.pushPromptAdvice().
-
PushPromptis presentational: givenreachability,action,declinedandbusy, it renders the presentation for the device's state and reportsonEnableandonDecline. It touches no platform API itself. -
PushPromptHostwires it to the live device. It owns nothing about when to ask (pushPromptAdvicedoes), but it owns the one thing the package refuses to decide: the key the decline is recorded under on this device. That is why it takes adeclinedVaultKeyconstructor parameter rather than a fixed key:dart import 'package:flutter/widgets.dart'; import 'package:magic_notifications/magic_notifications.dart'; Widget pushPrompt() { return const PushPromptHost(declinedVaultKey: 'my_app.push_prompt_declined'); }It writes the decline as an ISO-8601 UTC instant and reads only that shape; a value some other shape wrote reads as "never declined", so an app carrying an older value migrates it once before constructing this widget.
-
PushOffNoticeis a quiet shell marker (a sidebar row, or a compact glyph withcompact: truefor a mobile top bar) that tells a person, on a screen they were already looking at, that this device cannot be reached by push. Tapping it calls the requiredonOpenPreferencescallback, which a host wires to whereverPushPromptHostand its controls live:dart import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; import 'package:magic_notifications/magic_notifications.dart'; Widget offNotice() { return PushOffNotice( onOpenPreferences: () => MagicRoute.to('/settings/notifications'), ); }
PushPrompt and PushOffNotice resolve every string through trans('notifications.push_prompt.*'); Translation Keys lists each one with its English reference copy.
The components expose no per-instance className override. A host that wants a different look copies the component's recipe file into its own source and registers its own builder.
Translation Keys#
The package ships no translation catalogue. Every string the inbox screen, the preferences screen, NotificationDropdown, PushPrompt and PushOffNotice show goes through Magic's trans(), so a host adds each key below to every locale it ships; a missing key renders as the key itself. The English column is reference copy for your own en file, not text the package carries. A :name in the copy is a placeholder the widget fills.
| Key | Where it shows | English reference copy |
|---|---|---|
notifications.title |
Inbox header title, dropdown panel heading, and the title of the error shown when a delete fails | Notifications |
notifications.list_subtitle |
Inbox header subtitle | Everything we sent you |
notifications.mark_all_read |
Inbox header action and dropdown heading link, while something is unread | Mark all as read |
notifications.empty |
Inbox and dropdown with no rows | Nothing here yet |
notifications.load_failed |
The inbox when its read failed; the dropdown only when its notificationStream emits an error (with Notify.notifications() a failed fetchNotifications() is logged and the bell keeps its held list) |
Could not load notifications |
notifications.delete |
Screen-reader label of an inbox row's delete control | Delete notification |
notifications.delete_failed |
The error Magic.error shows when an inbox delete fails |
Could not delete the notification |
notifications.view_all |
Dropdown footer, only with onViewAll |
View all |
notifications.badge_overflow |
Dropdown badge above nine unread rows | 9+ |
notifications.preferences_title |
Preferences header title | Notification preferences |
notifications.preferences_description |
Preferences header subtitle | Choose how we reach you |
notifications.no_preferences |
Preferences screen when the matrix is empty | Nothing to configure |
notifications.fetch_error |
NotificationPreferencesController error when GET /notification-preferences answers a failure status; the shipped screen shows no_preferences instead |
Could not load your preferences |
notifications.bulk_title |
Heading of the bulk card above the matrix | Every notification type |
notifications.bulk_description |
Text of the bulk card | Turn a channel on or off at once |
notifications.channel_email |
Label of the mail row |
|
notifications.channel_in_app |
Label of the database row |
In app |
notifications.channel_push |
Label of the push row |
Push |
notifications.channel_sms |
Label of the sms row |
SMS |
notifications.channel_push_unconfigured |
Hint under the push row while meta.push_provisioned is false |
Push is not set up yet |
notifications.push_prompt.unavailable_body |
PushPrompt on a build with no push |
This build has no push notifications. |
notifications.push_prompt.on_body |
PushPrompt once push reaches the device |
You're all set to receive push notifications. |
notifications.push_prompt.blocked_title |
PushPrompt title on a blocked device |
Notifications are blocked |
notifications.push_prompt.blocked_body_settings |
PushPrompt on a blocked device that can open its settings |
Turn notifications back on in your device settings. |
notifications.push_prompt.blocked_body_web |
PushPrompt on a blocked browser |
Open the padlock icon in your browser's address bar to allow notifications. |
notifications.push_prompt.blocked_body_ios |
PushPrompt on a blocked iOS or macOS device without a settings route |
Open Settings, then Notifications, to allow notifications for this app. |
notifications.push_prompt.blocked_body_android |
PushPrompt on any other blocked device without a settings route |
Open this app's notification settings to allow notifications. |
notifications.push_prompt.declined_body |
PushPrompt after the reminder was turned down |
You turned off this reminder. You can still enable push any time. |
notifications.push_prompt.ask_title |
PushPrompt title while asking |
Turn on notifications |
notifications.push_prompt.ask_body |
PushPrompt text while asking |
Get notified the moment something needs your attention. |
notifications.push_prompt.open_settings |
PushPrompt button when it opens the settings page |
Open settings |
notifications.push_prompt.enable |
PushPrompt button when it raises the request |
Enable |
notifications.push_prompt.not_now |
PushPrompt decline button |
Not now |
notifications.push_prompt.shell_notice |
PushOffNotice label |
Push is off |
notifications.push_prompt.shell_notice_a11y |
PushOffNotice screen-reader label |
Push notifications are off |
The keys cover the package's own labels only. The dropdown and the inbox draw each notification's title and body exactly as the backend sent them, without trans(), so localising that content is the backend's or the host's job.
The same widgets also read these keys outside the notifications.* namespace. An app whose other screens already define them shares them; any other app adds them too.
| Key | Where it shows | English reference copy |
|---|---|---|
time.just_now |
Row time in the inbox and dropdown, under a minute old | Just now |
time.minutes_ago |
Row time under an hour old | :minutes min ago |
time.hours_ago |
Row time under a day old | :hours h ago |
time.days_ago |
Row time under a week old | :days d ago |
time.date_format |
Row time a week old or more | :day/:month/:year |
common.page_of |
Inbox pager | Page :current of :total |
common.back |
Preferences back control, only with backRoute |
Back |
errors.unexpected |
NotificationPreferencesController error when the preferences read throws |
Something went wrong |
Gotchas#
| Mistake | Fix |
|---|---|
Passing the GET /notification-preferences matrix to NotificationPreference.fromMap |
They are different shapes. The matrix feeds the shipped screen; NotificationPreference is flat, and you build it from whatever endpoint of your own supplies it. |
A backend that accepts only the single-cell PUT |
The bulk row sends {"preferences": [...]} and gets a 422. Accept both shapes. |
| Shipping the screen or the prompt without translation keys | Missing keys render as the key text. Add every key in Translation Keys to every locale. |
Asking isEnabled about a channel name it does not know |
It returns false. Use push, mail or email, database or in_app. |
Reusing one declinedVaultKey for something else |
PushPromptHost reads the key as an ISO-8601 timestamp and treats anything else as "never declined". Give it a key of its own. |
Related Documentation#
- Channels:
PushChanneland whereisEnabledis consulted. - Notification Manager:
pushPromptAdviceand the optimistic read-state changes behind the inbox screen. - Configuration:
reprompt_after_hours,soft_prompt.enabledand the automatic request. - Laravel Backend Setup: the backend contract for the preferences endpoints.