magic_notifications 0.2.0
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.onDeleteis nowFuture<bool> Function(String id)?instead ofFuture<void> Function(String id)?. A host passing aFuture<void>callback no longer compiles, which is the point: the widget had no way to learn whether the row left.onDeleteis an opaque host-supplied callback, so a host may delete by any route it likes and never touchNotify.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, whichmagic_starterdoes, spent a fullGET /notificationsevery time somebody declined one.truenow means the row is gone and the list reloads;falsemeans the host chose not to go ahead and nothing is re-read. A throw is a third outcome and is deliberately not the same asfalse: 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 trueat the end; a callback that can decline returnsfalseon that path. The package's own default (Notify.view's seedednotifications.list) is already updated. -
NotificationManager.deleteNotification(andNotify.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 acatch.markAsReadandmarkAllAsReaddeliberately 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_intervalnow 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 at5now issues six times the requests it did yesterday. An app configured at3600is 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_intervalwas validated by the CLI, reported bynotifications:doctor, shipped in every install stub, and never read at runtime.startPolling()constructedNotificationPoller(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 asNotificationManager.pollingInterval. The runtime now enforces the 5 to 600 second rangenotifications:doctorand 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, so1becomes 5 rather than jumping to 30. Zero and negatives clamp up for a specific reason,Timer.periodicaccepts them and then fires on every event-loop turn. A value that is present but not anint('30'from a string-backed source, or30.0) reads as absent, becauseConfig.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
smschannel rendered as "Sms" on the preferences screen, in every locale._channelLabelnamedmail,databaseandpushand let everything else fall to a helper that raises the machine name's first letter, andmagic-starter-laravelofferssmsin 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 anotifications.channel_smskey; 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
WAnchorwith 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 withnotifications.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_failedthroughMagic.error, then re-reads the page either way. Hosts must add anotifications.delete_failedkey; 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 owncurrent_page > last_pageand readslast_pageinstead. Keyed on that rather than on an emptydatalist, 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-500was written alone, so dark mode hovered to a red tuned for a white background. Paired withdark: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 seedednotifications.listbuilder passed noonDelete, and the list renders its per-row delete control only when that callback is non-null, so the affordance never appeared.deleteNotification()and theDELETE /notifications/{id}route behind it were working code with no surface. The default now passesdeleteNotification. The parameter stays nullable: a host that does not want its people deleting notifications registers its own screen over the default, which is the seamregisterDefaultexists 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/messagestill 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 onreachability(). NotificationManager.forgetChannels()andforgetPushDriver()are merged into oneforgetDrivers(). 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 testsetUp()calling either removed method to callforgetDrivers()instead.PushDriver.permissionStateis now the asynchronousFuture<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'sNotification.permission. The old synchronous getter could only ever report a cached guess. A customPushDriverimplementation must change the override from a getter to a method returning aFuture.PushNotSupportedExceptionis removed; the platform factory now throwsUnsupportedPlatformExceptioninstead. 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 catchesNotificationExceptionwhile a caller wanting the specific platform failure catches the new subtype by name.Notify.initializePush()(NotificationManager.initializePushWithUserId) andlogoutPush()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 throughVaultand reconciled by readingcurrentExternalId()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 aNotifiablethat 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)withpushinvia()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. TheNotifiableselects the preference matrix and the message; it never selected the recipient and cannot be made to. A mismatch now throwsNotificationExceptionwith codePUSH_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.datanow 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 publishednotification.additionalData, the object the server sent, flat; the web driver published the v16 event that WRAPS it, soevent.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 toNotify.onPushReceived/onPushClickedreads 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 nosubjecteither. A consumer that really did readdata['notification']['title']on web readsdata['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()(andNotify.fetchPaginatedNotifications()) now throwsNotificationExceptionon a failed read instead of answering an empty page. It used to catch everything and returnPaginatedNotifications.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 readRxStatuscorrectly; 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 asNOTIFICATIONS_FETCH_FAILED, and a 200 carrying a body the package cannot decode asNOTIFICATIONS_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.PushDrivergains three members that are ABSTRACT, so a driver written against 0.0.3 no longer compiles until it implements them:currentExternalId(),currentSubscriptionId()andonIdentityChanged. 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 onlogin/logout(reconcilePushIdentityreads the device back rather than trusting the call it just made) and what letsreachability()refuse anonwith no address to deliver to; the stream is the SDK's own confirmation that an identity change reached the server.PushIdentityChangefields 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 anullfield 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 theremoveTags/addEmail/removeEmailone 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)andNotify.forgetView().has(key)cannot answer "has anybody chosen a screen here", because readingNotify.viewis 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_startermounts both screens wrapped in the host's page geometry, and gated onhasthat wrap never reached either screen in a real app. The seeded pair is registered through a new internalregisterDefault, andregisterpromotes a key out of that set, sohasOverridedistinguishes a choice from a shipped default.forgetView()is the test-isolation seam for the registry, the sibling offorgetDrivers();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 nofirstNameorlastNameanywhere 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. Returningnullsays 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): aloginto 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)"; aloginto 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";logoutreverts 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 theloginorlogoutthat 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 thelogincall, 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. PushDrivergainsremoveTags,addEmailandremoveEmail, all three defaulted, so no existing driver breaks.removeTagsloops overremoveTag, 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.addEmailis 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 defaultaddEmailsends nothing and SAYS SO throughNotificationLog: 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. ItsremoveEmailtwin 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/removeTagsdirectly. The web SDK is whatever script the page'sindex.htmlloads, which this package does not control and cannot assert from here, so the interop PROBES forUser.addEmailbefore it uses it and answers whether the call was carried; the driver turns afalseinto a log line naming the method and pointing atindex.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 flipisPushIdentityConvergedor fillpushIdentityError: 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 fromisSupported,permissionState(),isOptedIn, andcurrentSubscriptionId(), 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=iospreviously accepted the flag and wrote nothing. It now addsremote-notificationtoUIBackgroundModesinInfo.plist(as a union with whatever the project already declares, not a replacement, so an existingfetchmode is not silently dropped), declaresaps-environmentinRunner.entitlements(creating the file if the project never had one), and pointsCODE_SIGN_ENTITLEMENTSat that file inproject.pbxprojso Xcode actually reads it.notifications:doctor's iOS check follows the same three markers instead of only assertingInfo.plistexists, which is true of every Flutter iOS project ever generated and could never fail. - The package now owns its notification UI.
Notify.viewis aNotificationViewRegistryholdingNotificationDropdown, anotifications.listview, and anotifications.preferencesview, API-identical tomagic_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 throughNotify.view.slot(NotificationViewRegistry.typeIconSlotView, type, builder), replacing the hardcodedmonitor_down/monitor_up/monitor_degradedicon 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 putspushin itsvia(). 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.isAvailablecarries 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. Reachingsend()at all while it is off means a caller went aroundisAvailable, 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_enabledinmagic-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_loginraises the platform request fromwant()(the pathNotify.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 ispermissionState() == notDetermined, and on mobile that state is sourced from the SDK's owncanRequest()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 fromreconcilePushIdentity(), 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 throughNotificationLogand 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 aPushPromptAdvicerather 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:actionisrequest(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), ornone. It already accounts for reachability, the interval,notifications.soft_prompt.enabledand 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_settingsmakes the mobile driver's settings fallback configurable, keeping today's behaviour as the default.OneSignalDriver.requestPermission()has always passedtruefor the SDK'sfallbackToSettings, 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'sPushDriver.canOpenPlatformSettings, because the two are the same fact, and that is whatpushPromptAdvice()reads to tellopenSettingsfrominstructions. The web driver has no equivalent and does not pretend to: no browser API opens site settings from a page, so the base contract answersfalseand 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()andPushDriver.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 itsbooldoes NOT distinguish: afalsecovers "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 askingcanRaisePermissionRequest()FIRST rather than by reading more into the bool than it carries.NotificationManager.pushDeliverySnapshot()and thePushDeliverySnapshotit 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 answersunavailablerather 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 insideNotificationServiceProvider.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 whilepushDriverOrNullis 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 postedreachability: unavailablefor 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 readspushDriverOrNullfor 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.NotificationPreferencesControllerand 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 throughMagicView'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.NotificationDropdownnow 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 innotification_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, atext-2xlglyph), 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'sdark:bg-gray-800alive under a light-only override, because Wind's last-wins is per family andbg-*anddark: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 inMediaQuery.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 readingkNotificationDropdownPanelClassNameto copy itsw-80beats 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 constructingNotificationManager()directly, so the instanceboot()configures is the oneregister()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 unservablenotifications.push.driverconfig value at error level, naming the bad value andNotify.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 (setPushDriverstill exists as the explicit escape hatch, butextendis 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 aNotificationExceptioninstead of reporting silent success. The send is gated on the newnotifications.push.self_test_enabledkey, 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
NotificationManagerhad already learned for the bell:logoutPushclears 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.fetchPaginatedNotificationscarries 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 alsosetEmpty()rather thansetSuccess(false): the argument tosetSuccessis 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, andwant()reassigns the intent without publishing anything, so an account switcher (or a token refresh resolving to a different subject) moved the device fromuser_Atouser_Bwith 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.findOrPutsingletons and magic's controller registry is process-lifetime, so sign-out disposed neither:NotificationManagercleared the bell's own cache and bumped its session, butpageNotifierkept A's incident titles andmatrixNotifierkept A's per-type channel matrix. On a shared device B signs in, opens the list, andbuildpaints A's rows beforeonInit's refresh lands;loadPage's catch then deliberately leaves the rows up, so a refresh that FAILS leaves them there indefinitely. The manager now publishesonSessionClearedwhen 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.sendawaited each channel in a bare loop, andPushChannel.sendbecame able to throw in this release (it was a no-op before the self-test endpoint). Withvia()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 avia()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 catchingNotificationExceptionfor a specificcodestill 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 carriedtitle,bodyanddata, and the endpoint validates exactly those three, so a fourth key would be dropped byvalidated()anyway. A notification that set its destination with.url()instead of burying it indatatherefore 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 underurl, the first key the deeplink handler reads; an explicitdata['url']still wins. - A reconcile pass that outlives a
forgetDrivers()no longer clears a newer pass's marker. The single-flight'sfinallynulled_reconcileInFlightunconditionally while_loadPushIntentguards 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 duplicatelogin()the single-flight exists to prevent. - The automatic permission request spent its one turn per launch having asked nobody.
_autoRequestPermissionOnLoginclaimed 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 whileNotificationServiceProvider, 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 switchednotifications.push.auto_request_on_loginon. Bothwant()'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:pushDriverOrNullis 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.
_autoRequestPermissionOnLoginclaimed the flag, calledrequestPermission(), 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 whoseinitializehas not finished yet:permissionState()answersnotDetermined, soreachability()readsoffandcanRaisePermissionRequest()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 readingonPushDriverAttachedis 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:installappended a config factory naming a symbol the project does not declare, and broke the build every time it ran. The guard deciding whetherlib/main.dartwas already wired tested the file for the literal stringnotificationConfig, 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 anotifications.*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. Restoringmain.dartby 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 ofconfig/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 oflib/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.dartis 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 --forcebroke the same build from the other end.--forceregenerateslib/config/notifications.dartfrom the stub, whose getter isnotificationConfig, while an already-wiredmain.dartgoes 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--forcerun 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--forcemeans "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 successfulnotifications:install, with or without--force, against any project state,lib/main.dartnames a getter that the config file on disk actually declares.notifications:uninstallreported the package removed while the project still referenced it. It matchedmain.dart's factory on the literalnotificationConfig, the same name the installer used to guess, so a project wired asnotificationsConfigkept 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 BEFOREmain.dartwas 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 fromlib/or as apackage: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.dartis 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:doctorreported "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 recognisesenvString('KEY', ...),env('KEY')andenv<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 missingapp_id, an empty one, aYOUR_APP_IDplaceholder 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 adart2wasmweb compile, soflutter build web --wasmfell through to the non-web arm and would have handed a browser the native mobile OneSignal driver. Both now guard ondart.library.js_interop, which a wasm target actually has. The non-web arm is also restructured into three: a stub default that throwsUnsupportedPlatformException(previously namedonesignal_stub.dartwhile actually returning the real mobile driver, so there was no arm at all for a platform this package does not implement), a newonesignal_io.dartcarrying the native arm, andonesignal_web.dartunchanged 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_pathandservice_worker_scopeout 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 registeredOneSignalSDKWorker.jsat the ROOT scope, which a Flutter web build already owns withflutter_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 ownOneSignalWebDriver.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
onNotificationReceivedlistener, 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 duringinitialize) 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 readactual != intentand both calledlogin(): 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, andpushIdentityErrorcarries 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 bootlogoutPush()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.drivername 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 registeringNotify.extend('fcm', ...)under a config sayingonesignalsilently got FCM, and the provider's unservable-value error never fired either, because that check consultspushDriverOrNullfirst 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, andnotifications()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 tologoutPush(), not tostopPolling(), becausestopPolling()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 wherewant(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
_isSavingflag guarded the whole type x channel grid and its early return neither queued the second edit nor reported it, whileWSwitch.valuereads 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
MagicStatefulViewover a controller withMagicStateMixin, while the list screen was a plainStatefulWidgetholding its rows, its page number, its loading flag and its error flag insetState, for state fetched over HTTP. That cost three things at once: the screen could not participate inRxStatus, 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 aNotificationsListControllernow, resolved throughMagicViewexactly as the preference pair is. The rows live on aValueNotifierbeside the mixin rather than inside its state, becausesetLoading()clears that state and paging forward would empty the table on every chevron tap; andloadPage()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-0on 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 arounded-2xl overflow-hiddenshell. 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/10was 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 astates:set. The preference chip's state is namedenabledrather thanactiveon purpose:active:is one of the three prefixesWDivreads as "this element is interactive", and using it would have wrapped every chip in aWAnchorit 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-500withtext-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 turnsdark:bg-gray-800, so it now lightens todark:bg-red-400with a near-blackdark:text-red-950count 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 overridingbadgeClassNameinherits nothing, so the default is the only chance to ship the pair. Separately, the "mark all as read" action wastext-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
addForegroundWillDisplayListenerrepublished the payload and never called thepreventDefault()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_addressedToIntenton the driver asPushDriver.subjectGuardwhen 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
foregroundWillDisplaywithpreventDefault()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 forsubjectat the top level, never found it, and read every clicked push as un-judgeable and therefore ours. Both paths now extractnotification.additionalDatabefore the guard sees it, which matters because a consumer subscribing toonPushClickednavigates 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:doctorprinted three ticks and exited 0 for a deployment that could not send a single push. Recognising an env-resolvedapp_idfixed 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 whoseONESIGNAL_APP_IDis 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.envis 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.enventry carries a non-empty value is green and names the key; an env-resolved key that.envdoes 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, becauseONESIGNAL_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:uninstallcut 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()reachedsetSuccessholding an empty page, so the view took its empty-state branch and told the reader there was nothing to see. The controller'scatchis now the reachable handler for every failure mode and puts the screen inisErrorwithnotifications.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 checksisErrorbefore 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 whenforgetDrivers()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 firstreconcilePushIdentity()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;
_applyRealtimeFramewrote 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.errorresolveslogout 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 insideNotificationsListController.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 absentlogbinding 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. EveryLog.call inlib/now goes through one package-internal seam,NotificationLog, which ASKS (Magic.bound('log')) rather than catching. The manager's private_safeLogErrorhad 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 boundlog: the same messages arrive at the same levels through the same facade, including the provider's boot-timedebugline and its twoerrorreports on an unservablenotifications.push.drivervalue 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 eachnotification.createdframe 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 shapeGET /notificationsreturns, so no HTTP follows it. Notify.stopRealtime(),Notify.isRealtimeandNotify.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.magicconstraint 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 redundantconnect()instead of refusing it. A0.0.zcaret pins the patch digit, so^0.0.5resolved 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, becausestartRealtime()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()returnsfalseand changes nothing when no broadcast driver is configured (aBROADCAST_CONNECTION=nulldeployment), 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. Seedoc/basics/laravel-backend-setup.mdfor the server half.
0.0.2 - 2026-07-26 #
Changed #
magicconstraint bumped to^0.0.5. Tracks the magic 0.0.5 release (theModel.save()422 validation-error surface and the auth-state redirect refresh). Under pub's0.0.zcaret semantics every bump of magic's patch digit is an upper-bound break, so a plugin left on^0.0.4makes 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.dartentrypoint: CLI commands now surface via host app's artisan binary (dart run <app>:artisan notifications:<cmd>), not as a standalonedart run magic_notifications <cmd>. Update your scripts and CI workflows accordingly. - Removed
magic_clidependency: Install now usesfluttersdk_artisan's manifest-driven model, not magic_cli's imperative Kernel.
Changed #
- Install now manifest-driven:
install.yamldeclares the static slice (provider injection only); dynamic logic (UUID validation, platform conditionals, placeholder-rendered config, arbitrary file writes) lives inInstallCommand's fluent override. - CLI architecture: Commands contributed via
MagicNotificationsArtisanProviderregistered in host'sartisan.providersconfig.
Added #
- MCP tools:
notifications_doctorandnotifications_channelsare 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