Multi-channel notifications for the Magic Framework.
Database, Push & Mail — one unified API.
Website · Docs · pub.dev · Issues · Discussions
Alpha —
magic_notificationsis under active development. APIs may change between minor versions until1.0.0.
Managing notifications in Flutter means juggling multiple channels — database polling, platform-specific push setup for iOS/Android/Web, email delivery, and user preference logic scattered across your codebase. Every project reinvents the same boilerplate.
Magic Notifications gives you a single, unified API for every channel. One config file drives everything. One CLI command sets up your project. Channels and drivers are swappable — switch from OneSignal to another push provider without touching application code.
Config-driven notifications. Define your channels, drivers, and preferences once. Magic Notifications handles the rest.
| Feature | Description | |
|---|---|---|
| 🔔 | Multi-channel | Database, Push, and Mail channels through one API |
| 📱 | OneSignal Push | iOS, Android, and Web push via onesignal_flutter |
| 🔄 | Real-time Polling | Background polling with pause/resume/stop lifecycle |
| 📡 | Socket Delivery | Take notification state from a broadcast channel instead, with polling as the fallback |
| 🎯 | User Preferences | Global and per-type channel preference management |
| 🛠️ | CLI Tools | Interactive install, configure, doctor, test, and more |
| ⚙️ | Config-Driven | All settings in one Dart config file via ConfigRepository |
| 🔍 | Reachability Read | Four-state answer (unavailable/blocked/off/on) so the app can decide what to show before asking the OS |
| 🌐 | Web Support | Full web push via conditional JS interop, subject to iOS Safari's Home Screen requirement (see below) |
dependencies:
magic_notifications: ^0.1.0dart run <app>:artisan notifications:installThis generates lib/config/notifications.dart, injects NotificationServiceProvider into lib/config/app.dart, wires the notificationConfig factory into lib/main.dart, and configures platform-specific setup for your selected platforms.
The NotificationServiceProvider is automatically registered during install. On app boot, it:
- Creates the configured channels (database, push, mail)
- Initializes the push driver with your config
- Sets up background polling for database notifications
- Registers notification preferences
That's it — notifications now work across all configured channels.
After running the install command, edit lib/config/notifications.dart:
Map<String, dynamic> get notificationConfig => {
'notifications': {
'push': {
'driver': 'onesignal',
'app_id': const String.fromEnvironment('ONESIGNAL_APP_ID'),
'notify_button_enabled': false,
},
'database': {
'enabled': true,
'polling_interval': 30, // seconds
},
'mail': {
'enabled': false,
},
'soft_prompt': {
'enabled': true,
'title': 'Stay Updated',
'message': 'Get notified about important events.',
},
},
};Add to .env:
ONESIGNAL_APP_ID=your-onesignal-app-id-here
All values are read at runtime via ConfigRepository — no hardcoded strings scattered across your codebase.
import 'package:magic_notifications/magic_notifications.dart';
Future<void> onLoginSuccess(User user) async {
await Notify.requestPushPermission();
await Notify.initializePush('user_${user.id}');
// Prefer the socket; startPolling() is the fallback and no-ops when the
// socket is live, so both calls are safe in either order.
await Notify.startRealtime(channel: 'App.Models.User.${user.id}');
Notify.startPolling();
}External ID Format: Always use a prefix like
user_before the user ID.Notify.initializePush()forwards the string you give it unchanged; nothing inside the package adds a prefix. The backend addresses a device asuser_<uuid>, so a bare numeric or bare UUID subscribes to an external id nothing sends to, and the push silently reaches nobody. OneSignal also blocks bare numeric external_id values outright.
NotificationDropdown(
notificationStream: Notify.notifications(),
onMarkAsRead: (id) => Notify.markAsRead(id),
onMarkAllAsRead: () => Notify.markAllAsRead(),
onNotificationTap: (n) => MagicRoute.to(n.actionUrl ?? '/'),
onViewAll: () => MagicRoute.to('/notifications'),
)PushDriver.reachability() answers whether push can actually reach the device
right now, without triggering the OS permission dialog:
final reachability = await NotificationManager().pushDriver.reachability();
switch (reachability) {
case PushReachability.unavailable:
// No platform driver here at all (e.g. web driver on a platform it
// does not support). Do not offer push.
break;
case PushReachability.blocked:
// The user denied the OS prompt. iOS and most browsers never let the
// app raise that dialog again: tell the person where the OS setting
// lives (Settings > Notifications, or the browser's site permissions)
// instead of calling requestPermission() again, which will silently
// no-op.
break;
case PushReachability.off:
// Never asked, or asked and not opted in. Safe to call
// Notify.requestPushPermission().
break;
case PushReachability.on:
// Permitted, opted in, and holding a subscription id.
break;
}Important
iOS Safari web push only works after the site is added to the Home Screen. Per OneSignal's own documentation, web push on iOS Safari "will work on iOS devices only after users add your site to their home screen and open it from there. This is Apple's design requirement." Shipping web push to iPhone/iPad Safari users without that step means the permission prompt and the reachability read both look normal while the push reaches nobody. There is no workaround; the app has to tell the person to add the site to their Home Screen first.
Future<void> onLogout() async {
Notify.stopRealtime();
Notify.stopPolling();
await Notify.logoutPush();
}Notify.startRealtime() subscribes to the notifiable's private broadcast channel
and applies each notification.created frame directly to the stream, so a new
notification appears when the server publishes it instead of up to one polling
interval later. It returns false and changes nothing when the app has no
broadcast driver configured, which is what keeps startPolling() meaningful on a
deployment without a socket.
final bool live = await Notify.startRealtime(
channel: 'App.Models.User.${user.id}',
);Behaviour worth knowing:
- The existing list is fetched ONCE on start. A socket only carries what happens next, so the rows that already exist still have to be read.
- A dropped connection falls back to polling; a reconnect drops the fallback and refetches once, because Reverb has no replay.
- A redelivered id replaces the held row rather than appending a duplicate.
The server half (the broadcast channel on the notification, the event name, and
the payload shape) is in
doc/basics/laravel-backend-setup.md.
All commands use the host app's artisan binary: dart run <app>:artisan notifications:[command]
| Command | Description |
|---|---|
notifications:install |
Interactive wizard to set up notifications |
notifications:configure |
Update notification configuration |
notifications:doctor |
Check installation and configuration health |
notifications:test |
Send test notifications to verify setup |
notifications:channels |
List all channels and their status |
notifications:publish |
Copy config stub to your project |
notifications:uninstall |
Remove plugin integration |
The notifications:doctor and notifications:channels commands are also available as read-only MCP tools for AI agents.
See the CLI Reference for all flags and options.
Notify.view is a NotificationViewRegistry holding the two screens the
package ships (notifications.list, notifications.preferences). A host app
touches it in two ways:
-
Re-register a view to wrap it in the host's own page container. The package deliberately cannot resolve that container itself (it does not know which shell, if any, the host app renders screens inside), so re-registering is the seam:
Notify.view.register( 'notifications.preferences', () => AppPageContainer(child: const NotificationPreferencesView()), );
-
Register a type icon so the shipped list and dropdown widgets can draw the right leading icon for one of the host's own notification types, without this package carrying a vocabulary that belongs to one product:
Notify.view.slot( NotificationViewRegistry.typeIconSlotView, 'order_shipped', (context) => WIcon(Icons.local_shipping, className: 'text-lg text-green-500'), );
The package's own defaults are registered the first time Notify.view is
read, so any registration you make afterwards replaces them; Notify.view.clear() drops every registration, package defaults included.
Seeding on first read has a consequence worth stating, because it caught a
downstream package out: has(key) is true before anybody has chosen
anything. A package that installs its own default for one of these screens
therefore cannot gate on has, or it skips every time and its own version never
reaches the app.
hasOverride(key) is the question with an answer. It is true only when somebody
CHOSE a screen for that key, and false while the key still holds the default
this package seeded:
// Install ours unless the app has picked its own.
if (!Notify.view.hasOverride('notifications.list')) {
Notify.view.register('notifications.list', () => wrapped(const MyListView()));
}register promotes a key out of the default set, so a host registration wins
whichever side of your own it lands on. registerDefault is what marks a key as
a default, and it is @internal: calling it from outside this package inverts
the guarantee, because your screen would then read as a default and the next
package to check hasOverride would overwrite it.
Notify.forgetView() drops the registry so the next read seeds a fresh one. It
is the test-isolation seam, and it is not the same as clear(): clearing leaves
the registry EMPTY, which is not the state an app boots with, so a suite that
clears cannot see a decision that turns on the defaults being present.
App launch → NotificationServiceProvider.boot()
→ reads config via ConfigRepository
→ creates channels (database, push, mail)
→ initializes OneSignalDriver
→ Notify facade delegates to NotificationManager
→ DatabaseChannel: stream + polling lifecycle
→ PushChannel: permission → initializePush → OneSignal
Key patterns:
| Pattern | Implementation |
|---|---|
| Singleton Manager | NotificationManager — central orchestrator |
| Strategy (Driver) | OneSignalDriver implements push driver contract |
| Facade | Notify — static API over NotificationManager |
| Service Provider | Two-phase bootstrap: register() (sync) → boot() (async) |
| IoC Container | All bindings via app.singleton() / app.make() |
| Document | Description |
|---|---|
| Installation | Adding the package and running the installer |
| Configuration | Config file reference and options |
| Channels | Database, Push, and Mail channel details |
| Drivers | Push driver contract and OneSignal implementation |
| Preferences | User notification preference management |
| CLI Tools | All CLI commands and flags |
| Laravel Backend | Laravel backend implementation guide |
| Notification Manager | Manager singleton and dispatch flow |
| Service Provider | Bootstrap lifecycle and IoC bindings |
Contributions are welcome! Please see the issues page for open tasks or to report bugs.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Write tests following the TDD flow — red, green, refactor
- Ensure all checks pass:
flutter test,dart analyze,dart format . - Submit a pull request
Magic Notifications is open-sourced software licensed under the MIT License.
Built with care by FlutterSDK
If Magic Notifications helps your project, consider giving it a star on GitHub.