Skip to content

chore(deps): bump the fluttersdk group with 2 updates - #14

Closed
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/pub/fluttersdk-ac1fad6dd9
Closed

dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/pub/fluttersdk-ac1fad6dd9

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Sep 7, 2026

Copy link
Copy Markdown
Contributor

Bumps the fluttersdk group with 2 updates: magic_notifications and magic_starter.

Updates magic_notifications from 0.0.3 to 0.2.0

Release notes

Sourced from magic_notifications's releases.

v0.2.0

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.

Full Changelog: fluttersdk/magic_notifications@0.1.0...0.2.0

Changelog

Sourced from magic_notifications's changelog.

[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.

... (truncated)

Commits
  • 8958654 chore(release): 0.2.0 (#23)
  • ba5c92e fix(notifications): four defects a live QA walk turned up (#22)
  • 01d15bc fix(views): the default list could never delete a notification (#21)
  • da397b4 chore(release): 0.1.0 (artisan ^0.0.14, wind ^1.2.0) (#20)
  • 3419c56 feat(push): repair OneSignal across web, iOS and Android, and take ownership ...
  • 26e86eb chore(deps): bump dart-lang/setup-dart/.github/workflows/publish.yml (#18)
  • 1c34d9d ci: auto-merge low-risk Dependabot PRs (#17)
  • 2ef7d1d chore(deps): bump dart-lang/setup-dart/.github/workflows/publish.yml (#14)
  • 9a0d7f2 chore(rules): the frontmatter key is paths, not path (#16)
  • 10f68ab chore(lint): exclude generated and platform directories from analysis (#15)
  • Additional commits viewable in compare view

Updates magic_starter from 0.0.1-alpha.24 to 0.0.1-alpha.26

Release notes

Sourced from magic_starter's releases.

v0.0.1-alpha.26

Changed

  • Requires magic_notifications ^0.2.0, and the delete confirmation now answers whether it went ahead. NotificationsListView.onDelete changed to Future<bool> in that release, so _confirmThenDelete returns false when it refuses (no navigator to ask in, or somebody said no) and true after a delete the server accepted. That answer is the whole reason the signature changed: the list reloads its page after a real delete, because a row leaving page one pulls one up from page two and only the server knows which, and with nothing to read it had to reload after EVERY tap. So this dialog, the one this package added in the same Unreleased block, was costing a full GET /notifications every time somebody declined it. Nothing about the dialog itself changes.

Added

  • A delete asks first. The notification list's delete is destructive, irreversible and one tap away in a scrollable list, so the mount now shows this package's own MSConfirmDialog and only calls Notify.deleteNotification once somebody says yes. Asked here rather than in magic_notifications, which removed its own dialog widget in 0.1.0 precisely so a published package stops imposing one adopter's tone and layout; this keeps the confirmation in the same package as every other destructive confirmation a starter app shows, and looking like them is the point: MSConfirmDialog reads MagicStarter.manager.modalTheme, while Magic.confirm styles from view.confirm.* with light-mode fallbacks and would have shipped the one destructive dialog in the app that ignores the host's dark mode. The dialog is shown against MagicRouter.instance.navigatorKey.currentContext, since neither the view registry nor onDelete provides a BuildContext; a null context refuses rather than deleting, because nobody could have been asked. Copy comes from notifications.delete_confirm_title, notifications.delete_confirm_message, common.delete and common.cancel, and all four now ship in assets/stubs/install/en.stub.

  • common.delete, notifications.delete_confirm_title and notifications.delete_confirm_message in the install stub. The stub is the catalogue starter:install scaffolds into every consumer project, and Translator.get answers a missing key with the key itself, so without these a freshly installed app would open a dialog titled notifications.delete_confirm_title with a confirm button reading common.delete. An app with a hand-written catalogue still needs them added.

Fixed

  • The notification list can delete a notification again, which it never could. _mountNotificationViews() built const NotificationsListView(), and that view renders its per-row delete control only when onDelete is non-null, so the affordance never appeared. Because this registration REPLACES the package's own default in order to apply the host page geometry, its null was the whole ecosystem's answer: Notify.deleteNotification and the DELETE /notifications/{id} route behind it were working code with no surface anywhere. The mount now passes onDelete: Notify.deleteNotification, and a test asserts the mounted view carries it, which turns red if the parameter is dropped again. The nullable parameter itself is unchanged and still lets a host opt out by registering its own screen.

Full Changelog: fluttersdk/magic_starter@0.0.1-alpha.25...0.0.1-alpha.26

Changelog

Sourced from magic_starter's changelog.

[0.0.1-alpha.26] - 2026-09-03

Changed

  • Requires magic_notifications ^0.2.0, and the delete confirmation now answers whether it went ahead. NotificationsListView.onDelete changed to Future<bool> in that release, so _confirmThenDelete returns false when it refuses (no navigator to ask in, or somebody said no) and true after a delete the server accepted. That answer is the whole reason the signature changed: the list reloads its page after a real delete, because a row leaving page one pulls one up from page two and only the server knows which, and with nothing to read it had to reload after EVERY tap. So this dialog, the one this package added in the same Unreleased block, was costing a full GET /notifications every time somebody declined it. Nothing about the dialog itself changes.

Added

  • A delete asks first. The notification list's delete is destructive, irreversible and one tap away in a scrollable list, so the mount now shows this package's own MSConfirmDialog and only calls Notify.deleteNotification once somebody says yes. Asked here rather than in magic_notifications, which removed its own dialog widget in 0.1.0 precisely so a published package stops imposing one adopter's tone and layout; this keeps the confirmation in the same package as every other destructive confirmation a starter app shows, and looking like them is the point: MSConfirmDialog reads MagicStarter.manager.modalTheme, while Magic.confirm styles from view.confirm.* with light-mode fallbacks and would have shipped the one destructive dialog in the app that ignores the host's dark mode. The dialog is shown against MagicRouter.instance.navigatorKey.currentContext, since neither the view registry nor onDelete provides a BuildContext; a null context refuses rather than deleting, because nobody could have been asked. Copy comes from notifications.delete_confirm_title, notifications.delete_confirm_message, common.delete and common.cancel, and all four now ship in assets/stubs/install/en.stub.

  • common.delete, notifications.delete_confirm_title and notifications.delete_confirm_message in the install stub. The stub is the catalogue starter:install scaffolds into every consumer project, and Translator.get answers a missing key with the key itself, so without these a freshly installed app would open a dialog titled notifications.delete_confirm_title with a confirm button reading common.delete. An app with a hand-written catalogue still needs them added.

Fixed

  • The notification list can delete a notification again, which it never could. _mountNotificationViews() built const NotificationsListView(), and that view renders its per-row delete control only when onDelete is non-null, so the affordance never appeared. Because this registration REPLACES the package's own default in order to apply the host page geometry, its null was the whole ecosystem's answer: Notify.deleteNotification and the DELETE /notifications/{id} route behind it were working code with no surface anywhere. The mount now passes onDelete: Notify.deleteNotification, and a test asserts the mounted view carries it, which turns red if the parameter is dropped again. The nullable parameter itself is unchanged and still lets a host opt out by registering its own screen.

[0.0.1-alpha.25] - 2026-09-02

Fixed

  • The host page geometry now actually reaches the two notification screens. _mountNotificationViews() gated on Notify.view.has(key), and reading Notify.view is what seeds magic_notifications' own two screens into the registry, so the key was always present and this mount ALWAYS skipped: the MSPageContainer and the 1280 width cap it exists to apply never reached either screen in a real app. Three tests covered that wrap and all three passed, because their setUp called Notify.view.clear() and left the registry empty, which is not the state an app boots with. The gate is now hasOverride(key), which is true only when somebody CHOSE a screen rather than when the package seeded its default, and the tests reset with Notify.forgetView() so they run against a registry carrying those defaults. Reverting the gate to has now turns three tests red.

  • A host that registers a notification view BEFORE the routes are mapped no longer loses it. _mountNotificationViews() called Notify.view.register unconditionally, while every other default in this package is installed register-if-absent (MagicStarterManager._registerDefault checks has(key) first) precisely so provider order does not matter. The two calls land in different files by design: the installer injects the route mount into route_service_provider.dart, and the scaffold tells adopters to do their Notify.view work in AppServiceProvider, so which boot runs first is a property of the host's provider order that neither file can see. An adopter following that guidance had their screen silently discarded. Both orders now win, and each has its own test.

  • The two starter:* command banners printed v0.0.1. Twenty-four alpha releases in, publish and uninstall were still announcing the version they were written against, because each carried a hand-written literal that nothing compared with anything. They read magicStarterVersion now, which starter_artisan_provider_test.dartDescription has been truncated

Bumps the fluttersdk group with 2 updates: [magic_notifications](https://github.com/fluttersdk/magic_notifications) and [magic_starter](https://github.com/fluttersdk/magic_starter).


Updates `magic_notifications` from 0.0.3 to 0.2.0
- [Release notes](https://github.com/fluttersdk/magic_notifications/releases)
- [Changelog](https://github.com/fluttersdk/magic_notifications/blob/master/CHANGELOG.md)
- [Commits](fluttersdk/magic_notifications@0.0.3...0.2.0)

Updates `magic_starter` from 0.0.1-alpha.24 to 0.0.1-alpha.26
- [Release notes](https://github.com/fluttersdk/magic_starter/releases)
- [Changelog](https://github.com/fluttersdk/magic_starter/blob/main/CHANGELOG.md)
- [Commits](fluttersdk/magic_starter@0.0.1-alpha.24...0.0.1-alpha.26)

---
updated-dependencies:
- dependency-name: magic_notifications
  dependency-version: 0.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: fluttersdk
- dependency-name: magic_starter
  dependency-version: 0.0.1-alpha.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: fluttersdk
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file pub Dart pub.dev package updates labels Sep 7, 2026
@anilcancakir

Copy link
Copy Markdown
Member

Superseded by #13, which moved this constraint past what the bump proposes: main now pins magic_notifications: ^0.3.1 along with the other eight fluttersdk dependencies at the releases that exist. Nothing here is left to apply.

@anilcancakir
anilcancakir deleted the dependabot/pub/fluttersdk-ac1fad6dd9 branch September 13, 2026 14:06
@dependabot @github

dependabot Bot commented on behalf of github Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

This pull request was built based on a group rule. Closing it will not ignore any of these versions in future pull requests.

To ignore these dependencies, configure ignore rules in dependabot.yml

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file pub Dart pub.dev package updates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant