magic_notifications 0.2.0 copy "magic_notifications: ^0.2.0" to clipboard
magic_notifications: ^0.2.0 copied to clipboard

Multi-channel notification system for Magic Framework. Supports database (in-app), push (OneSignal), and mail channels.

Changelog #

[Unreleased] #

0.2.0 - 2026-09-03 #

Breaking Changes #

  • NotificationsListView.onDelete is now Future<bool> Function(String id)? instead of Future<void> Function(String id)?. A host passing a Future<void> callback no longer compiles, which is the point: the widget had no way to learn whether the row left. onDelete is an opaque host-supplied callback, so a host may delete by any route it likes and never touch Notify.deleteNotification; nothing the manager or the controller can observe answers the question, and the return value is the only channel that exists. The list is a separately paginated fetch, so a real delete has to be followed by a reload (a row leaving page one pulls one up from page two, and only the server knows which). With no result to read, the row reloaded after EVERY tap, so a host that asks for confirmation, which magic_starter does, spent a full GET /notifications every time somebody declined one. true now means the row is gone and the list reloads; false means the host chose not to go ahead and nothing is re-read. A throw is a third outcome and is deliberately not the same as false: the manager removes the row optimistically and puts it back when the request fails, so what the server still holds is unknown and the list reloads. Migration for a callback that always deletes is one line, return true at the end; a callback that can decline returns false on that path. The package's own default (Notify.view's seeded notifications.list) is already updated.

  • NotificationManager.deleteNotification (and Notify.deleteNotification) now rethrows a failed request. It used to log, roll the row back, and complete NORMALLY, which left a caller no way to tell a delete that worked from one that did not: the only thing a person saw was the row leaving the list and coming back, with nothing said. The rollback is unchanged; the future now carries the failure. A caller that wants the old silence adds a catch. markAsRead and markAllAsRead deliberately still swallow: their failure is recoverable by looking again, while a delete that silently did not happen is the one mutation where the screen and the server disagree about something destructive.

  • notifications.database.polling_interval now takes effect, so an app that already sets it changes how often it polls on upgrade. This is filed as breaking because nothing in the app has to change for the behaviour to: the key was read by nobody, so every install effectively polled every 30 seconds whatever the config said. Both directions move. An app configured at 5 now issues six times the requests it did yesterday. An app configured at 3600 is clamped to 600, so its bell can be ten minutes stale where it used to be thirty seconds. Check the value you ship before taking this release; the details of the clamp and the logging are under Fixed.

Fixed #

  • notifications.database.polling_interval was validated by the CLI, reported by notifications:doctor, shipped in every install stub, and never read at runtime. startPolling() constructed NotificationPoller(this) with no argument, so the poller's own 30-second default always won on both routes onto it (the explicit start and the realtime-drop fallback). A consumer who set 10 got 30 and had nothing to tell them why. Both construction sites now pass the configured value, exposed as NotificationManager.pollingInterval. The runtime now enforces the 5 to 600 second range notifications:doctor and the configuration docs have always published, which it never had to agree with while it was ignoring the key: an out-of-range value is CLAMPED to the nearest bound rather than replaced by the default, so 1 becomes 5 rather than jumping to 30. Zero and negatives clamp up for a specific reason, Timer.periodic accepts them and then fires on every event-loop turn. A value that is present but not an int ('30' from a string-backed source, or 30.0) reads as absent, because Config.get<int> type-checks rather than casting, and falls back to 30. Nothing throws, since this is a timer a consumer wired to its auth state and a mistyped config value must not be what takes notification delivery down, but every substitution is logged: silently using a different number is the same shape of defect as silently ignoring the key.
  • The sms channel rendered as "Sms" on the preferences screen, in every locale. _channelLabel named mail, database and push and let everything else fall to a helper that raises the machine name's first letter, and magic-starter-laravel offers sms in the matrix out of the box, so the fallback was reachable on a DEFAULT install rather than only on an exotic one: three properly localised rows with an untranslated machine name sitting beside them. Hosts must add a notifications.channel_sms key; without it the row renders the raw key. The fallback stays, because a host can register a channel of its own and the machine name is the only thing available for it.
  • The list row's delete control had no accessible name. It is a bare glyph inside a WAnchor with no label, so a screen reader announced "button" on every row with nothing saying what it does, and an E2E driver had no handle to resolve it by. Labelled with notifications.delete. Hosts must add that key as well.
  • A failed delete says so. The list row's delete now catches the rethrown failure and surfaces notifications.delete_failed through Magic.error, then re-reads the page either way. Hosts must add a notifications.delete_failed key; without it the message renders as the raw key.
  • Deleting the last row of the last page no longer strands the reader on an empty page. NotificationsListController.refresh() re-read the page the reader was on, so deleting the only row of page 3 in a list that now ends at page 2 answered an empty page and showed "nothing here yet" while the notifications sat one page back. It now detects the paginator's own current_page > last_page and reads last_page instead. Keyed on that rather than on an empty data list, because emptiness lies in both directions: a failed read leaves the previous page in place, and a backend that answers an empty page while still claiming more pages exist would send the reader backwards for no reason.
  • The delete icon's hover tone had no dark: peer. hover:text-red-500 was written alone, so dark mode hovered to a red tuned for a white background. Paired with dark:hover:text-red-400, matching the surface tone beside it which was already paired.
  • The default notification list can delete a notification, which it never could. Notify.view's seeded notifications.list builder passed no onDelete, and the list renders its per-row delete control only when that callback is non-null, so the affordance never appeared. deleteNotification() and the DELETE /notifications/{id} route behind it were working code with no surface. The default now passes deleteNotification. The parameter stays nullable: a host that does not want its people deleting notifications registers its own screen over the default, which is the seam registerDefault exists for.

0.1.0 - 2026-09-02 #

Breaking Changes #

  • The soft-prompt dialog widget is removed. It shipped as a Material dialog with hardcoded English copy, and shipping a prompt widget at all forces one adopter's tone and layout onto everybody who installs the package. The package keeps the decision the widget existed to gate (see the new PushDriver.reachability() below) and leaves building the actual prompt UI to the host app. notifications.soft_prompt.enabled/title/message still exist in the config; they are now read by the host app's own prompt, not acted on by the package. Any app importing the removed widget must build its own dialog, gated on reachability().
  • NotificationManager.forgetChannels() and forgetPushDriver() are merged into one forgetDrivers(). The two-method split invited a test to reset one without the other, which left a driver or a channel from a previous test alive under the next one; a single call now clears every channel, every registered push driver factory, and every resolved push driver instance together. Update any test setUp() calling either removed method to call forgetDrivers() instead.
  • PushDriver.permissionState is now the asynchronous Future<PushPermissionState> permissionState() instead of a synchronous getter. Both platforms actually answer asynchronously: mobile reads the native permission over a platform channel, web reads the browser's Notification.permission. The old synchronous getter could only ever report a cached guess. A custom PushDriver implementation must change the override from a getter to a method returning a Future.
  • PushNotSupportedException is removed; the platform factory now throws UnsupportedPlatformException instead. The old stub arm of the OneSignal factory silently returned the wrong driver on a platform this package does not implement (see the wasm fix below); the new stub arm throws instead of guessing, and it throws this exception rather than reusing the removed one so a caller catching for "push did not work" still catches NotificationException while a caller wanting the specific platform failure catches the new subtype by name.
  • Notify.initializePush() (NotificationManager.initializePushWithUserId) and logoutPush() no longer throw when no push driver is configured. Both used to catch every failure, including a missing driver, and log "will retry when subscription is active" with nothing ever retrying; on a shared device that silent catch could leave the wrong person's external id on the subscription. Login/logout are now recorded as an INTENT persisted through Vault and reconciled by reading currentExternalId() back, so an app with no push channel configured at all calls these two methods for free instead of needing to guard every call site on whether push exists.
  • PushChannel.send() now refuses a Notifiable that is not the authenticated user instead of silently paging the caller. The endpoint it POSTs to derives the recipient from the authenticated session, and the request deliberately carries no recipient field: a client-triggered send that could choose its target is a harassment vector, and that omission is what makes exposing the endpoint safe at all. The signature promised otherwise. Notify.send(someOtherUser, notification) with push in via() ignored its first argument, POSTed a body naming nobody, and buzzed the caller's own device with somebody else's outage while every layer reported success. The Notifiable selects the preference matrix and the message; it never selected the recipient and cannot be made to. A mismatch now throws NotificationException with code PUSH_RECIPIENT_NOT_AUTHENTICATED_USER, before the request is built, naming both ids. Refused rather than skipped: skipping is what a disabled preference does and it reads as "delivered elsewhere", while a caller that named a specific person has to hear that this did not reach them. A build with no auth bound, and a session with nobody signed in, are un-answerable rather than mismatches and still send, because there is no caller identity to compare against and the endpoint rejects the request on its own.
  • On web, PushNotificationEvent.data now carries the server's own payload instead of the OneSignal SDK wrapper around it, so the shape is identical on both platforms. The mobile driver has always published notification.additionalData, the object the server sent, flat; the web driver published the v16 event that WRAPS it, so event.data['deep_link'], event.data['team_id'] and every other server key answered null in a browser. That is not a contract two consumers could reasonably hold at once: a subscriber to Notify.onPushReceived/onPushClicked reads the server's keys, and it cannot ask which platform it is on before deciding how deep to look. The wrapper shape was a defect wearing a contract's clothes, and it took tap-to-navigate down on the browser (the app's handler read no destination off the wrapper, logged that the push named none, and returned) while making the manager's own subject re-check on the click stream vacuous there, since the wrapper carries no subject either. A consumer that really did read data['notification']['title'] on web reads data['title'] off the payload now, or the wrapper is gone for them; nothing else changes, and mobile is untouched because mobile was already right.
  • NotificationManager.fetchPaginatedNotifications() (and Notify.fetchPaginatedNotifications()) now throws NotificationException on a failed read instead of answering an empty page. It used to catch everything and return PaginatedNotifications.empty(), so a 500, a dropped connection, an expired token and a genuinely empty inbox were one answer, and the notification list screen rendered all four as "nothing here yet". On an on-call product that is the worst possible wording for the failure it hides: the difference between "you have no unread alerts" and "we could not ask" is the difference between going back to sleep and picking up the phone. The screen's error branch existed and read RxStatus correctly; nothing on the path could make the facade fail, so it was unreachable. Three failure modes now raise: a throw from the transport and a non-2xx answer as NOTIFICATIONS_FETCH_FAILED, and a 200 carrying a body the package cannot decode as NOTIFICATIONS_DECODE_FAILED (that one landed on the same empty page and told the same lie). Every failure is still logged where the transport detail exists; it is handled deliberately rather than disguised as data. A caller that genuinely wants an empty page on failure now says so at its own call site, catch (_) { return PaginatedNotifications.empty(); }, where the choice is visible, instead of inheriting it from a method nobody could see swallowing.
  • PushDriver gains three members that are ABSTRACT, so a driver written against 0.0.3 no longer compiles until it implements them: currentExternalId(), currentSubscriptionId() and onIdentityChanged. Previously nothing could ask the device who it was subscribed as; only the web driver had an off-contract external-id reader. The two reads are what closes the loop on login/logout (reconcilePushIdentity reads the device back rather than trusting the call it just made) and what lets reachability() refuse an on with no address to deliver to; the stream is the SDK's own confirmation that an identity change reached the server. PushIdentityChange fields are all nullable because a single SDK event reports only part of the state (a user change carries the external id, a subscription change carries the subscription id and the opt-in flag), and a null field means "this event did not report it", never "it is empty". They are declared with no body, and that is the whole difference between this entry and the removeTags/addEmail/removeEmail one under Added, which says "all three defaulted, so no existing driver breaks": a default can be derived for those from members that already exist, and there is nothing to derive an identity read from, so this trio has to be required. A driver that cannot answer them cannot take part in the reconcile at all, which is the reason they are not defaulted rather than an oversight: a silent default here would report a device as carrying nobody on every pass, issue a login on every pass, and never converge. The break is a compile error in the adopting driver, which is the safe direction for a contract this load-bearing.

Added #

  • NotificationViewRegistry.hasOverride(key) and Notify.forgetView(). has(key) cannot answer "has anybody chosen a screen here", because reading Notify.view is what seeds this package's own two screens into the registry, so the answer is yes before any decision has been made. A downstream package installing its own default therefore always lost to a default it was supposed to replace: magic_starter mounts both screens wrapped in the host's page geometry, and gated on has that wrap never reached either screen in a real app. The seeded pair is registered through a new internal registerDefault, and register promotes a key out of that set, so hasOverride distinguishes a choice from a shipped default. forgetView() is the test-isolation seam for the registry, the sibling of forgetDrivers(); clear() is not a substitute, because it leaves the registry EMPTY rather than restoring the state an app boots with, and a suite running against an empty registry cannot see a mount decision that turns on the defaults being present.
  • A host can describe the person a device is subscribed as, and the identity lifecycle carries it, and it ships OFF. OneSignal segments and personalises on what it knows about a user (an email subscription, and tags), and until now this package could only carry tags, through two driver methods nothing in the identity path ever called. Notify.describePushUserUsing((externalId) => PushUserAttributes(email: ..., tags: {...})) is the seam, registered once at boot: it is called with the external id the device is being subscribed as, 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. What this package must not own is WHICH attributes a host sends. "Email, first name, last name" is one product's answer; the next app has different fields, or is not permitted to send an address at all, so there is no fixed field list here to fill in and there is deliberately no firstName or lastName anywhere in the package. A name has no field of its own in the OneSignal user model either, so it travels as a tag the host names. The resolver is SYNCHRONOUS on purpose: it runs inside the identity reconcile pass, whose whole promise is that the device stops carrying the previous person as fast as possible, and a resolver that awaited a network read would hold the login (and every guarantee that depends on the right subject) behind a request that may never answer. Returning null says there is nothing to describe, which is what a host answers for a guest or for somebody who has not consented, and a host that registers nothing behaves exactly as it did before this existed, asserted by a test that drives a login and a sign-out through a recording driver and reads the whole call list back.
  • notifications.push.share_user_attributes, the switch everything above sits behind, and an absent key is off. An email address and a name reaching a third party, under that vendor's retention and export rules, is a decision an adopter makes deliberately; discovering afterwards that an installed package has been sending it is the outcome this default exists to make impossible. 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, so sorting them would be this package guessing at a classification only the host can make. The generated config stub carries the key, the argument, and the two warnings below.
  • An account switch now leaves nothing of the previous person on the device, and this package does it itself rather than trusting the SDK to. What the OneSignal SDK actually promises, from its own migration guide (onesignal_flutter-5.6.0, MIGRATION_GUIDE.md:195-196): a login to an external id that EXISTS retrieves that user and sets the context from the server's copy, and operations performed under a device-scoped user "will not be applied to the now logged in user (they will be lost)"; a login to an id that does NOT exist creates the user "and the context set from the current local state", and operations performed under a device-scoped user "will be applied to the newly created user"; logout reverts to a fresh device-scoped user, and the push subscription (owned by the DEVICE, unlike tags and email subscriptions) transfers to whoever logs in next. So the documented promise covers a device-scoped user's operations, and the one branch it promises anything about at all is the branch that CARRIES them onto the next person, which is the ordinary shape of a shared device: somebody signs out, somebody new signs in for the first time. It says nothing either way about a straight switch from one identified user to another. That is not a guarantee to build a privacy boundary on, so 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 order is the whole fix and the test asserts it as an order: the removals are indexed against the login call, because the same removals issued afterwards run against the record of whoever has just arrived. 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 a name and an email address left attached to a subscription that has moved to somebody else. Only what THIS PACKAGE wrote is ever removed: a tag set from the dashboard, from a backend, or from another client is not this device's to delete.
  • PushDriver gains removeTags, addEmail and removeEmail, all three defaulted, so no existing driver breaks. removeTags loops over removeTag, which is correct everywhere and costs one platform round trip per key; both OneSignal drivers override it with their SDK's batch call, because the identity lifecycle removes a whole tag set at once and a per-key loop is a window in which half of somebody's tags are gone and half are not. addEmail is ADD rather than set, which is the verb both SDKs use (a user owns zero or more email subscriptions); what makes it read as "the address for this identity" is the manager, which detaches the address it previously attached whenever the described one changes. The default addEmail sends nothing and SAYS SO through NotificationLog: a driver whose platform has no email channel is a legitimate implementation, but a host that described somebody by their email address is entitled to know the address went nowhere, and going quiet is how a deployment finds out months later that the campaigns it built never had an address to send to. Its removeEmail twin is a silent no-op deliberately, because a driver that never attached an address has nothing to detach and reporting that would put an error in the log on every sign-out for the whole platform.
  • Both platforms carry the same four calls, and the one place they can differ reports itself. The mobile driver reaches OneSignal.User.addEmail/removeEmail/removeTags directly. The web SDK is whatever script the page's index.html loads, which this package does not control and cannot assert from here, so the interop PROBES for User.addEmail before it uses it and answers whether the call was carried; the driver turns a false into a log line naming the method and pointing at index.html, rather than accepting an address that went nowhere. That is a runtime answer from the page rather than a claim about a documentation URL, which is the only honest form the parity statement can take.
  • Two things a reader deciding what to tag has to know, argued where they will see them (the model's docblocks, the config stub, and doc/getting-started/configuration.md). First: 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, so a tag is safe for choosing an audience and safe for personalising a message, and it is NOT safe for anything a backend later trusts. A plan tier, an entitlement, a role, a quota decide what somebody is allowed, and a value the person being checked can rewrite decides nothing; those belong in a server-side tag write over OneSignal's REST API, from the system that already owns the fact. This release ships no such path and will not: this package has no server credentials and no business holding any. Second: the PII switch above, and why its default is the conservative one.
  • A failed attribute write cannot be mistaken for a failed identity. The apply and the take-back each hold their own handler and log through NotificationLog, so neither can flip isPushIdentityConverged or fill pushIdentityError: a tag is segmentation, the identity is what keeps somebody else's outage off this screen, and reporting a refused tag write as an identity that did not land would send a caller looking for a leak that is not there. The attributes are written only onto an identity that actually converged, for the mirror-image reason: writing an email address and a name onto a device still carrying the previous person is the same leak wearing a different hat. Ownership of a write is claimed BEFORE it is issued, so a pass that fails halfway is still fully taken back at the next switch; removing a tag that never landed costs nothing, since both SDKs treat an absent key and an unattached address as a no-op.
  • PushDriver.reachability() derives a four-state answer (unavailable/blocked/off/on) for whether push can actually reach the device right now, without ever triggering the OS permission dialog. It is implemented once in the base class from isSupported, permissionState(), isOptedIn, and currentSubscriptionId(), so every driver answers it the same way. This is the read a soft prompt (or any pre-permission UI) should gate on.
  • iOS is now a real installer target. notifications:install --platforms=ios previously accepted the flag and wrote nothing. It now adds remote-notification to UIBackgroundModes in Info.plist (as a union with whatever the project already declares, not a replacement, so an existing fetch mode is not silently dropped), declares aps-environment in Runner.entitlements (creating the file if the project never had one), and points CODE_SIGN_ENTITLEMENTS at that file in project.pbxproj so Xcode actually reads it. notifications:doctor's iOS check follows the same three markers instead of only asserting Info.plist exists, which is true of every Flutter iOS project ever generated and could never fail.
  • The package now owns its notification UI. Notify.view is a NotificationViewRegistry holding NotificationDropdown, a notifications.list view, and a notifications.preferences view, API-identical to magic_starter's own view registry. A host re-registers any of the three (typically to wrap it in the host's own page container) and registers a leading icon per notification type through Notify.view.slot(NotificationViewRegistry.typeIconSlotView, type, builder), replacing the hardcoded monitor_down/monitor_up/monitor_degraded icon map the dropdown previously carried.
  • notifications.push.self_test_enabled, the switch the push channel's send now sits behind, and it ships OFF. PushChannel.send() POSTs to a self-addressed endpoint that makes the platform emit a real push to the caller's own devices, and nothing in this release calls it: no notification in this package puts push in its via(). So the machinery ships cold. An outbound send a client can trigger is a capability rather than a detail, and the moment it is live it is live for anything holding a token; the argument that kept a recipient field out of the request in the first place is much easier to make against a capability nobody has switched on than against one already running in production. An absent key is off, so an app that upgrades without touching its config gets the off state rather than a surface it never asked for. Off is a SKIP, not a throw. An operator who has not enabled a feature has not made an error, which is exactly the shape of a disabled preference; naming a foreign recipient IS an error, which is why that one still throws. isAvailable carries the switch too, so the channel reports what it will actually do: with the switch off the manager skips push and a notification listing it among its channels still reaches the others, rather than being handed to a channel that reports itself available and then quietly drops it. A value that is not a boolean reads as off, because the safe reading of a configuration mistake on a switch guarding an outbound send is the one that sends nothing. Reaching send() at all while it is off means a caller went around isAvailable, so that path writes one debug line naming the key instead of passing in silence. Both halves have to be switched on. The backend carries the same switch (magic-starter.onesignal.self_test_enabled in magic-starter-laravel, also off, answering 501 while off), and either half alone is a half-measure: a client that refuses locally leaves the endpoint reachable by anything holding a token, and a server that refuses leaves the client posting requests that always fail. The generated config stub documents the key, what it is for, and why it is off.
  • The package can now ask for push permission by itself, once, and it ships OFF. notifications.push.auto_request_on_login raises the platform request from want() (the path Notify.initializePush(userId) already takes) when an identity is declared, and nowhere else. An absent key is off, so an app that upgrades without touching its config asks on exactly the terms it always did. The constraint that shapes all of this, stated plainly because the next reader will want to build around it: an OS permission that has been DENIED cannot be re-prompted by any code. Notification.requestPermission() on a denied origin resolves immediately with "denied" and shows the user nothing; iOS and Android behave the same way. The only route back is the browser's site settings or the Settings app. So the automatic request fires only where a dialog will actually appear: the device has never been asked (PushDriver.canRaisePermissionRequest(), which is permissionState() == notDetermined, and on mobile that state is sourced from the SDK's own canRequest() rather than guessed), it is not already subscribed, and this launch has not raised one yet. The once-per-launch flag is claimed when the pass STARTS, not when a dialog appears, because a consumer wires the login path to auth state and that bumps on every cold-boot restore and every team switch. It is deliberately not raised from reconcilePushIdentity(), which also runs on a signed-out boot, where a system dialog would arrive with nothing in front of it explaining what it is for. It is fired unawaited: the dialog resolves when the user taps it, and awaiting it would hold the identity reconcile, and every guarantee that depends on the device carrying the right subject, behind a dialog somebody may never look at. A request that throws is logged through NotificationLog and dropped, because a permission this app could not ask for is not a reason to fail a login.
  • NotificationManager.pushPromptAdvice({declinedAt}) answers whether the app's OWN reminder may be shown right now, and what its button can accomplish. The second cadence, and it is a different question from the one above: the OS prompt is a one-shot, but the reminder is our UI and recurs on whatever cadence an app configures, notifications.push.reprompt_after_hours (0 or absent means never). Hours rather than days because the useful cadence on an on-call product is a day or less and a day-based key cannot express 24 hours without a fraction. A denied device is included on purpose, which is the opposite of what the first draft of this feature said: what cannot recur there is the OS prompt, not our row, and on mobile that row's button opens the app's settings page where the permission really can be turned back on, so silencing it would strand exactly the people whose pages are going nowhere. The answer is a PushPromptAdvice rather than a bool, because "show it" without "and the button does X" is the half that two consumers would each get wrong in their own way: action is request (a real dialog will appear), openSettings (the prompt is spent but this platform routes there), instructions (the prompt is spent and there is nowhere to send a tap, which is every browser), or none. It already accounts for reachability, the interval, notifications.soft_prompt.enabled and the timestamp the caller passed in. The decline timestamp stays with the consumer: a decline is the consumer's own UI event, recorded wherever that app already keeps device state, and a second copy in this package would be a second answer to drift out of sync with the first. What the package owns is the policy.
  • notifications.push.fallback_to_settings makes the mobile driver's settings fallback configurable, keeping today's behaviour as the default. OneSignalDriver.requestPermission() has always passed true for the SDK's fallbackToSettings, which sends a request on a DENIED device to the app's own settings page instead of resolving silently. That was right and hardcoded; both postures are legitimate and neither is a default for everybody, so it is a key now: an on-call product treats a missed page as an outage nobody hears and wants to keep handing the user a route back, while an app whose notifications are a convenience asks once and drops it rather than bouncing somebody into Settings they did not ask for. It doubles as the driver's PushDriver.canOpenPlatformSettings, because the two are the same fact, and that is what pushPromptAdvice() reads to tell openSettings from instructions. The web driver has no equivalent and does not pretend to: no browser API opens site settings from a page, so the base contract answers false and a blocked browser gets words rather than a control that does nothing. A value that is not a boolean reads as the default rather than as off, since a configuration mistake should not quietly remove the only route a denied operator has back.
  • PushDriver.canRaisePermissionRequest() and PushDriver.canOpenPlatformSettings, the two questions a permission policy has to ask a platform. The first is the package's single answer to "would a request actually show the user something" (previously derivable only by comparing the permission enum at each call site, which is how two answers to one question start); the second is the settings-route capability above. requestPermission()'s docblock now also says plainly what its bool does NOT distinguish: a false covers "the user saw a dialog and declined", "nothing was shown at all", and "the settings page was opened and nothing has happened yet". Widening the return type would change the contract for every driver, so the distinction is drawn by asking canRaisePermissionRequest() FIRST rather than by reading more into the bool than it carries.
  • NotificationManager.pushDeliverySnapshot() and the PushDeliverySnapshot it answers with, so a backend can know whether a responder's device can actually receive a push. Everything needed to answer that lives on the client (the permission, the opt-in flag, the subscription id), and a server that has it can move an escalation on to the next responder immediately instead of waiting out an acknowledgement from a phone that was never going to ring. It carries the reachability, the external id the device reports being subscribed as (read back from the platform, not the intent this package holds), the subscription id, and a UTC capture time, because a stored snapshot is a claim about a moment and all four facts change while an app is closed. No HTTP, no endpoint, no transport: this package does not know the consumer's API, and an endpoint invented here would be one more contract to keep in sync with a backend it cannot see. What it owns is the SHAPE, so two consumers posting the same fact cannot describe it two ways. It carries nothing identifying beyond the external id the server itself handed out; the serialisation test asserts the whole map rather than key by key, so a field added later fails a test instead of reaching somebody's server unnoticed. A platform read that throws answers unavailable rather than raising, because of the two wrong answers available on a failed read, "this device may not be reachable" escalates to a human who is, while "reachable" strands the page on a device nobody can prove is there.
  • NotificationManager.onPushDriverAttached, a broadcast stream announcing every driver as this manager attaches it. It exists for one ordering, and that ordering is the ordinary launch rather than an edge case: a driver is resolved inside NotificationServiceProvider.boot(), while a host's auth provider is normally registered ahead of it (it has to be, notifications follow a session), so a cold boot that restores a stored session bumps the auth state from the earlier provider and everything the host wired to that bump runs while pushDriverOrNull is still null. Nothing about that is a race; the provider order decides it. Anything a host does WITH a driver on that path would otherwise run once, against nothing, with no way to run again, because the driver's own streams cannot cover it: subscribing to them is the very thing that needs a driver. The case that asked for it is a consumer posting the device's delivery state (pushDeliverySnapshot()) to its own backend, which on that launch posted reachability: unavailable for a device that was moments away from being reachable, and an escalation reading that record walks past a responder whose phone would have rung. It does NOT replay: a subscriber arriving after an attachment reads pushDriverOrNull for the current answer and listens here for the next one, and delivery is asynchronous so a listener cannot re-enter the attachment that announced it.
  • NotificationPreferencesController and its view for reading and updating the per-type channel preference matrix against the backend.
  • NotificationsListController, the controller behind the notification list screen, exported from the barrel beside its preference sibling. It owns the page the user is on and the rows that page carries; the screen reads them through MagicView's container resolution instead of holding them in a private widget state. See the corresponding Fixed entry for why the screen could not keep them.
  • NotificationManager.loadPushIntent() reads the persisted push intent into memory without touching a driver. It is public because the ORDER is the whole point and only the caller controls it: resolving a driver ATTACHES the manager's push listeners, so anything the SDK replays between that moment and the first reconcile is judged against an intent nothing has read yet. The service provider now calls it before it resolves a driver. Calling it again later is free, so wiring it early costs nothing.
  • NotificationDropdown now has a styling seam and a text-scale guard on its badge. Five optional parameters (panelClassName, triggerClassName, triggerIconClassName, badgeClassName, badgeTextClassName) default to the exact strings the widget used to inline, which now live named and documented in notification_dropdown.recipe.dart, so the existing five-parameter constructor is untouched and a caller passing only callbacks renders byte-identically. Without the seam every adopter inherited Wind's default palette (bg-white, text-gray-500, bg-red-500, a text-2xl glyph), so the bell read as a foreign control beside controls written in the app's own semantic aliases, and the only way out was forking the widget. An override REPLACES its default rather than appending to it: appending would leave the default's dark:bg-gray-800 alive under a light-only override, because Wind's last-wins is per family and bg-* and dark:bg-* are two families, which is a worse trap than restating a width. The seam stops at those four surfaces; the panel interior (header, rows, footer) still renders in Wind's defaults. Separately, the unread count is now wrapped in MediaQuery.withClampedTextScaling(maxScaleFactor: 1.1), because the badge pill is a fixed 14px high around a 9px line, so an OS accessibility text size grows the digit while its box does not and the digit is clipped. That is a defect an adopter already hit on a real iOS device and guarded in its own tree, on a pill LARGER than this one; the guard did not travel with the widget when this package took ownership of it, so it could recur here, visible only on a device at a large system size and on no widget test at the default scale. The 1.1 is measured rather than chosen (the paragraph needs 13.0px at scale 1.0, 14.0px at 1.1 and 15.0px at 1.2 inside a 14px box), and the constant carrying it explains why it must not be raised without making the pill taller in the same edit. All five defaults are exported from the package barrel, because an override REPLACES its default rather than appending to it: an adopter changing one palette token has to re-supply the layout tokens sitting beside it, and reading kNotificationDropdownPanelClassName to copy its w-80 beats restating a width from a docblock and having the two drift.

Changed #

  • The service provider now resolves the manager through the IoC container (app.make) instead of constructing NotificationManager() directly, so the instance boot() configures is the one register() actually bound. It registers both the database and push channels (previously nothing in production code registered the database channel), drives one unconditional identity reconcile at boot (covering a signed-out cold boot that fires no auth event at all), and logs an unservable notifications.push.driver config value at error level, naming the bad value and Notify.extend, instead of ignoring it in total silence.
  • The built-in OneSignal driver is registered through NotificationManager.extend()'s name-keyed factory registry rather than constructed and attached directly by the provider, so a consumer's driver override and the shipped driver travel the same resolution path (setPushDriver still exists as the explicit escape hatch, but extend is now preferred).
  • PushChannel.send() actually sends, once a deployment switches it on. It previously ended on a comment noting it was a no-op "until backend integration is added"; it now posts to a self-addressed test-push endpoint (carrying no recipient field, since the endpoint derives the recipient from the authenticated session) and surfaces a non-2xx response as a NotificationException instead of reporting silent success. The send is gated on the new notifications.push.self_test_enabled key, which ships off; see the Added entry for why, and for why the skip is a skip and not a throw.
  • The legacy Safari Web ID prompt is demoted to an opt-in path during install, defaulting to skipped, because modern Safari (macOS 13+/iOS 16.4+) subscribes over VAPID and needs no Safari Web ID at all.

Fixed #

  • A read or a write in flight when the session ends can no longer publish the previous person's data. Clearing the screen was only half of it, which is the half NotificationManager had already learned for the bell: logoutPush clears before its first await, so a read issued for A is still on its way back and lands into the emptied notifier, and B's first frame paints it. fetchPaginatedNotifications carries no epoch of its own and the controllers' publishes were unconditional, so both list and preferences reached it, and an in-flight preference toggle's rollback could write A's cell back into the emptied matrix. Each controller now captures a session epoch when a request starts and drops every publish, including the error states and the rollbacks, when the epoch has moved under it. The cleared state is also setEmpty() rather than setSuccess(false): the argument to setSuccess is the DATA, so that call left the status reading success, which claims a load that answered nothing rather than a screen holding nothing yet. The narrow-window framing understates it: this is the refresh that runs whenever the list is opened, so "sign out while the spinner is up" is enough.
  • Switching straight from one account to another clears what the last one held. The session signal fired only from logoutPush, and want() reassigns the intent without publishing anything, so an account switcher (or a token refresh resolving to a different subject) moved the device from user_A to user_B with both controllers still holding A's rows and A's preference matrix. A change away from a non-null intent now clears, which is the only line that can see that transition happen. A first sign-in and a cold-boot restore do not fire it: there is nobody before them to clear after.
  • The notification list and preference screens no longer show the previous person their successor's rows. Both controllers are Magic.findOrPut singletons and magic's controller registry is process-lifetime, so sign-out disposed neither: NotificationManager cleared the bell's own cache and bumped its session, but pageNotifier kept A's incident titles and matrixNotifier kept A's per-type channel matrix. On a shared device B signs in, opens the list, and build paints A's rows before onInit's refresh lands; loadPage's catch then deliberately leaves the rows up, so a refresh that FAILS leaves them there indefinitely. The manager now publishes onSessionCleared when it drops a session's state, and both controllers subscribe and reset. Published rather than pushed, because the manager is the core and must not reach up into the UI layer to reset it.
  • A failing channel no longer decides whether the other channels run. NotificationManager.send awaited each channel in a bare loop, and PushChannel.send became able to throw in this release (it was a no-op before the self-test endpoint). With via() answering ['push', 'database'] and the backend refusing the push, the in-app row was written or not depending on the ORDER of that list, which is not a thing the author of a via() is choosing. Every channel now gets its attempt, and the first failure is rethrown once they all have, with its own type and stack intact so a caller catching NotificationException for a specific code still sees it. Any further failures are reported at error level, because an exception can only carry one.
  • A PushMessage.url() destination now reaches the device. The self-test request body carried title, body and data, and the endpoint validates exactly those three, so a fourth key would be dropped by validated() anyway. A notification that set its destination with .url() instead of burying it in data therefore produced a test push that taps through to nothing, which is the single thing a test send exists to prove. It folds into the payload under url, the first key the deeplink handler reads; an explicit data['url'] still wins.
  • A reconcile pass that outlives a forgetDrivers() no longer clears a newer pass's marker. The single-flight's finally nulled _reconcileInFlight unconditionally while _loadPushIntent guards the same write with an identity check. An old pass completing after the reset could therefore clear the marker a newer pass had published, and the next caller would start a second concurrent pass and issue the duplicate login() the single-flight exists to prevent.
  • The automatic permission request spent its one turn per launch having asked nobody. _autoRequestPermissionOnLogin claimed the once-per-process flag and only THEN read the driver, returning on null. On the ordinary launch that is guaranteed to happen with no driver present: a host's auth provider is registered ahead of the notifications one, so a cold boot that restores a stored session declares an identity while NotificationServiceProvider, the only thing that resolves a driver, has not booted. The flag was taken by a pass that reached no platform, so no OS dialog could be raised for the rest of that launch, on a device that has never been asked, in an app that deliberately switched notifications.push.auto_request_on_login on. Both want()'s docblock and that key's own entry promised the opposite. The driver is read before the turn is claimed now, and the claim-before-await property that ordering exists for is unchanged: pushDriverOrNull is synchronous, so an overlapping second login still has nowhere to arrive between the two. The fixture that would have caught it did not exist, because every fixture in the suite installed a driver BEFORE declaring an identity, which is an ordering the application cannot produce; there is one now that declares an identity with no driver registered, installs one, declares again, and asserts exactly one request.
  • A permission request that THREW spent the one turn per launch anyway. The entry above moved the driver read in front of the claim, which closed the case where the flag was burnt by a driver that was absent; the case where it is burnt by a driver that is PRESENT and throws was left open. _autoRequestPermissionOnLogin claimed the flag, called requestPermission(), and logged the throw with the claim still held, so no OS prompt could be raised for the rest of that launch on a device that has never been asked. A throw out of that call is positive evidence that nothing was shown to anybody: the request is the only thing in the method that can draw a dialog, and it raised instead of resolving. The realistic route in is a web driver whose initialize has not finished yet: permissionState() answers notDetermined, so reachability() reads off and canRaisePermissionRequest() passes, and the SDK then raises NOT_INITIALIZED. The turn is released before the failure is logged now, so the next DECLARATION asks again, which for the provider ordering the entry above describes is the next auth-state bump and for a host reading onPushDriverAttached is the moment a driver exists. Nothing is retried from inside the method: a retry loop against a platform that is failing would be a dialog attempt nobody asked for. A pass that decided NOT to ask still keeps the turn, because that decision was made on a platform answer (on, blocked, or granted-then-opted-out) that reads the same way on the next bump.
  • notifications:install appended a config factory naming a symbol the project does not declare, and broke the build every time it ran. The guard deciding whether lib/main.dart was already wired tested the file for the literal string notificationConfig, which is the getter name the bundled stub happens to use. An app that wrote its own config named the getter after the config root it returns (notificationsConfig, for a notifications.* tree), and 'notificationsConfig'.contains('notificationConfig') is false, so the guard missed, the installer appended () => notificationConfig, next to a getter of a different name, and the app stopped compiling. Restoring main.dart by hand did not help: the next run did it again, which is the opposite of the idempotence every command here promises. Prior wiring is now read from the IMPORT of config/notifications.dart (with comments stripped first, so an import somebody commented out does not count as one), which is a property of the project rather than a name this command guessed. The appended factory names the getter the config file ACTUALLY declares, parsed out of lib/config/notifications.dart; the stub's own getter is used only when this run is the one writing that file, and it is parsed from the rendered stub rather than restated in code, so the two can never drift. When the config exists and declares no getter this command can name, main.dart is left untouched and the command says so instead of guessing, because a factory naming a symbol that does not exist costs a compile while a missing one costs one documented manual step.
  • notifications:install --force broke the same build from the other end. --force regenerates lib/config/notifications.dart from the stub, whose getter is notificationConfig, while an already-wired main.dart goes on naming the symbol the old hand-written config declared, so the command documented as the way to repair an install was the command that left the project not compiling. The operator asked to regenerate the config, not to break the build. A --force run that replaced an existing config now re-points that factory at the getter the regenerated file declares. The wiring follows the file rather than the file preserving the wiring, because --force means "give me the config this installer generates": rendering the stub under a foreign getter name would leave a file no fresh install would ever produce, and nobody reading that project later could tell what the tool actually writes. The reconcile runs after the transaction commits, so it reads the config that really landed and never fires on a dry run or a rollback, and it is skipped entirely when the previous getter cannot be read, with the symbol to check named in the output. The invariant both halves of this fix hold, and a test now asserts directly off disk: after any successful notifications:install, with or without --force, against any project state, lib/main.dart names a getter that the config file on disk actually declares.
  • notifications:uninstall reported the package removed while the project still referenced it. It matched main.dart's factory on the literal notificationConfig, the same name the installer used to guess, so a project wired as notificationsConfig kept both the factory and the config import after an uninstall that printed nothing but successes; the next build then failed on an import of a file the same command had just deleted. There was an ordering half too: the config file was deleted BEFORE main.dart was cleaned, so by the time the command needed the getter's name, the only file that could have told it was gone, and no name-based fix was even reachable. The getter is now read first, before any deletion, and it drives both the confirmation summary (which promises the line this run can actually remove) and the removal itself; the import is matched however the project spelled it, relative from lib/ or as a package: uri. When the name cannot be read, because an earlier partial uninstall already removed the config or because the config declares no getter this package can name, main.dart is left untouched, import included, and the leftovers are reported for a manual fix: removing the import while a factory still names the symbol it provided leaves a project compiling less than the one we started with. Install and uninstall now read the wiring through one shared reader, so the two commands cannot drift back apart on what the getter is called.
  • notifications:doctor reported "App ID not found in config" for a config that has one, and no amount of provisioning could turn it green. The check required a quoted literal ('app_id': '<uuid>'), so an app that resolves the value from the environment ('app_id': envString('ONESIGNAL_APP_ID', ''), the shape any value that differs between deployments has to take) failed a check it could never pass, and the doctor exited 1 forever. That is a pass/fail gate in at least one consumer's CI. The doctor was wrong here, not the app: a config that reads its app id from the environment IS configured, the doctor simply scans files and cannot see the value. It now recognises envString('KEY', ...), env('KEY') and env<String>('KEY'), reports the app id as resolved at runtime and NAMES the environment key the deployment has to carry, instead of claiming the key is absent. Nothing is weakened for a literal: a missing app_id, an empty one, a YOUR_APP_ID placeholder and a non-UUID all still fail, and an env call naming no key at all (envString('', '')) still reads as absent.
  • Both conditional-import driver factories guarded on dart.library.html, which is absent under a dart2wasm web compile, so flutter build web --wasm fell through to the non-web arm and would have handed a browser the native mobile OneSignal driver. Both now guard on dart.library.js_interop, which a wasm target actually has. The non-web arm is also restructured into three: a stub default that throws UnsupportedPlatformException (previously named onesignal_stub.dart while actually returning the real mobile driver, so there was no arm at all for a platform this package does not implement), a new onesignal_io.dart carrying the native arm, and onesignal_web.dart unchanged for the browser.
  • Notify.initializePush() no longer pages the wrong person's push subscription on a shared device. The old event-driven login caught every SDK failure and moved on with nothing retrying; a signed-out user with a failing network followed by a different user signing in on the same device could leave the subscription pointed at the first person's external id with nothing anywhere saying so. See the corresponding Breaking Change entry above for the new intent/reconcile model.
  • Web push registered its service worker at the wrong scope, because the provider never forwarded the two config keys that set it. The web driver reads service_worker_path and service_worker_scope out of the map it is handed and puts them on the SDK's init object, and the install command has written both into every generated config since it shipped; the provider in between hand-assembled a three-key map (app_id, safari_web_id, notify_button_enabled) and dropped them. OneSignal then registered OneSignalSDKWorker.js at the ROOT scope, which a Flutter web build already owns with flutter_service_worker.js, and whichever registration lands second wins the scope: with Flutter's second, push silently never arrives on a device the identity reconciler cheerfully reports as converged. The provider now builds that map through the driver's own OneSignalWebDriver.buildConfigFromEnv, which is where the key list actually lives, so the next option added beside them cannot be dropped here again, and a key the config does not declare stays OUT of the map rather than reaching the SDK as an empty string that would override its default.
  • A push the SDK replayed while it was initialising was dropped as if it were addressed to somebody else. Resolving a driver attaches the manager's onNotificationReceived listener, and the provider resolved one BEFORE anything read the persisted intent out of the vault, so a cold start from a notification TAP (which is exactly a replay during initialize) was compared against a null intent and silently discarded. An app escaped only if its own provider happened to set the intent before this one booted, which is provider ordering rather than a guarantee, and an adopter installing only this package has no such luck. Two halves are fixed: the provider reads the persisted intent before it resolves a driver, and the receive-side guard now delivers while the intent has never been READ at all, on the same reasoning that already delivers a payload carrying no subject. An unread intent is not a report that this device carries nobody; it is no evidence in either direction, and the guard exists to stop a leak, not to drop a page it cannot judge.
  • A realtime frame arriving between two overlapping reads was lost. The in-flight marker behind the mid-fetch frame buffer was a boolean, so with two fetchNotifications() calls in the air the first one to finish lowered it and emptied the buffer they share; a frame arriving after that was buffered nowhere, and the second read then assigned a server snapshot that predates it straight over the top. Two overlapping reads are the ORDINARY case, not a corner: a foreground push starts one without awaiting it and so does the reconnect watcher, and two pushes in quick succession is what an incident looks like. In realtime mode the poller is stopped, so nothing fetched again and the row stayed invisible until a reconnect or another push. The marker is now a depth, and the buffer is cleared when the LAST read finishes.
  • reconcilePushIdentity() could issue two logins for one intent. Consumers fire it unawaited from more than one lifecycle path, those paths overlap on a restore-then-bump, and both passes read actual != intent and both called login(): the same double call the OneSignal SDK's own 5.6.6 single-flight patch exists to survive, re-created one layer above it. It is single-flight now. A caller arriving while a pass runs JOINS it instead of starting a second one, and joining is not dropping: the intent can move while a pass is in the air, so a joiner the completed pass did not cover runs its own afterwards. This is still not a retry, which was deliberately refused; a pass that FAILED on the intent a joiner wants stays failed, and pushIdentityError carries the reason.
  • want() could skip persisting a change it was called for. It marked the intent as loaded and then compared against whatever happened to be in memory, so on a signed-out cold boot logoutPush() compared null against null, returned early, and never issued the vault delete while the vault still held the previous person's external id. That stale id then survived every later boot, and any ordering change that let a reconcile read it first would have issued a login on a signed-out device, which is the wrong-recipient page this whole design exists to prevent. The persisted intent is now read BEFORE the equality check, which also revives _loadPushIntent() from the dead code the old ordering had made of it.
  • A configured notifications.push.driver name could be served by a driver registered under a different one. The registry fell back to "the only factory there is" even when the config named another, so a consumer registering Notify.extend('fcm', ...) under a config saying onesignal silently got FCM, and the provider's unservable-value error never fired either, because that check consults pushDriverOrNull first and reads a served driver as a consumer having supplied their own. A PRESENT config value is an instruction and is now served only by a factory registered under that name; the single-factory fallback is kept for an ABSENT one, where there is no instruction to contradict and where a test registering one double reaches the registry with no provider booted.
  • Signing out left the previous person's notifications on the bell. stopPolling() stopped the timer and nothing else, so the cached list survived, and notifications() hands that list to every new listener immediately: on a shared device the next person read the previous person's incident titles and monitor names until the first fetch landed. The clear belongs to logoutPush(), not to stopPolling(), because stopPolling() is not a sign-out at all: startRealtime() calls it on its way in, so clearing there would empty the bell every time a socket comes up. logoutPush() is the sign-out call the facade documents, it runs on a build with no push driver at all, and it is unconditional where want(null) returns early. A read issued for the session that just ended is covered too: it is compared against a session marker on the way back and its rows are dropped rather than assigned, since the answer to a request made for somebody who has signed out is still that person's data.
  • The preference matrix saved with one mutex for every cell. A single _isSaving flag guarded the whole type x channel grid and its early return neither queued the second edit nor reported it, while WSwitch.value reads the stored data that return never touched: a second toggle during an in-flight PUT was a tap that visibly did nothing, with no spinner, no snap-back and no message. An operator silencing two noisy channels in a row kept being paged by the second one with nothing on screen saying why. The guard is keyed by type and channel now, so independent cells no longer block each other, and a repeat of the SAME cell is still coalesced. Rollback had to follow: restoring a whole-matrix snapshot taken before a neighbouring cell's edit would undo a write the backend ACCEPTED, with nothing re-applying it, so a failed write now reverts its own cell against the current matrix instead.
  • The notification list screen and the preference screen shipped two different answers to the same question. Both arrived in this release; the preference screen is a MagicStatefulView over a controller with MagicStateMixin, while the list screen was a plain StatefulWidget holding its rows, its page number, its loading flag and its error flag in setState, for state fetched over HTTP. That cost three things at once: the screen could not participate in RxStatus, so nothing outside the widget could read whether it was loading or had failed; it forgot which page the user was on across a remount, so a host shell rebuilding the screen on navigation sent the user back to the top of the list with nothing on screen explaining why; and it exposed no container-resolved seam a host or a test could reach, which is the seam this package documents everywhere else. It is a NotificationsListController now, resolved through MagicView exactly as the preference pair is. The rows live on a ValueNotifier beside the mixin rather than inside its state, because setLoading() clears that state and paging forward would empty the table on every chevron tap; and loadPage() is deliberately not guarded against a concurrent call, for the same reason the preference matrix's guard had to be keyed per cell rather than held across the whole grid. Pagination behaves as it did from the user's side, minus the forgetting.
  • A last:border-b-0 on the preference rows never fired, so every card kept a hairline it was written to remove. Wind implements no structural pseudo-variants; last: is on its explicitly unsupported list, and an unrecognised prefix is read as a state name that nothing ever activates. Nothing warned either, because the token BODY (border-b-0) is a recognised family, so the class was dropped in silence. The visible result was a bottom border under the final channel row of every card, running into the clipped corner of a rounded-2xl overflow-hidden shell. The row now learns whether it is last from its index, which is the only thing that knows, and emits the separator for every row but that one.
  • Four classNames were built by Dart string interpolation, which doubles the parser cache keys for one row shape. Wind caches parsed styles per resolved className string, so a row whose class list is assembled per item parses two variants where it has one shape: a list of twenty notifications paid for twenty parses of two strings instead of one. The dropdown's row was the worse shape, because the read arm interpolated an EMPTY segment: bg-primary/5 dark:bg-primary/10 was present in one string and absent from the other rather than being overridden, which is exactly what Wind's state prefixes exist to express. All four sites (the dropdown's row and title, the preference view's channel chip and its glyph) are now a single static className plus a states: set. The preference chip's state is named enabled rather than active on purpose: active: is one of the three prefixes WDiv reads as "this element is interactive", and using it would have wrapped every chip in a WAnchor it does not need.
  • Three colour defaults in the dropdown shipped without their dark: peer, and its mark-all-as-read hover was hardcoded to one adopter's brand. The unread badge (bg-red-500 with text-white) and the error glyph (text-red-500) were the only unpaired tokens in a component whose every other default is paired. The badge is the element that most has to read at a glance, and it sits on a panel that turns dark:bg-gray-800, so it now lightens to dark:bg-red-400 with a near-black dark:text-red-950 count on it. Pairing matters more on these three than on an ordinary token because an override REPLACES its default rather than appending to it: an adopter overriding badgeClassName inherits nothing, so the default is the only chance to ship the pair. Separately, the "mark all as read" action was text-primary hover:text-green-600: the resting colour resolves to the ADOPTER's brand while the hover jumped to a fixed palette green, which looks deliberate only while that brand happens to be green. The hover is brand-relative now (hover:text-primary/80).
  • A push addressed to somebody who signed out was still DRAWN on the lock screen. The receive-side subject guard was described as what makes the un-converged identity window safe; measured, it only made the in-app bell safe. The mobile driver's addForegroundWillDisplayListener republished the payload and never called the preventDefault() the SDK exposes, so the manager suppressed its own republish while the OS went ahead and drew the incident title and monitor name for the next person holding the device. The foreground listener now asks the guard BEFORE the notification is drawn and suppresses both halves when the subject disagrees with the device's push intent. The comparison is not duplicated in the driver: the manager installs _addressedToIntent on the driver as PushDriver.subjectGuard when it attaches one and takes it back when it detaches, so there is still exactly one implementation of "is this addressed to us", and a driver nobody installed a guard on displays everything rather than going silent. What this does not close: backgrounded or killed, the listener does not run at all and the OS draws the notification with no client code involved. That half is not closable from a client; it needs the server to stop addressing a subscription it believes is stale. Both the driver and the manager's guard now say so in their docblocks, so the next reader does not believe the guard is stronger than it is.
  • The web driver had the same open half, on both of its event paths. It now answers the OneSignal Web SDK's foregroundWillDisplay with preventDefault() when the guard rejects the payload, so a push addressed to somebody who has signed out is neither drawn by the browser nor republished in-app; the interop layer hands the driver the event's own suppression across the JS boundary rather than converting the event to a map and losing it. The click path had a quieter version of the same bug: the web event nests the payload one level deeper than the mobile SDK does, so a guard handed the wrapper looked for subject at the top level, never found it, and read every clicked push as un-judgeable and therefore ours. Both paths now extract notification.additionalData before the guard sees it, which matters because a consumer subscribing to onPushClicked navigates from that payload. With no visible page the service worker still draws whatever arrives, which only the server can close. That first round extracted the payload for the JUDGEMENT only and went on republishing the wrapper, on the reasoning that a consumer might already be reading fields off it; no such consumer existed, the one that does exist reads the server's keys flat, and the browser is the platform it reads them on. The republished shape is fixed in the Breaking Changes entry above.
  • notifications:doctor printed three ticks and exited 0 for a deployment that could not send a single push. Recognising an env-resolved app_id fixed a false negative and introduced a false positive: the env branch printed its tick unconditionally, without checking whether anything actually carries a value for that key. Against a real consumer whose ONESIGNAL_APP_ID is present but BLANK, the doctor answered "All config checks passed", "All requirements met" and exit 0. The doctor cannot read the environment a build will run with, but .env is a readable file in the project root, which is the whole reason this state is checkable. There are three answers now, not two: a valid literal is green as before; an env-resolved key whose .env entry carries a non-empty value is green and names the key; an env-resolved key that .env does not carry, or carries blank, is a WARNING naming both the key and the file it looked in, and prints no tick. A blank value reads as absent deliberately, because ONESIGNAL_APP_ID= initialises the SDK with an empty App ID just as surely as a missing line does. A warning does not fail the command, because working before provisioning is a normal state and a doctor that always fails stops being read, but it withholds "All requirements met" and says "Nothing failed, but push cannot send yet" instead. Every existing failure is unchanged.
  • notifications:uninstall cut its own wiring out of the middle of a comment. Removal applied the import and factory patterns to the raw source while detection strips comments first, so a project whose config import is commented out was correctly judged "not wired" and then had that exact text excised from inside its comment anyway, leaving a dangling // on the line. Removal now shares its notion of "comment" with the detection that authorised it, and both edits go through the same reader so the two cannot drift apart.
  • A failing backend rendered as an empty inbox on the notification list screen. With the facade absorbing the failure, NotificationsListController.loadPage() reached setSuccess holding an empty page, so the view took its empty-state branch and told the reader there was nothing to see. The controller's catch is now the reachable handler for every failure mode and puts the screen in isError with notifications.load_failed; the rows and the page number it already had are left untouched, so a failed reload shows the failure without throwing away what the user was reading, and a retry re-reads the page they were on. The view checks isError before the rows and before the empty state, and its shared placeholder helper is no longer named after only one of the two states it renders. A genuinely empty inbox still shows the empty state.
  • The push-intent load counted a read that had STARTED as one that had finished, and both of its readers mean finished. This one was introduced by the fix two entries above, the one that moved the vault read in front of want()'s equality check: the guard flag was raised BEFORE the await, so a second caller arriving while the read was suspended walked straight past it with nothing read yet. Both readers of that flag then answered on evidence that did not exist. want() compared against an in-memory intent the vault had not answered for, so a sign-out landing mid-read saw null against null, returned early, and was overtaken when the first caller resumed, assigned the vault's value and logged that person in: the device ended up subscribed as somebody who had just signed out, with nothing retrying, which is the exact wrong-recipient page the whole intent/reconcile design exists to prevent. And the receive-side guard's escape hatch, "an intent nothing has READ is no evidence in either direction", was closed for the entire read window, which is precisely the window the SDK replays a cold-start tap into, so a real page was dropped in silence there. The load is single-flight now: the read in flight is held as a future, a second caller JOINS it rather than passing it, and the flag is raised only once the read has resolved. A read that throws still counts as read and still completes, because the alternatives are re-reading a vault that is failing on every later call and leaving every joiner on a future nothing completes. A read still in the air when forgetDrivers() runs is completed for its joiners and written nowhere, since its answer belongs to a manager state that no longer exists.
  • forgetDrivers() left every in-flight operation behind. It cleared the intent, the convergence flag, the error and the last reconciled intent, but not the reconcile pass, the vault read, the fetch depth or the mid-fetch frame buffer, so a pass or a read still in the air at teardown leaked into the next test through the one seam that exists to stop exactly that. The reconcile field was the sharpest edge: the next test's first reconcilePushIdentity() JOINED a future minted by a test that was already over, and did nothing at all until something released it. The reset now drops the in-flight pass and the in-flight load with the rest, zeroes the fetch depth, empties the frame buffer, and bumps the session marker so a read already on its way back cannot land on whoever is holding the manager by then; the depth is clamped at zero on the way out for the same reason, because a read that comes back after the reset decrements past it and a negative depth would keep the next read's buffer from ever clearing. What it still does NOT do is touch the PERSISTED intent, and that split is deliberate: a test helper that signed a real device out would be worse than the leak it fixes.
  • A realtime frame for a session that had ended still reached the bell. The fetch path compares a session marker before it assigns rows, so a read issued for a person who has since signed out cannot land; _applyRealtimeFrame wrote to the cached list with no such check, and the realtime path fires far more often than the fetch path. Leaving a channel is not instantaneous, so a frame already on the socket when the sign-out ran was decoded, prepended and published, putting the previous person's incident title back on the next person's bell after the clear that dropped it. The socket needs its own copy of the marker because a frame is applied synchronously as it arrives, by which time the session has already moved: startRealtime() records the session it subscribed for and a frame from any other one is dropped. The idempotent branch (a repeat call for the same channel and event) adopts the current session too, or a person signing back in on the channel they were already subscribed to would be left with a bell that is permanently deaf, which is a worse failure than the one being fixed.
  • Every diagnostic this package writes could throw on a host that bound no logger, and the one in the entry above did. Log.error resolves log out of magic's container and raises when nothing bound it, so eight unguarded call sites (the two HTTP controllers, the service provider and the manager) each turned "we could not read your notifications" into an unhandled exception on a build that registers no logging provider. The catch inside NotificationsListController.loadPage() is where it surfaced, and it is the worst possible place for a call that can raise: handling the failure itself failed, so the screen never reached the error state the entry above exists to set and the exception escaped into whatever awaited the load. The logging was always unsafe; it only became REACHABLE when the fetch started throwing instead of absorbing a failed read into an empty page, which is why 501 green tests and every release before this one never saw it. An absent log binding is also not merely a test condition: it is a legitimate host configuration, and a package must not require its consumer to have bound a service the package chose to use for its own diagnostics. Every Log. call in lib/ now goes through one package-internal seam, NotificationLog, which ASKS (Magic.bound('log')) rather than catching. The manager's private _safeLogError had the right idea and the wrong mechanism: try { Log.error(...) } catch (_) {} reaches the same outcome and hides everything else with it (a broken log channel, a driver that throws on write, a message that was never delivered), and being library-private it could not cover the five call sites outside that one file. It is deleted rather than kept as a wrapper. Nothing gets quieter on a host that HAS bound log: the same messages arrive at the same levels through the same facade, including the provider's boot-time debug line and its two error reports on an unservable notifications.push.driver value and a failed SDK initialisation, which were made loud on purpose earlier in this release.

0.0.3 - 2026-08-19 #

Added #

  • Notification state can arrive over a socket instead of being polled for. Notify.startRealtime(channel: ...) subscribes to the notifiable's private broadcast channel and applies each notification.created frame straight to the stream, so a new notification shows up when the server sends it rather than up to 30 seconds later. The frame carries the whole row in the same shape GET /notifications returns, so no HTTP follows it.
  • Notify.stopRealtime(), Notify.isRealtime and Notify.isPolling.

Changed #

  • startPolling() is a no-op while realtime is live. A consumer keeps wiring it to auth state and does not have to know whether a socket happens to be up: with one, the 30-second timer is waste on top of a connection that already delivers every row; without one, nothing changes. stopRealtime() or a dropped connection restores the timer.
  • magic constraint bumped to ^0.0.6. Echo.connection (the public driver accessor) is the floor for the realtime path: without it there is no way to tell an already-open connection from a closed one, and magic's Reverb driver opens a SECOND WebSocket on a redundant connect() instead of refusing it. A 0.0.z caret pins the patch digit, so ^0.0.5 resolved exactly 0.0.5, which has no such accessor.

Fixed #

  • A socket frame that arrived while fetchNotifications() was in flight was clobbered by the read. The frame prepended to the cached list and the server's list was then assigned over the top, so the notification vanished until something fetched again. The window is small and entirely real, because startRealtime() subscribes and THEN fetches, which is the exact moment a backlog is most likely to be publishing. Frames received during a read are now merged back on top, keyed by id.
  • startRealtime()'s idempotence key now includes the event name. Keyed on the channel alone, a caller that changed the event for the same channel hit the early return, so the manager silently kept handling the old event name and delivered nothing, with no error.

Notes #

  • Realtime is opt-in and degrades in both directions. startRealtime() returns false and changes nothing when no broadcast driver is configured (a BROADCAST_CONNECTION=null deployment), and a socket that drops falls back to polling until it returns, at which point the fallback is dropped and the list is refetched once to cover what Reverb cannot replay.
  • The channel name is the caller's to supply (App.Models.User.{id} by Laravel's default): this package has no user model and cannot know whose notifications it is receiving. See doc/basics/laravel-backend-setup.md for the server half.

0.0.2 - 2026-07-26 #

Changed #

  • magic constraint bumped to ^0.0.5. Tracks the magic 0.0.5 release (the Model.save() 422 validation-error surface and the auth-state redirect refresh). Under pub's 0.0.z caret semantics every bump of magic's patch digit is an upper-bound break, so a plugin left on ^0.0.4 makes a shared resolution with any consumer on magic 0.0.5 unsolvable. This release exists to keep that graph solvable; there is no behavior change in this package.

0.0.1 - 2026-06-24 #

Breaking Changes #

  • Removed bin/magic_notifications.dart entrypoint: CLI commands now surface via host app's artisan binary (dart run <app>:artisan notifications:<cmd>), not as a standalone dart run magic_notifications <cmd>. Update your scripts and CI workflows accordingly.
  • Removed magic_cli dependency: Install now uses fluttersdk_artisan's manifest-driven model, not magic_cli's imperative Kernel.

Changed #

  • Install now manifest-driven: install.yaml declares the static slice (provider injection only); dynamic logic (UUID validation, platform conditionals, placeholder-rendered config, arbitrary file writes) lives in InstallCommand's fluent override.
  • CLI architecture: Commands contributed via MagicNotificationsArtisanProvider registered in host's artisan.providers config.

Added #

  • MCP tools: notifications_doctor and notifications_channels are now read-only tools available to AI agents via MCP.

📚 Documentation #

  • README: Rewrite to match Magic ecosystem format
  • doc/ folder: Add comprehensive documentation
  • CLAUDE.md: Add project guidance for AI-assisted development

0.0.1-alpha.1 - 2026-03-25 #

✨ Core Features #

  • Multi-channel notifications: Database (in-app), Push (OneSignal), Mail (contract)
  • Notify facade: Static API for sending, fetching, polling, preferences
  • NotificationManager: Singleton dispatcher with channel/driver orchestration
  • NotificationPoller: Timer-based background polling with pause/resume/stop
  • User preferences: Global channel toggles + per-type channel preferences
  • Optimistic updates: markAsRead, markAllAsRead, delete with API rollback

🔔 Push Notifications #

  • OneSignalDriver: iOS/Android push via onesignal_flutter ^5.4.0
  • OneSignalWebDriver: Web push via JS interop with conditional imports
  • PushPromptDialog: Soft prompt widget before OS permission request
  • Push subscription: Permission state tracking, opt-in/opt-out

🔧 CLI Tools #

  • install: Interactive wizard, config, pubspec, platform files, OneSignal setup
  • configure: Show/update notification settings
  • doctor: Health check with exit codes
  • test: Send test notifications (dry-run, database, push, mail)
  • channels: List channel status
  • uninstall: Remove plugin integration
  • publish: Copy config stub to consumer project

🏗️ Architecture #

  • Contract-first design: Notification, NotificationChannel, Notifiable abstractions
  • Service Provider: Two-phase bootstrap (register + boot) with IoC bindings
  • Driver abstraction: Swappable push providers (OneSignal, FCM, etc.)
  • Config-driven: All settings via Magic ConfigRepository
0
likes
150
points
339
downloads

Documentation

Documentation
API reference

Publisher

verified publisherfluttersdk.com

Weekly Downloads

Multi-channel notification system for Magic Framework. Supports database (in-app), push (OneSignal), and mail channels.

Homepage
Repository (GitHub)
View/report issues

Topics

#notifications #push-notifications #onesignal #magic-framework #flutter-plugin

License

MIT (license)

Dependencies

flutter, fluttersdk_artisan, fluttersdk_wind, magic, meta, onesignal_flutter

More

Packages that depend on magic_notifications