Skip to content

Add flutter_local_notifications_watchos: local notifications on the watch - #9

Open
iamoguzhanarslantas wants to merge 6 commits into
flutterwatch:mainfrom
iamoguzhanarslantas:flutter-local-notifications-watchos
Open

iamoguzhanarslantas wants to merge 6 commits into
flutterwatch:mainfrom
iamoguzhanarslantas:flutter-local-notifications-watchos

Conversation

@iamoguzhanarslantas

Copy link
Copy Markdown
Contributor

Summary

Adds flutter_local_notifications_watchos 0.0.1, the watchOS implementation of flutter_local_notifications, over UserNotifications and dart:ffi.

Not verified on a physical Apple Watch. Everything below was checked on the watchOS 27.0 Simulator and on the host only. I could not get a watch to show up in Xcode, so no device run has been done.

  • flutter_local_notifications is not fully federated: on platforms that report iOS, which the watch does, the app-facing calls check that the platform instance is an IOSFlutterLocalNotificationsPlugin. This package's class therefore extends it and overrides every method that would use the method channel. It depends on flutter_local_notifications itself, which does not depend back on it.
  • Supported: initialize (categories with their actions, default presentation, the permissions the settings ask for), requestPermissions / checkPermissions, show, zonedSchedule (a calendar trigger in the date's own time zone, repeating by matchDateTimeComponents), periodicallyShow / periodicallyShowWithDuration, cancelling, the pending and delivered lists, getNotificationAppLaunchDetails, and taps through onDidReceiveNotificationResponse.
  • The flutter-watchos host's FlutterWatchOSAppDelegate owns the notification-center delegate and rebroadcasts its callbacks. The native file observes them from a load-time constructor, so the tap that launched the app is caught before Dart runs. Each notification carries its id, payload and presentation flags in userInfo under keys of this package's own, and callbacks for anyone else's notifications (pushes, other plugins) are left alone.
  • Not supported, documented in the README: named sounds (the default sound plays), typed text input on an action, onDidReceiveBackgroundNotificationResponse, and openAppNotificationSettings (returns false). isBadgeEnabled is always false.
  • The root README gets the table row; tool/upstream_versions.yaml gets flutter_local_notifications; the word allow-list gets the package's C free(.

Verified

On the watchOS 27.0 Simulator (Apple Watch Series 12, 46 mm):

  • Upstream's example/integration_test/flutter_local_notifications_test.dart passes, unchanged but for the formatting CI checks, after the permission prompt is allowed (flutter-watchos test reinstalls the app, so the prompt comes back each run).
  • example/integration_test/watchos_notifications_test.dart (4 tests) passes: the permission flags, the launch details, and scheduling, listing and cancelling against the real notification center. It fails on the first commit's code (see the second commit).
  • By hand with the example: a notification scheduled ten seconds out was listed as pending, shown on the watch face with the app in the background, and its tap reached onDidReceiveNotificationResponse with its id and payload. A cold launch from a notification is reported by getNotificationAppLaunchDetails.
  • The example builds with every ffiSymbols entry in the export table.

Off the watch:

  • 14 host tests through the app-facing FlutterLocalNotificationsPlugin, with the target platform set to iOS.
  • Every tool/ check, dart format as CI runs it, and pub publish --dry-run are clean.

Not verified

  • A physical Apple Watch. Nothing has run on a device.
  • A notification arriving while the app is in front: it was added but not seen on screen in the Simulator, so the host's willPresent path and the five present… flags are unchecked.
  • Action buttons on a delivered notification, and repeating triggers firing more than once (they were scheduled and listed, not watched over time).

Notes for review

  • The app must install the host's application delegate (@WKApplicationDelegateAdaptor(FlutterWatchOSAppDelegate.self)), as flutter-watchos create does.
  • A banner shown while the app is in front closes when tapped and sends no response: watchOS does not report that tap. The README says so.
  • example/lib/ is a small watch demo, because upstream's example needs flutter_timezone, which has no watchOS implementation.
  • Three commits: the package; the BOOL fix (@(a == b) boxed an int, so the launch flag and every permission flag reached Dart as 1 and read false) with the new integration test; and the formatting of upstream's test.
  • Like my other open PRs here, this one edits the root README's table and tool/upstream_versions.yaml, so whichever merges first leaves the others a conflict there; I will rebase each one after a merge.

🤖 Generated with Claude Code

iamoguzhanarslantas and others added 3 commits October 21, 2026 01:49
… watch

flutter_local_notifications checks that the platform instance is an
IOSFlutterLocalNotificationsPlugin on iOS-reporting platforms, which the
watch is, so this class extends it and overrides every method that would
use the method channel, answering through UNUserNotificationCenter over
dart:ffi: initialize with categories and default presentation,
requestPermissions and checkPermissions, show, zonedSchedule (calendar
triggers in the date's own time zone, repeating by matchDateTimeComponents),
periodicallyShow and periodicallyShowWithDuration, cancel, pending and
delivered lists, and the launch tap.

The flutter-watchos host owns the notification-center delegate and
rebroadcasts its callbacks; the native file observes them from a load-time
constructor, so the launch tap, which the host does not keep, is caught.
Each notification carries its id, payload and presentation flags in
userInfo, and callbacks for anyone else's notifications are left alone.
Named sounds, typed action input and the background-isolate callback have
no watchOS path and are documented as such.

The upstream example needs flutter_timezone, which has no watchOS
implementation, so the example's lib/ is a small watch demo; the official
integration test is upstream's, unchanged. The root README lists the
package, tool/upstream_versions.yaml gains flutter_local_notifications
22.3.2, and the word allow-list gains the package's C free().

Checked: 13 host tests through the app-facing plugin with the target
platform set to iOS; the official integration test on the watchOS 27.0
Simulator after answering the permission prompt; and the example there
scheduling a notification, showing it on the watch face in the background,
and delivering the tap with its id and payload. A notification arriving
while the app is in front was not seen on screen, and nothing is checked on
a physical watch yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ags that read true

A C comparison is an int, so `@(launch != nil)` and the checkPermissions
flags reached JSON as 1, which Dart's `== true` reads as false: a launch
tap was never reported and every alert setting read as off. They are cast
to BOOL now.

A launch tap that comes in after Dart has read the launch details queues
for the response callback instead of being dropped, since the host posts
it from the main thread while Dart runs on its own.

A new integration test reads the flags, the launch details, and
schedule/list/cancel against the real notification center; it fails on
the old code. Checked on the Simulator: cold launch from a notification
reports it, and a tap while the app runs reaches the callback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st as CI checks it

Its content is upstream's; only the layout changes, so that CI's
dart format step passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mehmet-nettle

This comment was marked as duplicate.

@MAUstaoglu MAUstaoglu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, this is a careful port with good coverage (Simulator-tested, as the description says). Before merging:

Must-fix

  1. LICENSE:1 names only FlutterWatch. Sibling packages keep their upstream's notice, and this one ships upstream code and tests, so please add Michael Bui's BSD-3 notice.
  2. watchos/Classes/flutter_local_notifications_watchos_ffi.m:501-502: upstream 22.3.2 ignores UNErrorDomain errors when adding a request, because iOS 27 reports a missing permission that way. Here they become a PlatformException, so show() without permission likely throws on watchOS 27. Please match upstream.
  3. _ffi.m:208-210 and lib/flutter_local_notifications_watchos.dart:140: any response that arrives before initialize, including dismissals and background actions, becomes the launch tap. Dismissals are also always reported, while upstream reports them only when dismissIsolate is set, and that setting never reaches native here.
  4. example/integration_test/watchos_notifications_test.dart:25-29: setUpAll requests permission, so the suite blocks on the prompt. Please gate it as AUTHORING §2c describes.

Nice-to-have

  • _ffi.m:
    • :169 and :172 hide banner and list when presentAlert is false; upstream ignores that flag on iOS 14+;
    • :463-464 counts provisional as enabled;
    • :446 returns true when nothing is requested, where upstream returns false.
  • PORTING_REPORT.md:71: providesAppNotificationSettings exists on watchOS 5+, and criticalSoundVolume is dropped without a note.
  • README: mention the time-sensitive and critical-alert entitlements, and that tz.local stays UTC on the watch.
  • Extending upstream's concrete iOS class means a method added in an upstream minor release will throw MissingPluginException; 23.0.0 is at dev.4. Worth a note.
  • Allow-list: free( is justified, since it is a C function. Please keep the list alphabetical, and fix the reason text: the export is _release.

We'll start the CI run after your update. Thanks!

@MAUstaoglu
MAUstaoglu dismissed mehmet-nettle’s stale review October 23, 2026 20:47

Posted from the wrong account; superseded by the maintainer review.

iamoguzhanarslantas and others added 2 commits October 24, 2026 00:18
From the review of PR flutterwatch#9:

* LICENSE carries Michael Bui's BSD-3-Clause notice, as upstream's does,
  next to The FlutterWatch Authors'; the README says so.
* An error in UNErrorDomain from adding a request is ignored, as upstream
  22.3.2 ignores it: watchOS 27, like iOS 27, reports a missing permission
  that way, so show() without permission threw.
* Only a tap on the notification, or on an action that opens the app, is
  the launch tap, as upstream. A dismissal is reported only for a
  notification whose details set dismissIsolate, which now reaches native
  in userInfo, and one for the main isolate that comes in before
  initialize is dropped, as upstream drops it. Background actions and the
  other dismissals reach onDidReceiveNotificationResponse once initialize
  has run. Which actions open the app is kept with each notification, since
  a launch response arrives before initialize.
* The watchOS integration test asks for permission only with
  --dart-define=FLNW_PROMPT=true; without it the cases that need
  permission are skipped and the suite cannot hang on the prompt.
* As upstream on iOS 14+: presentAlert is not read for the banner and the
  list; checkPermissions' isEnabled is full authorization only; initialize
  and requestPermissions answer false when they ask for nothing.
* providesAppNotificationSettings (watchOS 5+) is asked for and reported,
  and criticalSoundVolume plays the default critical sound at that volume.
* README: the Time Sensitive and Critical Alerts entitlements, tz.local
  staying UTC on the watch, and what extending upstream's concrete class
  means for methods a later upstream release adds.
* The word allow-list entry is in alphabetical order, and its reason names
  the _release export.

Checked: 15 host tests (2 new); on the watchOS 27.0 Simulator the watchOS
integration test without the define (1 passed, 3 skipped, no prompt) and
with it (4 passed after allowing the prompt), and upstream's integration
test (5 passed after allowing it). Taps from the watch face were not run
again after the routing change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tool/upstream_versions.yaml: main's new Firebase majors, and this
branch's flutter_local_notifications line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@iamoguzhanarslantas

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Pushed 7d903c9:

Must-fix

  1. LICENSE now has Michael Bui's notice, upstream's text verbatim, next to ours. The README says so.
  2. UNErrorDomain errors from addNotificationRequest are ignored, as in 22.3.2.
  3. Routing now follows upstream:
    • Only a default tap, or an action with .foreground, becomes the launch response.
    • A dismissal is reported only when the details set dismissIsolate, which now reaches native.
    • A main-isolate dismissal that arrives before initialize is dropped.
    • Background actions and the remaining dismissals are queued for onDidReceiveNotificationResponse, because the watch has no background isolate.
    • Each notification's userInfo records which of its category's actions open the app, because a launch response arrives before initialize.
  4. The prompt is gated behind --dart-define=FLNW_PROMPT=true. Without it the suite asks for nothing: 1 test passes and 3 are skipped.

Nice-to-have

  • Banner and list ignore presentAlert.
  • isEnabled no longer counts provisional.
  • initialize and requestPermissions answer false when nothing is requested.
  • providesAppNotificationSettings is requested and reported.
  • criticalSoundVolume uses defaultCriticalSoundWithAudioVolume:.
  • README: the time-sensitive and critical-alert entitlements, tz.local staying UTC, and a note on extending the concrete iOS class.
  • Allow-list: alphabetical, and the reason names _release.

Checked: 15 host tests. On the watchOS 27.0 Simulator:

  • The watchOS suite without the define: 1 passed, 3 skipped.
  • The watchOS suite with the define, after allowing the prompt: 4 passed.
  • Upstream's suite: 5 passed.

I did not re-run taps from the watch face after the routing change.

🤖 Generated with Claude Code

@MAUstaoglu MAUstaoglu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! All four must-fixes and every nice-to-have are in, and they match upstream 22.3.2. Before merging:

  1. A test for the UNErrorDomain fix: a watchos_notifications_test case where show() completes without permission, gated as skip: _prompt. It runs unattended and checks the watchOS 27 claim.

  2. A Simulator pass over the new response routing:

    • a tap and a foreground action at cold launch;
    • a background action;
    • a dismissal, with and without dismissIsolate.

    Please note the results in PORTING_REPORT.

  3. Docs: update the native header's JSON contract (_ffi.h:41-55 lacks providesAppNotificationSettings and isProvidesAppNotificationSettingsEnabled; the host fake follows that header), and PORTING_REPORT lines 41–43 and 15–19, which still describe the old launch rule.

Optional: requestAuthorization errors throw here, while upstream ignores them and returns granted.

@MAUstaoglu

Copy link
Copy Markdown
Member

On the example, for the record. AUTHORING.md §3 asks for upstream's own example, unchanged. Here upstream's example cannot launch on the watch: main() awaits FlutterTimezone.getLocalTimezone() with no guard, and flutter_timezone has no watchOS implementation, so it throws before runApp.

That makes your watch demo the right call for this package, and the report's Example section says why. The official integration test is upstream's, as the rule asks. Nothing to change here beyond the round-2 items.

From the second review of PR flutterwatch#9:

* The watchOS integration test shows a notification without permission on
  an unattended run (skipped when someone answers the prompt, or when
  notifications are already allowed). On the watchOS 27.0 Simulator the
  add fails in UNErrorDomain, code 2003, "Source is not authorized": the
  test fails on the old error handling, which threw a PlatformException,
  and passes now.
* The test passes iOS details without attachments, and that found a crash
  that predates the review: their null reached native as NSNull, which
  _content walked as a list, so show() with any DarwinNotificationDetails
  ended the app. The attachments, categories and actions are only walked
  when they are lists.
* The response routing was run by hand on the Simulator with new demo
  buttons, a "lap" category with an action that opens the app, a
  background action and a reported dismissal. A tap at cold launch is the
  launch details; the opening action at cold launch reaches
  onDidReceiveNotificationResponse instead, since the host delivers it
  about 0.2 s after Dart read the launch details; a background action and
  a dismissal with dismissIsolate reach the callback; a dismissal without
  it is not reported. PORTING_REPORT has the table, and the README says to
  read both the launch details and the callback.
* The native header's JSON contract names providesAppNotificationSettings,
  isProvidesAppNotificationSettingsEnabled, dismissIsolate and the new
  launch and queue rules; PORTING_REPORT no longer describes the old
  launch rule.
* As upstream, an error from requestAuthorization is not reported; the
  answer is whether it was granted.

Checked: 15 host tests; the watchOS integration test unattended from a
fresh install (2 passed, 3 skipped) and with the prompt allowed (4 passed,
1 skipped); the five routing cases above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@iamoguzhanarslantas

Copy link
Copy Markdown
Contributor Author

Thanks! Pushed ced2c4c:

  1. UNErrorDomain test. show completes without permission runs when _prompt is false. It skips itself if notifications are already allowed. On the watchOS 27.0 Simulator the add fails with UNErrorDomain code 2003 ("Source is not authorized"). Against the old error handling the test fails with that PlatformException; with the fix it passes.
    The test passes DarwinNotificationDetails without attachments, and that found an older crash. attachments: null reached native as NSNull, and _content iterated it, so any show() with iOS details ended the app. Attachments, categories and actions are now only iterated when they are arrays.
  2. Simulator pass over the routing. The demo has new buttons: a lap category with a foreground "Resume" action, a background "Skip" action and customDismissAction, scheduled with and without dismissIsolate. Results, also in PORTING_REPORT:
    • Tap, app not running: launch details ✅
    • "Resume", app not running: the app launches, and the action arrives through onDidReceiveNotificationResponse instead of the launch details. A log shows the host posting it about 0.2 s after Dart read the launch details, so it takes the late-launch queue. It is not lost, but it differs from iOS. The README says to read both.
    • "Skip", app in the background: the app stays in the background, and the action reaches the callback ✅
    • Dismissal with dismissIsolate: notificationDismissed ✅
    • Dismissal without it: not reported ✅
  3. Docs. The header's JSON contract now has providesAppNotificationSettings, isProvidesAppNotificationSettingsEnabled, dismissIsolate and the new launch and queue rules. PORTING_REPORT describes the new launch rule.

Optional: done. A requestAuthorization error is no longer reported, and the answer is granted, as upstream.

Checked:

  • 15 host tests.
  • The watchOS integration test from a fresh install, unattended: 2 passed, 3 skipped.
  • The same test with the prompt allowed: 4 passed, 1 skipped.

And thanks for the note on the example.

🤖 Generated with Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants