From 9d2e5f95c261f86399e270a8017dcac689282315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20Arslanta=C5=9F?= Date: Wed, 21 Oct 2026 01:49:17 +0200 Subject: [PATCH 1/5] flutter_local_notifications_watchos 0.0.1: local notifications on the 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 --- README.md | 1 + .../.gitignore | 23 + .../CHANGELOG.md | 18 + .../LICENSE | 27 + .../PORTING_REPORT.md | 73 +++ .../README.md | 71 +++ .../analysis_options.yaml | 19 + .../example/.gitignore | 14 + .../example/README.md | 13 + .../example/analysis_options.yaml | 4 + .../flutter_local_notifications_test.dart | 154 +++++ .../example/lib/main.dart | 123 ++++ .../example/pubspec.yaml | 25 + .../example/test_driver/integration_test.dart | 4 + .../example/watchos/Flutter/.gitignore | 3 + .../AppIcon.appiconset/Contents.json | 13 + .../HostApp/Assets.xcassets/Contents.json | 6 + .../example/watchos/HostApp/Info.plist | 48 ++ .../watchos/Runner.xcodeproj/project.pbxproj | 438 +++++++++++++ .../xcshareddata/xcschemes/Runner.xcscheme | 81 +++ ...utter_local_notifications_example.xcscheme | 77 +++ .../example/watchos/Runner/App.swift | 57 ++ .../AppIcon.appiconset/Contents.json | 140 +++++ .../AppIcon.appiconset/icon_1024.png | Bin 0 -> 13949 bytes .../icon_108x108_2x_44mm.png | Bin 0 -> 3890 bytes .../icon_117x117_2x_45mm.png | Bin 0 -> 4182 bytes .../icon_129x129_2x_49mm.png | Bin 0 -> 4452 bytes .../AppIcon.appiconset/icon_24x24_2x_38mm.png | Bin 0 -> 1066 bytes .../icon_27.5x27.5_2x_42mm.png | Bin 0 -> 1261 bytes .../icon_29x29_2x_companion.png | Bin 0 -> 1214 bytes .../icon_29x29_3x_companion.png | Bin 0 -> 1603 bytes .../AppIcon.appiconset/icon_33x33_2x_45mm.png | Bin 0 -> 1366 bytes .../AppIcon.appiconset/icon_40x40_2x_38mm.png | Bin 0 -> 1559 bytes .../AppIcon.appiconset/icon_44x44_2x_40mm.png | Bin 0 -> 1756 bytes .../AppIcon.appiconset/icon_46x46_2x_41mm.png | Bin 0 -> 1800 bytes .../AppIcon.appiconset/icon_50x50_2x_44mm.png | Bin 0 -> 2068 bytes .../AppIcon.appiconset/icon_51x51_2x_45mm.png | Bin 0 -> 1846 bytes .../AppIcon.appiconset/icon_54x54_2x_49mm.png | Bin 0 -> 1987 bytes .../AppIcon.appiconset/icon_86x86_2x.png | Bin 0 -> 2891 bytes .../AppIcon.appiconset/icon_98x98_2x.png | Bin 0 -> 3680 bytes .../example/watchos/Runner/Info.plist | 28 + .../flutter_local_notifications_watchos.dart | 443 ++++++++++++++ .../lib/src/backend.dart | 175 ++++++ .../pubspec.yaml | 62 ++ ...tter_local_notifications_watchos_test.dart | 368 +++++++++++ .../flutter_local_notifications_watchos_ffi.h | 103 ++++ .../flutter_local_notifications_watchos_ffi.m | 576 ++++++++++++++++++ .../watchos/Package.swift | 36 ++ tool/upstream_versions.yaml | 1 + tool/words/forbidden_words_allow.txt | 1 + 50 files changed, 3225 insertions(+) create mode 100644 packages/flutter_local_notifications_watchos/.gitignore create mode 100644 packages/flutter_local_notifications_watchos/CHANGELOG.md create mode 100644 packages/flutter_local_notifications_watchos/LICENSE create mode 100644 packages/flutter_local_notifications_watchos/PORTING_REPORT.md create mode 100644 packages/flutter_local_notifications_watchos/README.md create mode 100644 packages/flutter_local_notifications_watchos/analysis_options.yaml create mode 100644 packages/flutter_local_notifications_watchos/example/.gitignore create mode 100644 packages/flutter_local_notifications_watchos/example/README.md create mode 100644 packages/flutter_local_notifications_watchos/example/analysis_options.yaml create mode 100644 packages/flutter_local_notifications_watchos/example/integration_test/flutter_local_notifications_test.dart create mode 100644 packages/flutter_local_notifications_watchos/example/lib/main.dart create mode 100644 packages/flutter_local_notifications_watchos/example/pubspec.yaml create mode 100644 packages/flutter_local_notifications_watchos/example/test_driver/integration_test.dart create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Flutter/.gitignore create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/HostApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/HostApp/Assets.xcassets/Contents.json create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/HostApp/Info.plist create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner.xcodeproj/project.pbxproj create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/flutter_local_notifications_example.xcscheme create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/App.swift create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_1024.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_108x108_2x_44mm.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_117x117_2x_45mm.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_129x129_2x_49mm.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_24x24_2x_38mm.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_27.5x27.5_2x_42mm.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_29x29_2x_companion.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_29x29_3x_companion.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_33x33_2x_45mm.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_40x40_2x_38mm.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_44x44_2x_40mm.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_46x46_2x_41mm.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_50x50_2x_44mm.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_51x51_2x_45mm.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_54x54_2x_49mm.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_86x86_2x.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_98x98_2x.png create mode 100644 packages/flutter_local_notifications_watchos/example/watchos/Runner/Info.plist create mode 100644 packages/flutter_local_notifications_watchos/lib/flutter_local_notifications_watchos.dart create mode 100644 packages/flutter_local_notifications_watchos/lib/src/backend.dart create mode 100644 packages/flutter_local_notifications_watchos/pubspec.yaml create mode 100644 packages/flutter_local_notifications_watchos/test/flutter_local_notifications_watchos_test.dart create mode 100644 packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.h create mode 100644 packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m create mode 100644 packages/flutter_local_notifications_watchos/watchos/Package.swift diff --git a/README.md b/README.md index bf62315..3df590b 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ at the time; the last full run on the Simulator is under | [`audioplayers_watchos`](packages/audioplayers_watchos) [![pub](https://img.shields.io/pub/v/audioplayers_watchos.svg)](https://pub.dev/packages/audioplayers_watchos) | [`audioplayers`](https://pub.dev/packages/audioplayers) | AVFoundation (`AVPlayer`) | | [`in_app_purchase_watchos`](packages/in_app_purchase_watchos) [![pub](https://img.shields.io/pub/v/in_app_purchase_watchos.svg)](https://pub.dev/packages/in_app_purchase_watchos) | [`in_app_purchase`](https://pub.dev/packages/in_app_purchase) | StoreKit (`SKProductsRequest`, `SKPaymentQueue`) | | [`games_services_watchos`](packages/games_services_watchos) [![pub](https://img.shields.io/pub/v/games_services_watchos.svg)](https://pub.dev/packages/games_services_watchos) | [`games_services`](https://pub.dev/packages/games_services) | GameKit (`GKLeaderboard`, `GKLocalPlayer`) — leaderboards only [†](#games-services-status) | +| [`flutter_local_notifications_watchos`](packages/flutter_local_notifications_watchos) | [`flutter_local_notifications`](https://pub.dev/packages/flutter_local_notifications) | UserNotifications (`UNUserNotificationCenter`), taps through the host's application delegate | | [`firebase_core_watchos`](packages/firebase_core_watchos) [![pub](https://img.shields.io/pub/v/firebase_core_watchos.svg)](https://pub.dev/packages/firebase_core_watchos) | [`firebase_core`](https://pub.dev/packages/firebase_core) | Firebase Apple SDK (`FirebaseCore`) | | [`firebase_auth_watchos`](packages/firebase_auth_watchos) [![pub](https://img.shields.io/pub/v/firebase_auth_watchos.svg)](https://pub.dev/packages/firebase_auth_watchos) | [`firebase_auth`](https://pub.dev/packages/firebase_auth) | Firebase Apple SDK (`FirebaseAuth`) | | [`firebase_storage_watchos`](packages/firebase_storage_watchos) [![pub](https://img.shields.io/pub/v/firebase_storage_watchos.svg)](https://pub.dev/packages/firebase_storage_watchos) | [`firebase_storage`](https://pub.dev/packages/firebase_storage) | Firebase Apple SDK (`FirebaseStorage`) | diff --git a/packages/flutter_local_notifications_watchos/.gitignore b/packages/flutter_local_notifications_watchos/.gitignore new file mode 100644 index 0000000..3cd27a4 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/.gitignore @@ -0,0 +1,23 @@ +# Dart / Flutter +.dart_tool/ +build/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub/ + +# Written into the package by `flutter-watchos test` (GeneratedPluginRegistrant.swift) +watchos/Flutter/ + +# Xcode / SwiftPM (per-user, generated when watchos/Package.swift is opened) +**/.swiftpm/ +**/.build/ +**/xcuserdata/ + +# IDE +.idea/ +.vscode/ +*.iml + +# macOS +.DS_Store diff --git a/packages/flutter_local_notifications_watchos/CHANGELOG.md b/packages/flutter_local_notifications_watchos/CHANGELOG.md new file mode 100644 index 0000000..8020e6e --- /dev/null +++ b/packages/flutter_local_notifications_watchos/CHANGELOG.md @@ -0,0 +1,18 @@ +## 0.0.1 + +First version, staged: checked on the watchOS 27.0 Simulator, not yet on a +physical watch. + +* `FlutterLocalNotificationsWatchos` extends `IOSFlutterLocalNotificationsPlugin`, + which flutter_local_notifications requires on iOS-reporting platforms, and + answers every method through UNUserNotificationCenter over dart:ffi: + initialize with categories, permissions, show, zonedSchedule (calendar + triggers in the date's own time zone), periodicallyShow and + periodicallyShowWithDuration, cancel, pending and delivered lists, and the + launch tap. +* Taps reach `onDidReceiveNotificationResponse` through the flutter-watchos + host's application delegate, observed from a load-time constructor so the + launch tap is not missed. +* The example's `lib/` is a small watch demo, because the upstream example + needs `flutter_timezone`; its official integration test is upstream's, + unchanged. diff --git a/packages/flutter_local_notifications_watchos/LICENSE b/packages/flutter_local_notifications_watchos/LICENSE new file mode 100644 index 0000000..05260aa --- /dev/null +++ b/packages/flutter_local_notifications_watchos/LICENSE @@ -0,0 +1,27 @@ +Copyright 2026 The FlutterWatch Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of FlutterWatch nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_local_notifications_watchos/PORTING_REPORT.md b/packages/flutter_local_notifications_watchos/PORTING_REPORT.md new file mode 100644 index 0000000..4906204 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/PORTING_REPORT.md @@ -0,0 +1,73 @@ +# flutter_local_notifications_watchos — porting report + +## Verification status + +| Aspect | Result | +|---|---| +| Implementation | ✅ Working (UserNotifications FFI) | +| watchOS capability | Partial — named sounds, typed action input and the background-isolate callback are dropped (see the README). Not yet checked on a physical watch | +| Host unit tests (`flutter test`) | ✅ pass, through the app-facing `FlutterLocalNotificationsPlugin` with the target platform set to iOS | +| Upstream integration test | ✅ passes verbatim on the watchOS 27.0 Simulator, after the permission prompt is answered | +| Internal unified demo | ○ not included yet | + +Marking: ✅ full / passes · ◐ partial — reason given · ○ not applicable (no upstream test) · ✗ unsupported on watchOS. + +On the Simulator the example also scheduled a notification ten seconds out, +listed it as pending, showed it on the watch face with the app in the +background, and delivered the tap with its id and payload. A notification +shown while the app is in front was added but not seen on screen; that path +(the host's `willPresent` and the presentation flags) is not yet checked. + +Generated by `flutter-watchos plugin port` on 2026-10-21, then finished by +hand. + +Source: `flutter_local_notifications` 22.3.2 +Base platform: ios (Objective-C) + +## How this differs from the iOS implementation + +flutter_local_notifications is not fully federated: its iOS implementation, +`IOSFlutterLocalNotificationsPlugin`, lives in the app-facing package, and +the app-facing calls check that the platform instance *is* one before using +it. The watch reports iOS as its target platform, so this package's class +extends `IOSFlutterLocalNotificationsPlugin` and overrides every method that +would use the method channel. That makes it depend on +`flutter_local_notifications` itself, which does not depend back on it. + +The iOS plugin is the notification-center delegate. On the watch the +flutter-watchos host is (`FlutterWatchOSAppDelegate`), and it rebroadcasts +"will present" and "did receive response" as NSNotifications. The native +file observes them from a load-time constructor, so the tap that launched +the app, which the host does not keep, is caught before Dart runs; a tap +that arrives before `initialize` is the launch tap, later ones queue for +Dart. Each notification carries its id, payload and presentation flags in +`userInfo` under keys of this package's own, and callbacks for +notifications without them (pushes, other plugins) are left alone. + +The port tool flagged `UIApplication` (badge clearing and opening settings +on iOS); neither exists on watchOS. + +## Example + +flutter_local_notifications' own example app is a phone demo that needs +`flutter_timezone`, which has no watchOS implementation, so +`example/lib/main.dart` is a small watch demo instead. +`example/integration_test/flutter_local_notifications_test.dart` is +upstream's test, unchanged. + +## Interface coverage + +Every member of `IOSFlutterLocalNotificationsPlugin` and the +`FlutterLocalNotificationsPlatform` methods it inherits, audited by hand. +✅ implemented · ◐ implemented with a limit · ✗ fails on watchOS. + +| Member | watchOS | +|---|---| +| `initialize` | ◐ `onDidReceiveBackgroundNotificationResponse` is not called | +| `requestPermissions` | ✅ `carPlay` and `providesAppNotificationSettings` have nothing to ask for | +| `checkPermissions` | ◐ `isBadgeEnabled` is always false | +| `show` / `zonedSchedule` / `periodicallyShow` / `periodicallyShowWithDuration` | ◐ the default sound only | +| `cancel` / `cancelAll` / `cancelAllPendingNotifications` | ✅ | +| `pendingNotificationRequests` / `getActiveNotifications` | ✅ | +| `getNotificationAppLaunchDetails` | ✅ | +| `openAppNotificationSettings` | ✗ returns false | diff --git a/packages/flutter_local_notifications_watchos/README.md b/packages/flutter_local_notifications_watchos/README.md new file mode 100644 index 0000000..7c7bd81 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/README.md @@ -0,0 +1,71 @@ +# flutter_local_notifications_watchos + +The watchOS implementation of [`flutter_local_notifications`](https://pub.dev/packages/flutter_local_notifications). + +Notifications go through **UserNotifications** (`UNUserNotificationCenter`) +over dart:ffi: show one now, schedule one for a date (once or repeating), or +repeat one every interval; list, cancel, and get taps back. + +> Scaffolded by [`flutter-watchos plugin port`](https://github.com/flutterwatch/flutter-watchos) +> from `flutter_local_notifications`, then implemented and verified by hand. + +## Usage + +This is a federated plugin implementation. Apps that already depend on +`flutter_local_notifications` and target watchOS only need to add this +package alongside it: + +```yaml +dependencies: + flutter_local_notifications: ^22.3.2 + flutter_local_notifications_watchos: ^0.0.1 +``` + +The plugin registers automatically via Flutter's federated registry — no +explicit imports required from app code. The watch reports itself as iOS, +so pass `iOS:` settings and details, as on an iPhone: + +```dart +await FlutterLocalNotificationsPlugin().initialize( + settings: const InitializationSettings(iOS: DarwinInitializationSettings()), + onDidReceiveNotificationResponse: (NotificationResponse r) => print(r.payload), +); +``` + +The app must install the flutter-watchos host's application delegate, +which owns the notification-center delegate and forwards its callbacks; +`flutter-watchos create` does (`@WKApplicationDelegateAdaptor(FlutterWatchOSAppDelegate.self)` +in `watchos/Runner/App.swift`), and the CLI warns at build time when it is +missing. + +## Behaviour on watchOS + +| Member | watchOS | +|---|---| +| `initialize` | supported: categories with their actions, default presentation, the permissions the settings request | +| `requestPermissions` / `checkPermissions` | supported; `isBadgeEnabled` is always false: watchOS has no badge setting | +| `show` | supported | +| `zonedSchedule` | supported: a calendar trigger in the date's own time zone, repeating by `matchDateTimeComponents` | +| `periodicallyShow` / `periodicallyShowWithDuration` | supported: a repeating interval trigger | +| `cancel` / `cancelAll` / `cancelAllPendingNotifications` | supported | +| `pendingNotificationRequests` / `getActiveNotifications` | supported, for this package's notifications | +| `getNotificationAppLaunchDetails` | supported: the tap that launched the app | +| `onDidReceiveNotificationResponse` | taps and action taps, with the notification's id and payload | + +Of `DarwinNotificationDetails`, these reach the notification: the five +`present…` flags (for a notification arriving while the app is in front), +`subtitle`, `threadIdentifier`, `categoryIdentifier`, `badgeNumber`, +`interruptionLevel` and `attachments`. + +## Not supported on watchOS + +| Member | On watchOS | Why | +|---|---|---| +| `DarwinNotificationDetails.sound` | the default sound plays instead | watchOS has no `UNNotificationSound soundNamed:` | +| `DarwinNotificationAction.text` | a plain action; `NotificationResponse.input` is null | the host's delegate forwards the action, not the typed text | +| `onDidReceiveBackgroundNotificationResponse` | not called; every tap reaches `onDidReceiveNotificationResponse` | the watch app handles taps in its own isolate | +| `openAppNotificationSettings` | returns false | watchOS has no settings page an app can open | + +## License + +The FlutterWatch Authors under a BSD-3-Clause license. See `LICENSE` for the full text. diff --git a/packages/flutter_local_notifications_watchos/analysis_options.yaml b/packages/flutter_local_notifications_watchos/analysis_options.yaml new file mode 100644 index 0000000..1d83d30 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/analysis_options.yaml @@ -0,0 +1,19 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + public_member_api_docs: true diff --git a/packages/flutter_local_notifications_watchos/example/.gitignore b/packages/flutter_local_notifications_watchos/example/.gitignore new file mode 100644 index 0000000..3b47c00 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/.gitignore @@ -0,0 +1,14 @@ +.dart_tool/ +.packages +build/ +.flutter-plugins +.flutter-plugins-dependencies +*.iml +.idea/ +.DS_Store + +# watchOS / CocoaPods +watchos/Pods/ +watchos/Podfile.lock +watchos/.symlinks/ +watchos/Flutter/Flutter.framework diff --git a/packages/flutter_local_notifications_watchos/example/README.md b/packages/flutter_local_notifications_watchos/example/README.md new file mode 100644 index 0000000..0aeeca4 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/README.md @@ -0,0 +1,13 @@ +# flutter_local_notifications_example + +A watchOS-only example: show a notification now, schedule one ten seconds +out, and cancel them. Run it on an Apple Watch simulator with: + +```sh +flutter-watchos run +``` + +`integration_test/flutter_local_notifications_test.dart` is +flutter_local_notifications' official integration test, unchanged. Its +first case asks for notification permission; answer the prompt on the watch +the first time it runs. diff --git a/packages/flutter_local_notifications_watchos/example/analysis_options.yaml b/packages/flutter_local_notifications_watchos/example/analysis_options.yaml new file mode 100644 index 0000000..801d885 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/analysis_options.yaml @@ -0,0 +1,4 @@ +analyzer: + exclude: + - build/** +include: package:flutter_lints/flutter.yaml diff --git a/packages/flutter_local_notifications_watchos/example/integration_test/flutter_local_notifications_test.dart b/packages/flutter_local_notifications_watchos/example/integration_test/flutter_local_notifications_test.dart new file mode 100644 index 0000000..fc112bb --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/integration_test/flutter_local_notifications_test.dart @@ -0,0 +1,154 @@ +import 'dart:io'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + const AndroidInitializationSettings initializationSettingsAndroid = + AndroidInitializationSettings('app_icon'); + const IOSInitializationSettings initializationSettingsIOS = + IOSInitializationSettings(); + const DarwinInitializationSettings initializationSettingsMacOS = + DarwinInitializationSettings(); + final LinuxInitializationSettings initializationSettingsLinux = + LinuxInitializationSettings( + defaultActionName: 'Open notification', + defaultIcon: AssetsLinuxIcon('icons/app_icon.png'), + ); + final InitializationSettings initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + iOS: initializationSettingsIOS, + macOS: initializationSettingsMacOS, + linux: initializationSettingsLinux, + ); + late FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin; + group('initialize()', () { + setUpAll(() async { + flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + }); + testWidgets('can initialise', (WidgetTester tester) async { + final bool? initialised = await flutterLocalNotificationsPlugin + .initialize(settings: initializationSettings); + expect(initialised, isTrue); + }); + + testWidgets( + 'initialize with settings equal to null for the targeting platform ' + 'should throw an ArgumentError', + (WidgetTester tester) async { + const InitializationSettings initializationSettings = + InitializationSettings(); + late Matcher errorMessageMatcher; + if (Platform.isAndroid) { + errorMessageMatcher = equals( + 'Android settings must be set when targeting Android platform.', + ); + } else if (Platform.isIOS) { + errorMessageMatcher = equals( + 'iOS settings must be set when targeting iOS platform.', + ); + } else if (Platform.isLinux) { + equals('Linux settings must be set when targeting Linux platform.'); + } else if (Platform.isMacOS) { + errorMessageMatcher = equals( + 'macOS settings must be set when targeting macOS platform.', + ); + } else { + errorMessageMatcher = anything; + } + expect( + () async => await flutterLocalNotificationsPlugin.initialize( + settings: initializationSettings, + ), + throwsA( + isArgumentError.having( + (ArgumentError e) => e.message, + 'message', + errorMessageMatcher, + ), + ), + ); + }, + ); + }); + group('resolvePlatformSpecificImplementation()', () { + setUpAll(() async { + flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + await flutterLocalNotificationsPlugin.initialize( + settings: initializationSettings, + ); + }); + + if (Platform.isIOS) { + testWidgets('Can resolve iOS plugin implementation when running on iOS', ( + WidgetTester tester, + ) async { + expect( + flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >(), + isA(), + ); + }); + } + + if (Platform.isAndroid) { + testWidgets( + 'Can resolve Android plugin implementation when running on Android', + (WidgetTester tester) async { + expect( + flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(), + isA(), + ); + }, + ); + } + + if (Platform.isIOS) { + testWidgets( + 'Returns null trying to resolve Android plugin implementation when ' + 'running on iOS', + (WidgetTester tester) async { + expect( + flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(), + isNull, + ); + }, + ); + } + if (Platform.isAndroid) { + testWidgets( + 'Returns null trying to resolve iOS plugin implementation when ' + 'running on Android', + (WidgetTester tester) async { + expect( + flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >(), + isNull, + ); + }, + ); + } + + testWidgets('Throws argument error requesting base class type', ( + WidgetTester tester, + ) async { + expect( + () => flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation(), + throwsArgumentError, + ); + }); + }); +} diff --git a/packages/flutter_local_notifications_watchos/example/lib/main.dart b/packages/flutter_local_notifications_watchos/example/lib/main.dart new file mode 100644 index 0000000..b753b6a --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/lib/main.dart @@ -0,0 +1,123 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// flutter_local_notifications' own example app is built for phones and needs +// flutter_timezone, which has no watchOS implementation, so this +// watch-sized demo stands in for it. The official integration test in +// integration_test/ is upstream's, unchanged. + +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:timezone/timezone.dart' as tz; + +final FlutterLocalNotificationsPlugin _notifications = + FlutterLocalNotificationsPlugin(); + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + tzdata.initializeTimeZones(); + runApp(const NotificationsExampleApp()); +} + +/// A watch app that shows and schedules notifications. +class NotificationsExampleApp extends StatelessWidget { + /// The example app. + const NotificationsExampleApp({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(useMaterial3: true), + home: const NotificationsPage(), + ); +} + +/// Show, schedule, list and cancel. +class NotificationsPage extends StatefulWidget { + /// The one page. + const NotificationsPage({super.key}); + + @override + State createState() => _NotificationsPageState(); +} + +class _NotificationsPageState extends State { + String _status = 'starting'; + int _next = 0; + + @override + void initState() { + super.initState(); + _start(); + } + + Future _start() async { + final NotificationAppLaunchDetails? launch = + await _notifications.getNotificationAppLaunchDetails(); + final bool? granted = await _notifications.initialize( + settings: const InitializationSettings( + iOS: DarwinInitializationSettings(), + ), + onDidReceiveNotificationResponse: (NotificationResponse r) { + setState(() => _status = 'tapped #${r.id} ${r.payload ?? ''}'); + }, + ); + setState(() { + _status = launch?.didNotificationLaunchApp == true + ? 'launched by #${launch!.notificationResponse?.id}' + : granted == true + ? 'allowed' + : 'not allowed'; + }); + } + + Future _show() async { + final int id = _next++; + await _notifications.show( + id: id, + title: 'Lap ${id + 1}', + body: 'Pace 5:30 /km', + payload: 'lap-$id', + ); + setState(() => _status = 'shown #$id'); + } + + Future _schedule() async { + final int id = _next++; + await _notifications.zonedSchedule( + id: id, + title: 'Time to stretch', + body: 'Scheduled 10 seconds ago', + scheduledDate: tz.TZDateTime.now(tz.UTC).add(const Duration(seconds: 10)), + notificationDetails: const NotificationDetails(), + androidScheduleMode: AndroidScheduleMode.inexact, + payload: 'stretch-$id', + ); + final int pending = + (await _notifications.pendingNotificationRequests()).length; + setState(() => _status = 'scheduled #$id, $pending pending'); + } + + Future _cancelAll() async { + await _notifications.cancelAll(); + setState(() => _status = 'cancelled'); + } + + @override + Widget build(BuildContext context) => Scaffold( + body: ListView( + padding: const EdgeInsets.fromLTRB(12, 28, 12, 24), + children: [ + Text(_status, + style: + const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + FilledButton(onPressed: _show, child: const Text('Show now')), + FilledButton.tonal( + onPressed: _schedule, child: const Text('In 10 seconds')), + TextButton(onPressed: _cancelAll, child: const Text('Cancel all')), + ], + ), + ); +} diff --git a/packages/flutter_local_notifications_watchos/example/pubspec.yaml b/packages/flutter_local_notifications_watchos/example/pubspec.yaml new file mode 100644 index 0000000..3f2a268 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/pubspec.yaml @@ -0,0 +1,25 @@ +name: flutter_local_notifications_example +description: "watchOS example for flutter_local_notifications_watchos." +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ">=3.1.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_local_notifications: ^22.3.2 + flutter_local_notifications_watchos: + path: ../ + timezone: ^0.11.0 + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true diff --git a/packages/flutter_local_notifications_watchos/example/test_driver/integration_test.dart b/packages/flutter_local_notifications_watchos/example/test_driver/integration_test.dart new file mode 100644 index 0000000..2acb97e --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/test_driver/integration_test.dart @@ -0,0 +1,4 @@ +// Generated by `flutter-watchos plugin port --include-example`. +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Flutter/.gitignore b/packages/flutter_local_notifications_watchos/example/watchos/Flutter/.gitignore new file mode 100644 index 0000000..196f947 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/watchos/Flutter/.gitignore @@ -0,0 +1,3 @@ +# Build artifacts placed here by `flutter-watchos build` — never commit. +* +!.gitignore diff --git a/packages/flutter_local_notifications_watchos/example/watchos/HostApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/flutter_local_notifications_watchos/example/watchos/HostApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/watchos/HostApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/flutter_local_notifications_watchos/example/watchos/HostApp/Assets.xcassets/Contents.json b/packages/flutter_local_notifications_watchos/example/watchos/HostApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/watchos/HostApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/flutter_local_notifications_watchos/example/watchos/HostApp/Info.plist b/packages/flutter_local_notifications_watchos/example/watchos/HostApp/Info.plist new file mode 100644 index 0000000..fb01b05 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/watchos/HostApp/Info.plist @@ -0,0 +1,48 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Flutter Local Notifications Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + ITSWatchOnlyContainer + + LSApplicationLaunchProhibited + + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner.xcodeproj/project.pbxproj b/packages/flutter_local_notifications_watchos/example/watchos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6a7e62f --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/watchos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,438 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + AB0000000000000000000001 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000001 /* App.swift */; }; + AB0000000000000000000003 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000003 /* Flutter.framework */; }; + AB0000000000000000000004 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000003 /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + AB0000000000000000000005 /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000004 /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + AB0000000000000000000006 /* flutter_assets in Resources */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000005 /* flutter_assets */; }; + AB0000000000000000000007 /* icudtl.dat in Resources */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000006 /* icudtl.dat */; }; + AB0000000000000000000008 /* vm_isolate_snapshot.bin in Resources */ = {isa = PBXBuildFile; fileRef = AF000000000000000000000A /* vm_isolate_snapshot.bin */; }; + AB0000000000000000000009 /* isolate_snapshot.bin in Resources */ = {isa = PBXBuildFile; fileRef = AF000000000000000000000B /* isolate_snapshot.bin */; }; + AB0000000000000000000010 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF000000000000000000000C /* Assets.xcassets */; }; + CB0000000000000000000002 /* Runner.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000007 /* Runner.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + CB0000000000000000000003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CF0000000000000000000004 /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + CA0000000000000000000004 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AA0000000000000000000002 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AA0000000000000000000001; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + AC0000000000000000000001 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + AB0000000000000000000004 /* Flutter.framework in Embed Frameworks */, + AB0000000000000000000005 /* App.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + CD0000000000000000000011 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + CB0000000000000000000002 /* Runner.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0B66C03D2FE326E4002C6E54 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AF0000000000000000000001 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + AF0000000000000000000003 /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Flutter.framework; sourceTree = ""; }; + AF0000000000000000000004 /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = App.framework; sourceTree = ""; }; + AF0000000000000000000005 /* flutter_assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = flutter_assets; sourceTree = ""; }; + AF0000000000000000000006 /* icudtl.dat */ = {isa = PBXFileReference; lastKnownFileType = file; path = icudtl.dat; sourceTree = ""; }; + AF0000000000000000000007 /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AF000000000000000000000A /* vm_isolate_snapshot.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; path = vm_isolate_snapshot.bin; sourceTree = ""; }; + AF000000000000000000000B /* isolate_snapshot.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; path = isolate_snapshot.bin; sourceTree = ""; }; + AF000000000000000000000C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AF000000000000000000000D /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + AF000000000000000000000E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + CF0000000000000000000001 /* flutter_local_notifications_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flutter_local_notifications_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CF0000000000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CF0000000000000000000004 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AD0000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AB0000000000000000000003 /* Flutter.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AE0000000000000000000001 = { + isa = PBXGroup; + children = ( + AE0000000000000000000002 /* Runner */, + CE0000000000000000000001 /* HostApp */, + AE0000000000000000000004 /* Flutter */, + AE0000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + AE0000000000000000000002 /* Runner */ = { + isa = PBXGroup; + children = ( + AF0000000000000000000001 /* App.swift */, + AF000000000000000000000C /* Assets.xcassets */, + 0B66C03D2FE326E4002C6E54 /* Info.plist */, + ); + path = Runner; + sourceTree = ""; + }; + AE0000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + AF0000000000000000000007 /* Runner.app */, + CF0000000000000000000001 /* flutter_local_notifications_example.app */, + ); + name = Products; + sourceTree = ""; + }; + AE0000000000000000000004 /* Flutter */ = { + isa = PBXGroup; + children = ( + AF0000000000000000000003 /* Flutter.framework */, + AF0000000000000000000004 /* App.framework */, + AF0000000000000000000005 /* flutter_assets */, + AF0000000000000000000006 /* icudtl.dat */, + AF000000000000000000000A /* vm_isolate_snapshot.bin */, + AF000000000000000000000B /* isolate_snapshot.bin */, + AF000000000000000000000D /* Debug.xcconfig */, + AF000000000000000000000E /* Release.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + CE0000000000000000000001 /* HostApp */ = { + isa = PBXGroup; + children = ( + CF0000000000000000000004 /* Assets.xcassets */, + CF0000000000000000000003 /* Info.plist */, + ); + path = HostApp; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AA0000000000000000000001 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = BA0000000000000000000002 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + AD0000000000000000000002 /* Sources */, + AD0000000000000000000001 /* Frameworks */, + AD0000000000000000000003 /* Resources */, + AC0000000000000000000001 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = AF0000000000000000000007 /* Runner.app */; + productType = "com.apple.product-type.application"; + }; + CA0000000000000000000001 /* HostApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = CA0000000000000000000003 /* Build configuration list for PBXNativeTarget "HostApp" */; + buildPhases = ( + CD0000000000000000000012 /* Resources */, + CD0000000000000000000011 /* Embed Watch Content */, + ); + buildRules = ( + ); + dependencies = ( + CA0000000000000000000002 /* PBXTargetDependency */, + ); + name = HostApp; + productName = flutter_local_notifications_example; + productReference = CF0000000000000000000001 /* flutter_local_notifications_example.app */; + productType = "com.apple.product-type.application.watchapp2-container"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AA0000000000000000000002 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 2700; + TargetAttributes = { + AA0000000000000000000001 = { + CreatedOnToolsVersion = 15.0; + }; + CA0000000000000000000001 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = BA0000000000000000000001 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AE0000000000000000000001; + productRefGroup = AE0000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AA0000000000000000000001 /* Runner */, + CA0000000000000000000001 /* HostApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AD0000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AB0000000000000000000006 /* flutter_assets in Resources */, + AB0000000000000000000007 /* icudtl.dat in Resources */, + AB0000000000000000000008 /* vm_isolate_snapshot.bin in Resources */, + AB0000000000000000000009 /* isolate_snapshot.bin in Resources */, + AB0000000000000000000010 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CD0000000000000000000012 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CB0000000000000000000003 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AD0000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AB0000000000000000000001 /* App.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + CA0000000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AA0000000000000000000001 /* Runner */; + targetProxy = CA0000000000000000000004 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + BB0000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + ENABLE_BITCODE = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + BB0000000000000000000002 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF000000000000000000000D /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Flutter"; + GENERATE_INFOPLIST_FILE = NO; + HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/Flutter"; + INFOPLIST_FILE = Runner/Info.plist; + INSTALL_PATH = "$(LOCAL_APPS_DIR)"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Flutter"; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.flutterLocalNotificationsExample.watchkitapp"; + PRODUCT_NAME = Runner; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + BB0000000000000000000003 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + ENABLE_BITCODE = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = s; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + BB0000000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF000000000000000000000E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/Flutter"; + GENERATE_INFOPLIST_FILE = NO; + HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/Flutter"; + INFOPLIST_FILE = Runner/Info.plist; + INSTALL_PATH = "$(LOCAL_APPS_DIR)"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Flutter"; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.flutterLocalNotificationsExample.watchkitapp"; + PRODUCT_NAME = Runner; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; + CC0000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF000000000000000000000D /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + ENABLE_BITCODE = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = HostApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.flutterLocalNotificationsExample"; + PRODUCT_NAME = flutter_local_notifications_example; + SDKROOT = iphoneos; + SKIP_INSTALL = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CC0000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF000000000000000000000E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + ENABLE_BITCODE = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = HostApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.flutterLocalNotificationsExample"; + PRODUCT_NAME = flutter_local_notifications_example; + SDKROOT = iphoneos; + SKIP_INSTALL = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + BA0000000000000000000001 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BB0000000000000000000001 /* Debug */, + BB0000000000000000000003 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + BA0000000000000000000002 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BB0000000000000000000002 /* Debug */, + BB0000000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CA0000000000000000000003 /* Build configuration list for PBXNativeTarget "HostApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CC0000000000000000000001 /* Debug */, + CC0000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AA0000000000000000000002 /* Project object */; +} diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/flutter_local_notifications_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..1099389 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/flutter_local_notifications_example.xcscheme b/packages/flutter_local_notifications_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/flutter_local_notifications_example.xcscheme new file mode 100644 index 0000000..7572c7c --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/watchos/Runner.xcodeproj/xcshareddata/xcschemes/flutter_local_notifications_example.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner/App.swift b/packages/flutter_local_notifications_watchos/example/watchos/Runner/App.swift new file mode 100644 index 0000000..a0f2f17 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/watchos/Runner/App.swift @@ -0,0 +1,57 @@ +import SwiftUI + +#if !arch(arm64_32) +// The Flutter host (frame display, input, native overlays) — compiled by +// flutter-watchos into watchos/Flutter/, like Flutter.framework itself. +import FlutterWatchOS +#endif + +@main +struct FlutterLocalNotificationsExampleApp: App { + #if !arch(arm64_32) + // Remote-notification plumbing (APNs device token, notification payloads) + // for plugins that need it, e.g. firebase_messaging. Safe to keep even if + // no plugin uses notifications. + @WKApplicationDelegateAdaptor(FlutterWatchOSAppDelegate.self) + private var flutterAppDelegate + + init() { + // Native platform views: register a SwiftUI factory for every + // `viewType` your Dart code embeds with WatchPlatformView + // (package:flutter_watchos). Example: + // + // WatchPlatformViewRegistry.register("my-gauge") { params in + // AnyView(MyGaugeView(params: params)) + // } + } + #endif + + var body: some Scene { + WindowGroup { + #if arch(arm64_32) + // 32-bit watches (Series 4–8 / SE) are not supported. + UnsupportedDeviceView() + #else + // Holds a black launch placeholder over the screen until Flutter's + // first frame is ready, then fades it out. Supply your own to + // replace it: `FlutterHostView { MyLaunchScreen() }`. + FlutterHostView() + #endif + } + } +} + +// Shown on 32-bit watches (Series 4–8 / SE), which this app does not support. +struct UnsupportedDeviceView: View { + var body: some View { + VStack(spacing: 8) { + Text("Flutter Local Notifications Example") + .font(.headline) + Text("Requires Apple Watch Series 9 or later.") + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .padding() + } +} diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..287d73c --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,140 @@ +{ + "images" : [ + { + "filename" : "icon_24x24_2x_38mm.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "24x24", + "subtype" : "38mm" + }, + { + "filename" : "icon_27.5x27.5_2x_42mm.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "27.5x27.5", + "subtype" : "42mm" + }, + { + "filename" : "icon_29x29_2x_companion.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "icon_29x29_3x_companion.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "icon_33x33_2x_45mm.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "33x33", + "subtype" : "45mm" + }, + { + "filename" : "icon_40x40_2x_38mm.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "40x40", + "subtype" : "38mm" + }, + { + "filename" : "icon_44x44_2x_40mm.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "44x44", + "subtype" : "40mm" + }, + { + "filename" : "icon_46x46_2x_41mm.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "46x46", + "subtype" : "41mm" + }, + { + "filename" : "icon_50x50_2x_44mm.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "50x50", + "subtype" : "44mm" + }, + { + "filename" : "icon_51x51_2x_45mm.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "51x51", + "subtype" : "45mm" + }, + { + "filename" : "icon_54x54_2x_49mm.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "54x54", + "subtype" : "49mm" + }, + { + "filename" : "icon_86x86_2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "86x86", + "subtype" : "38mm" + }, + { + "filename" : "icon_98x98_2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "98x98", + "subtype" : "42mm" + }, + { + "filename" : "icon_108x108_2x_44mm.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "108x108", + "subtype" : "44mm" + }, + { + "filename" : "icon_117x117_2x_45mm.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "117x117", + "subtype" : "45mm" + }, + { + "filename" : "icon_129x129_2x_49mm.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" + }, + { + "filename" : "icon_1024.png", + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..819d623869aef43d1258129b6a7881eaf044d358 GIT binary patch literal 13949 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 R!dgH-&h(veeOkzl{{x@0>-_)# literal 0 HcmV?d00001 diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_108x108_2x_44mm.png b/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_108x108_2x_44mm.png new file mode 100644 index 0000000000000000000000000000000000000000..7b3140c5bd31fb73521cce7311c79f4fe6c0b3a3 GIT binary patch literal 3890 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 XnmZWr70ryY00000NkvXXu0mjfMqFv` literal 0 HcmV?d00001 diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_29x29_2x_companion.png b/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_29x29_2x_companion.png new file mode 100644 index 0000000000000000000000000000000000000000..796aa3f3f41a0b817d86991185799927570009cc GIT binary patch literal 1214 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 JPDHLkV1lby=yd=9 literal 0 HcmV?d00001 diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_44x44_2x_40mm.png b/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_44x44_2x_40mm.png new file mode 100644 index 0000000000000000000000000000000000000000..ec54e61006487ba7a596c7653cd3df452b02e6ca GIT binary patch literal 1756 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 literal 0 HcmV?d00001 diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_54x54_2x_49mm.png b/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_54x54_2x_49mm.png new file mode 100644 index 0000000000000000000000000000000000000000..457d76ac599f48e8736a28139476fe7f4540f844 GIT binary patch literal 1987 zcmV;!2R!(RP) zPiz!b9LIm}y_wy%yZuKSdN78z7>gn#ni!hsLD7IFDi<#t{DW6dMo>>u)EK;I;=$-e zK_bS40|X3z4w69BQbO@i6iB2E7Vtpn(z5QhGxK}q&2*RTvTV1rZ|1#e=X)Sz={7r` z{r=wX_j~hu1L2%QtolUje1EJMq8!Cel%N=*1jP^~D26COF+>T9AxcmTQG#NK5)?y} zpctYA84xLP04WPal)|D(z8{lxiQEbC%mtb2$7+IndjR<)#T?fHC9>H~Hn<<%~ z)^#GvBPgOc?Rqc(g8DiU<$5qkf|@!Jr4kgGWNm;1b#x+1Cm75+sIL<-f&}$~h|&oL zxen?G5rZHoyB^dUB1V~@77#Jw1l4C}luIz}Rx{xeRP03b2wL)b5)}khIuWJ!#bM=Q zT_9o*QLKodnsCM-x;hn6tjviR&7MRfCt_rK5?vr-^y@*z5OL`e3<)AGX@X%S7F>#% zBvdEj63ryxIT4qE2ZMDYE)Nd|2odGp?bI)m1P&2{*jdn+pnQl}GWD?>JSac0U__=q z^mQUeXX-;!Ct|o$AN9|%5#1nSh*KYP6UU&Jj0jcKyfE-;VO<<4fxH7vOxpOj5fy?C z7e97!8o#`3*Avc$`+=YDVy|@@l*{4hA0l_Z(u@^GPf{c}Fk)qGF?#K9zE=>m_;X!s z>naUqw!QcKo8zDSX7tCZjjLX!HO*`_c#3md!Wjy|)vIH(WjoCCLJxk3D& zhNzX`6b@Vv1QXQ%w{>e4Hm;=aK5TlwfGDpMy-(1aCcD=s(uP>483}`+KMyjo>Hv@2-e%NP$Dk1Cq+JXIo9aM zTBbgV9^5%nuLs3C=y}k6g=pCJF_&Yv%dxozKi0B0BP)vMJlGXf6wBYsQh}!5|4r+kjv-aI9tO!*&Uto5XEHvv~)Sek-TD9t><93}hFFRpmIZ zC%HdCgTSyLICDM6Z{)e->olqGU@;fFuaF3B%Cbrvd2KyOk>HzWW^y((8=^TI%ZgB@ zn&slacedF*9Fuh6J?eP`9cW;4uS{7mmgU8L2Ms+K;3-qbUD`&od|<@7p2J65>8`ak z5F8R7Y$#a=iTkfDCUmF7`}o3raY&gYq@%iy%kl3!{JT+W`FLt-!xM`Z(dgwb3G31;O#Sa_`JDfQPb`4D6^LOQl zm`peQbMEZPeFyS35~BWrDxpH8MBHF{qMh#VPqrrDzs2u|b3`MRnixKN=k&?LLpQeU zK0aePln_+PF>EJNw=Z1NLicS*wwsIN=n>4EKXdKaQ9IR{=x9ALcxB6H$MdQ=D#*^v z<@iVo-PgYejvO69aCq?A(VwBYlTc>aw%OKrVsKPBM>VHRSqaA#HN>?bm`-QT{WgB& zAf!8iGTNzSqs>_NTHzeDua-1VLbZY$IPec%;fP$b`a9a+VsyV7W@61Zp zN8v}i0!QvRMsqsz`!C}M_L6jmAn1CqRJNnokBT2(zzTl+enYZ7S?)(cuq8ct`q=pX zuSjZz>p{nzASq|0){p9+0l~`Ry5Pqm!E1XzCrzyaoINZ4qztG5gq95>DfFXyh)~AS z^CL&2)g}1tZb&o;UPdD{t$@Zh@fTA0W2^F``f|T)ANQ{*d~Gm4Wj4+Hdid&|FQB8F zv^@anm5@k@AF33~Wikyqo5^kJ?>Y4T<_1&rg*4!{P$P&i$B|#k!YkjLy8OdlW7RsR zp-uQsEQRIyq>2nit=r*o)FPaVVgzi2z7+ZK#h%Xf&)BSm0g9z?F$BH!G0Zw#FDH7k zksFkpADh@TaQNobjL~R1&f*}3>_@$v=*ghOcJT4;juQhfceXUxIg2qrpEEQ+>Ie~B z6BRhVRN&~xM7AA9zSa~XRN!c9>qmVdLUjD7V`3?j?V!P^n`e&PoXVSxCi>VG=c0bh zXL2v}_Z<3QbAnMW>HI}BAVPuT@YuxWfg?93vS2d5XD}#aa!Q;{~H8ZHV~nN zB_@f?|jg6hoAt7@`El5G5#vC_ynq35p>~Pz(|3*Z)M6 Voim7*OhNzv002ovPDHLkV1k0`qHh2I literal 0 HcmV?d00001 diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_86x86_2x.png b/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_86x86_2x.png new file mode 100644 index 0000000000000000000000000000000000000000..28feaed7bc280a3673c14d0830dd2544e38736a8 GIT binary patch literal 2891 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 T`1_FiAsKM)j5)FBG>P(Ge{Mg_ literal 0 HcmV?d00001 diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_98x98_2x.png b/packages/flutter_local_notifications_watchos/example/watchos/Runner/Assets.xcassets/AppIcon.appiconset/icon_98x98_2x.png new file mode 100644 index 0000000000000000000000000000000000000000..3b901e585b3ba2f2eeb0d174f395a6c543d33248 GIT binary patch literal 3680 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 literal 0 HcmV?d00001 diff --git a/packages/flutter_local_notifications_watchos/example/watchos/Runner/Info.plist b/packages/flutter_local_notifications_watchos/example/watchos/Runner/Info.plist new file mode 100644 index 0000000..a3747f5 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/watchos/Runner/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Flutter Local Notifications Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + flutter_local_notifications_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + WKApplication + + WKWatchOnly + + + diff --git a/packages/flutter_local_notifications_watchos/lib/flutter_local_notifications_watchos.dart b/packages/flutter_local_notifications_watchos/lib/flutter_local_notifications_watchos.dart new file mode 100644 index 0000000..e815677 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/lib/flutter_local_notifications_watchos.dart @@ -0,0 +1,443 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// watchOS implementation of `flutter_local_notifications`, over dart:ffi. +// +// flutter_local_notifications routes every iOS call to +// `IOSFlutterLocalNotificationsPlugin` and checks the platform instance's +// type before using it, so on the watch (where `defaultTargetPlatform` is +// iOS) the implementation has to be one. This class extends it and answers +// every method through UNUserNotificationCenter +// (`watchos/Classes/flutter_local_notifications_watchos_ffi.m`) instead of +// the method channel, which does not run on watchOS. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/timezone.dart' as tz; + +import 'src/backend.dart'; + +export 'src/backend.dart' show NotificationsWatchosBackend; + +/// watchOS implementation of `flutter_local_notifications`. +/// +/// Apps use `FlutterLocalNotificationsPlugin` as on iOS: pass `iOS:` +/// settings to `initialize` and `iOS:` details to `show` and the scheduling +/// calls, because the watch reports itself as iOS. +class FlutterLocalNotificationsWatchos + extends IOSFlutterLocalNotificationsPlugin { + /// Test hook: set before first use to replace the native backend. + @visibleForTesting + static NotificationsWatchosBackend? backendOverride; + + static NotificationsWatchosBackend? _backend; + + NotificationsWatchosBackend get _b => + backendOverride ?? (_backend ??= FfiNotificationsWatchosBackend()); + + /// Registers this implementation as the platform instance on watchOS. + static void registerWith() { + FlutterLocalNotificationsPlatform.instance = + FlutterLocalNotificationsWatchos(); + } + + DidReceiveNotificationResponseCallback? _onResponse; + bool _initialized = false; + bool _registered = false; + final Map> _pending = >{}; + + // ---- Requests ----------------------------------------------------------- + + void _update() { + final bool wanted = _initialized || _pending.isNotEmpty; + if (wanted == _registered) { + return; + } + _registered = wanted; + _b.setSignalHandler(wanted ? _onSignal : null); + } + + void _onSignal(int kind) { + switch (kind) { + case NotificationsSignal.request: + _drainRequests(); + case NotificationsSignal.responses: + _drainResponses(); + } + } + + void _drainRequests() { + for (final int id in _pending.keys.toList()) { + final String? json = _b.takeRequestResult(id); + if (json == null) { + continue; + } + final Completer completer = _pending.remove(id)!; + final Object? result = jsonDecode(json); + if (result is Map && result['error'] is Map) { + final Map error = + result['error']! as Map; + completer.completeError(PlatformException( + code: error['code'] as String? ?? 'error', + message: error['message'] as String?, + )); + } else { + completer.complete(result); + } + } + _update(); + } + + Future _await(int id) { + final Completer completer = Completer(); + _pending[id] = completer; + _update(); + // The result may have landed before the handler was registered. + _drainRequests(); + return completer.future; + } + + void _drainResponses() { + final String? json = _b.pollResponses(); + if (json == null) { + return; + } + for (final Object? item in jsonDecode(json) as List) { + if (item is Map) { + _onResponse?.call(_response(item)); + } + } + } + + static NotificationResponse _response(Map r) => + NotificationResponse( + id: (r['id'] as num?)?.toInt(), + actionId: r['actionId'] as String?, + payload: r['payload'] as String?, + notificationResponseType: + NotificationResponseType.values[(r['type']! as num).toInt()], + ); + + // ---- Validation, as flutter_local_notifications does it ----------------- + + static void _validateId(int id) { + if (id > 0x7FFFFFFF || id < -0x80000000) { + throw ArgumentError.value( + id, 'id', 'must fit within the size of a 32-bit integer'); + } + } + + // ---- Serialization ------------------------------------------------------ + + static int _bits(Iterable options, int Function(Object) value) => + options.fold(0, (int all, Object o) => all | value(o)); + + static Map _details(DarwinNotificationDetails? d) => + d == null + ? const {} + : { + 'presentAlert': d.presentAlert, + 'presentSound': d.presentSound, + 'presentBadge': d.presentBadge, + 'presentBanner': d.presentBanner, + 'presentList': d.presentList, + 'subtitle': d.subtitle, + 'badgeNumber': d.badgeNumber, + 'threadIdentifier': d.threadIdentifier, + 'categoryIdentifier': d.categoryIdentifier, + 'interruptionLevel': d.interruptionLevel?.index, + 'attachments': d.attachments + ?.map((DarwinNotificationAttachment a) => { + 'filePath': a.filePath, + 'identifier': a.identifier, + 'hideThumbnail': a.hideThumbnail, + }) + .toList(), + }; + + Future _add({ + required int id, + required String? title, + required String? body, + required String? payload, + required DarwinNotificationDetails? details, + required Map trigger, + }) async { + _validateId(id); + await _await(_b.add(jsonEncode({ + 'id': id, + 'title': title, + 'body': body, + 'payload': payload ?? '', + 'details': _details(details), + 'trigger': trigger, + }))); + } + + // ---- IOSFlutterLocalNotificationsPlugin --------------------------------- + + /// Sets the categories and default presentation, asks for the permissions + /// [settings] request, and starts handing taps to + /// [onDidReceiveNotificationResponse]. + /// + /// [onDidReceiveBackgroundNotificationResponse] is not used: on the watch + /// every tap reaches the app's own isolate, through + /// [onDidReceiveNotificationResponse]. + @override + Future initialize({ + required DarwinInitializationSettings settings, + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + DidReceiveBackgroundNotificationResponseCallback? + onDidReceiveBackgroundNotificationResponse, + }) async { + _onResponse = onDidReceiveNotificationResponse; + _initialized = true; + _update(); + final Object? result = await _await(_b.initialize(jsonEncode( + { + 'categories': [ + for (final DarwinNotificationCategory c + in settings.notificationCategories) + { + 'identifier': c.identifier, + 'options': _bits(c.options, + (Object o) => (o as DarwinNotificationCategoryOption).value), + 'actions': [ + for (final DarwinNotificationAction a in c.actions) + { + 'identifier': a.identifier, + 'title': a.title, + 'options': _bits( + a.options, + (Object o) => + (o as DarwinNotificationActionOption).value), + }, + ], + }, + ], + 'defaults': { + 'presentAlert': settings.defaultPresentAlert, + 'presentSound': settings.defaultPresentSound, + 'presentBadge': settings.defaultPresentBadge, + 'presentBanner': settings.defaultPresentBanner, + 'presentList': settings.defaultPresentList, + }, + 'request': { + 'alert': settings.requestAlertPermission, + 'sound': settings.requestSoundPermission, + 'badge': settings.requestBadgePermission, + 'provisional': settings.requestProvisionalPermission, + 'critical': settings.requestCriticalPermission, + }, + }, + ))); + // Taps that arrived since native started queueing them. + _drainResponses(); + return (result as Map?)?['granted'] == true; + } + + @override + Future requestPermissions({ + bool sound = false, + bool alert = false, + bool badge = false, + bool provisional = false, + bool critical = false, + bool carPlay = false, + bool providesAppNotificationSettings = false, + }) async { + final Object? result = await _await(_b.requestPermissions(jsonEncode( + { + 'alert': alert, + 'sound': sound, + 'badge': badge, + 'provisional': provisional, + 'critical': critical, + }, + ))); + return (result as Map?)?['granted'] == true; + } + + @override + Future checkPermissions() async { + final Map r = + (await _await(_b.checkPermissions()))! as Map; + bool flag(String key) => r[key] == true; + return NotificationsEnabledOptions( + isEnabled: flag('isEnabled'), + isAlertEnabled: flag('isAlertEnabled'), + isSoundEnabled: flag('isSoundEnabled'), + isBadgeEnabled: flag('isBadgeEnabled'), + isProvisionalEnabled: flag('isProvisionalEnabled'), + isCriticalEnabled: flag('isCriticalEnabled'), + isProvidesAppNotificationSettingsEnabled: false, + ); + } + + /// watchOS has no notification settings page an app can open. + @override + Future openAppNotificationSettings() async => false; + + @override + Future show({ + required int id, + String? title, + String? body, + DarwinNotificationDetails? notificationDetails, + String? payload, + }) => + _add( + id: id, + title: title, + body: body, + payload: payload, + details: notificationDetails, + trigger: const {'type': 'now'}, + ); + + @override + Future zonedSchedule({ + required int id, + String? title, + String? body, + required tz.TZDateTime scheduledDate, + String? payload, + DateTimeComponents? matchDateTimeComponents, + DarwinNotificationDetails? notificationDetails, + }) { + if (matchDateTimeComponents == null && + scheduledDate.isBefore(DateTime.now())) { + throw ArgumentError.value( + scheduledDate, 'scheduledDate', 'Must be a date in the future'); + } + return _add( + id: id, + title: title, + body: body, + payload: payload, + details: notificationDetails, + trigger: { + 'type': 'calendar', + 'epochMs': scheduledDate.millisecondsSinceEpoch, + 'timeZone': scheduledDate.location.name, + 'match': matchDateTimeComponents?.index, + }, + ); + } + + @override + Future periodicallyShow({ + required int id, + String? title, + String? body, + required RepeatInterval repeatInterval, + DarwinNotificationDetails? notificationDetails, + String? payload, + }) => + _add( + id: id, + title: title, + body: body, + payload: payload, + details: notificationDetails, + trigger: { + 'type': 'interval', + 'seconds': switch (repeatInterval) { + RepeatInterval.everyMinute => 60, + RepeatInterval.hourly => 3600, + RepeatInterval.daily => 86400, + RepeatInterval.weekly => 604800, + }, + 'repeats': true, + }, + ); + + @override + Future periodicallyShowWithDuration({ + required int id, + String? title, + String? body, + required Duration repeatDurationInterval, + DarwinNotificationDetails? notificationDetails, + String? payload, + }) { + if (repeatDurationInterval.inMilliseconds < + Duration.millisecondsPerMinute) { + throw ArgumentError.value(repeatDurationInterval, + 'repeatDurationInterval', 'must be one minute or more'); + } + return _add( + id: id, + title: title, + body: body, + payload: payload, + details: notificationDetails, + trigger: { + 'type': 'interval', + 'seconds': repeatDurationInterval.inMilliseconds / 1000, + 'repeats': true, + }, + ); + } + + @override + Future cancel({required int id}) async { + _validateId(id); + _b.cancel(id); + } + + @override + Future cancelAll() async => _b.cancelAll(pendingOnly: false); + + @override + Future cancelAllPendingNotifications() async => + _b.cancelAll(pendingOnly: true); + + @override + Future> + pendingNotificationRequests() async => [ + for (final Object? p + in (await _await(_b.pending()))! as List) + if (p is Map) + PendingNotificationRequest( + (p['id']! as num).toInt(), + p['title'] as String?, + p['body'] as String?, + p['payload'] as String?, + ), + ]; + + @override + Future> getActiveNotifications() async => + [ + for (final Object? n in (await _await(_b.active()))! as List) + if (n is Map) + ActiveNotification( + id: (n['id'] as num?)?.toInt(), + title: n['title'] as String?, + body: n['body'] as String?, + payload: n['payload'] as String?, + groupKey: n['threadIdentifier'] as String?, + ), + ]; + + /// The tap that launched the app, if one did: a tap that arrived before + /// [initialize] ran. + @override + Future + getNotificationAppLaunchDetails() async { + final Map d = + jsonDecode(_b.launchDetails()) as Map; + final Object? response = d['response']; + return NotificationAppLaunchDetails( + d['launched'] == true, + notificationResponse: + response is Map ? _response(response) : null, + ); + } +} diff --git a/packages/flutter_local_notifications_watchos/lib/src/backend.dart b/packages/flutter_local_notifications_watchos/lib/src/backend.dart new file mode 100644 index 0000000..4590d06 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/lib/src/backend.dart @@ -0,0 +1,175 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// The C symbols of `watchos/Classes/flutter_local_notifications_watchos_ffi.h`, +// behind an interface so host tests can substitute a fake. + +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +/// What woke Dart up. Mirrored in the native header. +abstract final class NotificationsSignal { + /// A request finished. + static const int request = 1; + + /// Taps are queued. + static const int responses = 2; +} + +/// The native half of `FlutterLocalNotificationsWatchos`. Every member maps +/// to one exported C function; JSON shapes are the native header's. +abstract class NotificationsWatchosBackend { + /// Starts `initialize` and returns its request id. + int initialize(String settingsJson); + + /// Starts a permission request and returns its id. + int requestPermissions(String optionsJson); + + /// Starts a permission check and returns its id. + int checkPermissions(); + + /// Starts adding a notification and returns the request id. + int add(String notificationJson); + + /// Removes notification [id], pending and delivered. + void cancel(int id); + + /// Removes every notification, or only pending ones. + void cancelAll({required bool pendingOnly}); + + /// Starts listing pending notifications and returns the request id. + int pending(); + + /// Starts listing delivered notifications and returns the request id. + int active(); + + /// The launch tap as JSON. + String launchDetails(); + + /// The queued taps as a JSON array, or null when none are. + String? pollResponses(); + + /// The result of request [id] as JSON, or null while it runs. + String? takeRequestResult(int id); + + /// The function native wakes, or null to stop. + void setSignalHandler(void Function(int kind)? handler); +} + +typedef _SignalNative = Void Function(Int64); + +/// [NotificationsWatchosBackend] over the exported C functions. +class FfiNotificationsWatchosBackend implements NotificationsWatchosBackend { + /// Resolves the symbols from the process lazily, on first use. + FfiNotificationsWatchosBackend() : _lib = DynamicLibrary.process(); + + final DynamicLibrary _lib; + NativeCallable<_SignalNative>? _callable; + void Function(int kind)? _handler; + + late final void Function(Pointer>) + _setCallback = _lib.lookupFunction< + Void Function(Pointer>), + void Function(Pointer>)>( + 'flutter_local_notifications_watchos_set_callback'); + late final int Function(Pointer) _initialize = _lib.lookupFunction< + Int64 Function(Pointer), + int Function( + Pointer)>('flutter_local_notifications_watchos_initialize'); + late final int Function(Pointer) _requestPermissions = + _lib.lookupFunction), + int Function(Pointer)>( + 'flutter_local_notifications_watchos_request_permissions'); + late final int Function() _checkPermissions = + _lib.lookupFunction( + 'flutter_local_notifications_watchos_check_permissions'); + late final int Function(Pointer) _add = _lib.lookupFunction< + Int64 Function(Pointer), + int Function(Pointer)>('flutter_local_notifications_watchos_add'); + late final void Function(int) _cancel = + _lib.lookupFunction( + 'flutter_local_notifications_watchos_cancel'); + late final void Function(int) _cancelAll = + _lib.lookupFunction( + 'flutter_local_notifications_watchos_cancel_all'); + late final int Function() _pending = + _lib.lookupFunction( + 'flutter_local_notifications_watchos_pending'); + late final int Function() _active = + _lib.lookupFunction( + 'flutter_local_notifications_watchos_active'); + late final Pointer Function() _launchDetails = + _lib.lookupFunction Function(), Pointer Function()>( + 'flutter_local_notifications_watchos_launch_details'); + late final Pointer Function() _pollResponses = + _lib.lookupFunction Function(), Pointer Function()>( + 'flutter_local_notifications_watchos_poll_responses'); + late final Pointer Function(int) _takeRequestResult = + _lib.lookupFunction< + Pointer Function(Int64), + Pointer Function( + int)>('flutter_local_notifications_watchos_take_request_result'); + late final void Function(Pointer) _release = _lib.lookupFunction< + Void Function(Pointer), + void Function( + Pointer)>('flutter_local_notifications_watchos_release'); + + String? _take(Pointer p) { + if (p == nullptr) { + return null; + } + try { + return p.toDartString(); + } finally { + _release(p); + } + } + + int _withString(String s, int Function(Pointer) call) => + using((Arena arena) => call(s.toNativeUtf8(allocator: arena))); + + @override + int initialize(String settingsJson) => _withString(settingsJson, _initialize); + @override + int requestPermissions(String optionsJson) => + _withString(optionsJson, _requestPermissions); + @override + int checkPermissions() => _checkPermissions(); + @override + int add(String notificationJson) => _withString(notificationJson, _add); + @override + void cancel(int id) => _cancel(id); + @override + void cancelAll({required bool pendingOnly}) => + _cancelAll(pendingOnly ? 1 : 0); + @override + int pending() => _pending(); + @override + int active() => _active(); + @override + String launchDetails() => + _take(_launchDetails()) ?? '{"launched": false, "response": null}'; + @override + String? pollResponses() => _take(_pollResponses()); + @override + String? takeRequestResult(int id) => _take(_takeRequestResult(id)); + + @override + void setSignalHandler(void Function(int kind)? handler) { + _handler = handler; + if (handler == null) { + _setCallback(nullptr); + return; + } + final NativeCallable<_SignalNative> callable = _callable ??= () { + final NativeCallable<_SignalNative> created = + NativeCallable<_SignalNative>.listener( + (int kind) => _handler?.call(kind)); + created.keepIsolateAlive = false; + return created; + }(); + _setCallback(callable.nativeFunction); + } +} diff --git a/packages/flutter_local_notifications_watchos/pubspec.yaml b/packages/flutter_local_notifications_watchos/pubspec.yaml new file mode 100644 index 0000000..bd7ff27 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/pubspec.yaml @@ -0,0 +1,62 @@ +name: flutter_local_notifications_watchos +description: "watchOS implementation of the flutter_local_notifications plugin: show, schedule and cancel local notifications on Apple Watch via dart:ffi." +version: 0.0.1 +homepage: https://flutterwatch.dev +repository: https://github.com/flutterwatch/plugins/tree/main/packages/flutter_local_notifications_watchos +issue_tracker: https://github.com/flutterwatch/plugins/issues +topics: + - watchos + - ffi + - notifications + +environment: + # 3.1 is where NativeCallable.listener landed — the push path this plugin + # depends on. + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + ffi: ^2.1.0 + flutter: + sdk: flutter + # IOSFlutterLocalNotificationsPlugin, which this package extends because + # flutter_local_notifications checks for that type on iOS-reporting + # platforms, and the Darwin settings and details. + flutter_local_notifications: ^22.3.2 + timezone: ^0.11.0 + +dev_dependencies: + flutter_lints: ^4.0.0 + flutter_test: + sdk: flutter + +flutter: + plugin: + implements: flutter_local_notifications + platforms: + watchos: + # FFI plugin: the CLI compiles watchos/Classes/*.m into a + # static archive force-loaded into the watch binary; Dart + # resolves the exported symbols via DynamicLibrary.process(). + # Method-channel plugins are not supported on watchOS, + # so this is the supported plugin model. + ffiPlugin: true + dartPluginClass: FlutterLocalNotificationsWatchos + # C symbols exported for dart:ffi lookup. They survive the link + # because the CLI force-loads the plugin archive, each export is + # marked used with default visibility, and the CLI keeps global + # symbols through the App Store strip. + ffiSymbols: + - flutter_local_notifications_watchos_set_callback + - flutter_local_notifications_watchos_initialize + - flutter_local_notifications_watchos_request_permissions + - flutter_local_notifications_watchos_check_permissions + - flutter_local_notifications_watchos_add + - flutter_local_notifications_watchos_cancel + - flutter_local_notifications_watchos_cancel_all + - flutter_local_notifications_watchos_pending + - flutter_local_notifications_watchos_active + - flutter_local_notifications_watchos_launch_details + - flutter_local_notifications_watchos_poll_responses + - flutter_local_notifications_watchos_take_request_result + - flutter_local_notifications_watchos_release diff --git a/packages/flutter_local_notifications_watchos/test/flutter_local_notifications_watchos_test.dart b/packages/flutter_local_notifications_watchos/test/flutter_local_notifications_watchos_test.dart new file mode 100644 index 0000000..206935b --- /dev/null +++ b/packages/flutter_local_notifications_watchos/test/flutter_local_notifications_watchos_test.dart @@ -0,0 +1,368 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// Host-side unit tests. UNUserNotificationCenter is replaced with a fake that +// records what native would be asked and answers as the native header +// documents. The calls go through the app-facing FlutterLocalNotificationsPlugin, +// with the target platform set to iOS as the watch reports it, so the routing +// to this implementation is checked too. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_local_notifications_watchos/flutter_local_notifications_watchos.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:timezone/timezone.dart' as tz; + +class _FakeBackend implements NotificationsWatchosBackend { + final List> added = >[]; + Map? initialized; + final List calls = []; + final Map answers = {}; + final Map results = {}; + final List> responses = >[]; + String launch = '{"launched": false, "response": null}'; + void Function(int kind)? handler; + int _next = 1; + + /// What every request answers, unless [answers] names one. + Object? defaultAnswer = {}; + + int _request(String name) { + calls.add(name); + final int id = _next++; + // Answered asynchronously, as UserNotifications does. + scheduleMicrotask(() { + results[id] = answers.containsKey(id) ? answers[id] : defaultAnswer; + handler?.call(1); + }); + return id; + } + + void tap(Map response) { + responses.add(response); + handler?.call(2); + } + + @override + int initialize(String settingsJson) { + initialized = (jsonDecode(settingsJson) as Map) + .cast(); + return _request('initialize'); + } + + @override + int requestPermissions(String optionsJson) => + _request('requestPermissions $optionsJson'); + @override + int checkPermissions() => _request('checkPermissions'); + @override + int add(String notificationJson) { + added.add((jsonDecode(notificationJson) as Map) + .cast()); + return _request('add'); + } + + @override + void cancel(int id) => calls.add('cancel $id'); + @override + void cancelAll({required bool pendingOnly}) => + calls.add(pendingOnly ? 'cancelAllPending' : 'cancelAll'); + @override + int pending() => _request('pending'); + @override + int active() => _request('active'); + @override + String launchDetails() => launch; + @override + String? pollResponses() { + if (responses.isEmpty) { + return null; + } + final String json = jsonEncode(responses); + responses.clear(); + return json; + } + + @override + String? takeRequestResult(int id) => + results.containsKey(id) ? jsonEncode(results.remove(id)) : null; + @override + void setSignalHandler(void Function(int kind)? h) => handler = h; +} + +void main() { + late _FakeBackend fake; + late FlutterLocalNotificationsPlugin plugin; + + setUpAll(tzdata.initializeTimeZones); + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + fake = _FakeBackend(); + FlutterLocalNotificationsWatchos.backendOverride = fake; + FlutterLocalNotificationsWatchos.registerWith(); + plugin = FlutterLocalNotificationsPlugin(); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + FlutterLocalNotificationsWatchos.backendOverride = null; + }); + + test('the watchOS implementation is what iOS calls resolve to', () { + expect(FlutterLocalNotificationsPlatform.instance, + isA()); + expect( + plugin.resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>(), + isA()); + }); + + test('initialize sends the categories, defaults and requests', () async { + fake.defaultAnswer = {'granted': true}; + final bool? granted = await plugin.initialize( + settings: InitializationSettings( + iOS: DarwinInitializationSettings( + requestSoundPermission: false, + defaultPresentBanner: false, + notificationCategories: [ + DarwinNotificationCategory( + 'lap', + actions: [ + DarwinNotificationAction.plain('resume', 'Resume', + options: { + DarwinNotificationActionOption.foreground, + }), + ], + options: { + DarwinNotificationCategoryOption.customDismissAction, + }, + ), + ], + ), + ), + ); + expect(granted, isTrue); + final Map sent = fake.initialized!; + expect(sent['categories'], [ + { + 'identifier': 'lap', + 'options': 1, + 'actions': [ + { + 'identifier': 'resume', + 'title': 'Resume', + 'options': 4 + }, + ], + }, + ]); + expect( + (sent['defaults']! as Map)['presentBanner'], false); + expect((sent['request']! as Map)['sound'], false); + expect((sent['request']! as Map)['alert'], true); + }); + + test('a tap after initialize reaches the callback', () async { + final List taps = []; + await plugin.initialize( + settings: + const InitializationSettings(iOS: DarwinInitializationSettings()), + onDidReceiveNotificationResponse: taps.add, + ); + fake.tap({ + 'id': 7, + 'actionId': 'resume', + 'payload': 'run', + 'type': 1, + }); + await pumpEventQueue(); + expect(taps.single.id, 7); + expect(taps.single.actionId, 'resume'); + expect(taps.single.payload, 'run'); + expect(taps.single.notificationResponseType, + NotificationResponseType.selectedNotificationAction); + }); + + test('the launch tap is reported by getNotificationAppLaunchDetails', + () async { + fake.launch = jsonEncode({ + 'launched': true, + 'response': { + 'id': 3, + 'actionId': null, + 'payload': 'p', + 'type': 0, + }, + }); + final NotificationAppLaunchDetails? details = + await plugin.getNotificationAppLaunchDetails(); + expect(details!.didNotificationLaunchApp, isTrue); + expect(details.notificationResponse!.id, 3); + expect(details.notificationResponse!.notificationResponseType, + NotificationResponseType.selectedNotification); + }); + + test('show adds an immediate notification with its details', () async { + await plugin.show( + id: 1, + title: 'Lap 3', + body: '5:30 /km', + notificationDetails: const NotificationDetails( + iOS: DarwinNotificationDetails( + subtitle: 'Run', + presentSound: false, + threadIdentifier: 'run', + categoryIdentifier: 'lap', + interruptionLevel: InterruptionLevel.timeSensitive, + ), + ), + payload: 'lap-3', + ); + final Map n = fake.added.single; + expect(n['id'], 1); + expect(n['title'], 'Lap 3'); + expect(n['body'], '5:30 /km'); + expect(n['payload'], 'lap-3'); + expect(n['trigger'], {'type': 'now'}); + final Map d = n['details']! as Map; + expect(d['subtitle'], 'Run'); + expect(d['presentSound'], false); + expect(d['threadIdentifier'], 'run'); + expect(d['categoryIdentifier'], 'lap'); + expect(d['interruptionLevel'], InterruptionLevel.timeSensitive.index); + }); + + test('zonedSchedule sends the instant, its zone and the repeat', () async { + final tz.Location istanbul = tz.getLocation('Europe/Istanbul'); + final tz.TZDateTime at = + tz.TZDateTime.now(istanbul).add(const Duration(hours: 1)); + await plugin.zonedSchedule( + id: 2, + title: 'Stretch', + body: null, + scheduledDate: at, + notificationDetails: const NotificationDetails(), + androidScheduleMode: AndroidScheduleMode.inexact, + matchDateTimeComponents: DateTimeComponents.time, + ); + expect(fake.added.single['trigger'], { + 'type': 'calendar', + 'epochMs': at.millisecondsSinceEpoch, + 'timeZone': 'Europe/Istanbul', + 'match': DateTimeComponents.time.index, + }); + }); + + test('a one-time schedule in the past is refused', () { + expect( + () => plugin.zonedSchedule( + id: 2, + title: null, + body: null, + scheduledDate: + tz.TZDateTime.now(tz.UTC).subtract(const Duration(minutes: 1)), + notificationDetails: const NotificationDetails(), + androidScheduleMode: AndroidScheduleMode.inexact, + ), + throwsArgumentError, + ); + }); + + test('repeat intervals become seconds', () async { + for (final RepeatInterval interval in RepeatInterval.values) { + await plugin.periodicallyShow( + id: interval.index, + title: null, + body: null, + repeatInterval: interval, + notificationDetails: const NotificationDetails(), + androidScheduleMode: AndroidScheduleMode.inexact, + ); + } + expect( + fake.added + .map((Map n) => + (n['trigger']! as Map)['seconds']) + .toList(), + [60, 3600, 86400, 604800]); + await plugin.periodicallyShowWithDuration( + id: 9, + title: null, + body: null, + repeatDurationInterval: const Duration(minutes: 90), + notificationDetails: const NotificationDetails(), + androidScheduleMode: AndroidScheduleMode.inexact, + ); + expect(fake.added.last['trigger'], { + 'type': 'interval', + 'seconds': 5400.0, + 'repeats': true, + }); + }); + + test('cancel calls reach native', () async { + await plugin.cancel(id: 4); + await plugin.cancelAll(); + await plugin.cancelAllPendingNotifications(); + expect(fake.calls, ['cancel 4', 'cancelAll', 'cancelAllPending']); + }); + + test('pending and delivered notifications are listed', () async { + fake.defaultAnswer = [ + { + 'id': 5, + 'title': 't', + 'body': 'b', + 'payload': 'p', + 'threadIdentifier': 'run', + }, + ]; + final List pending = + await plugin.pendingNotificationRequests(); + expect(pending.single.id, 5); + expect(pending.single.payload, 'p'); + final List active = + await plugin.getActiveNotifications(); + expect(active.single.groupKey, 'run'); + }); + + test('a failed request is a PlatformException', () async { + fake.defaultAnswer = { + 'error': {'code': 'add', 'message': 'no'}, + }; + await expectLater( + plugin.show(id: 1, title: 'x', body: null), + throwsA(isA() + .having((PlatformException e) => e.code, 'code', 'add')), + ); + }); + + test('an id beyond 32 bits is refused', () { + expect(() => plugin.show(id: 0x80000000, title: null, body: null), + throwsArgumentError); + }); + + test('checkPermissions maps the settings', () async { + fake.defaultAnswer = { + 'isEnabled': true, + 'isAlertEnabled': true, + 'isSoundEnabled': false, + 'isBadgeEnabled': false, + 'isProvisionalEnabled': false, + 'isCriticalEnabled': false, + }; + final NotificationsEnabledOptions? options = await plugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>()! + .checkPermissions(); + expect(options!.isEnabled, isTrue); + expect(options.isSoundEnabled, isFalse); + }); +} diff --git a/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.h b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.h new file mode 100644 index 0000000..d59c3c1 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.h @@ -0,0 +1,103 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// watchOS implementation of `flutter_local_notifications`, over dart:ffi. +// +// Local notifications through UNUserNotificationCenter. The flutter-watchos +// host owns the notification-center delegate and rebroadcasts its callbacks +// as NSNotifications; this file observes them from the moment the binary +// loads, so the tap that launched the app is not missed, answers +// "will present" from the flags stored in each notification, and queues +// taps for Dart. +// +// The asynchronous UserNotifications calls return a request id at once; a +// FLNW_SIGNAL_REQUEST signal says one finished and +// `..._take_request_result` hands its JSON over. Taps queue as events +// behind FLNW_SIGNAL_RESPONSES. Strings are JSON that native allocates and +// `..._release` releases. + +#ifndef FLUTTER_LOCAL_NOTIFICATIONS_WATCHOS_FFI_H +#define FLUTTER_LOCAL_NOTIFICATIONS_WATCHOS_FFI_H + +#include + +// Each exported symbol is marked `used` + default-visibility so it survives +// the linker's `-dead_strip` and lands in the executable's dynamic symbol +// table, where `DynamicLibrary.process()` / dlsym can resolve it. +#define FLNW_EXPORT __attribute__((visibility("default"))) __attribute__((used)) + +// What woke Dart up. Mirrored in lib/src/backend.dart. +#define FLNW_SIGNAL_REQUEST 1 +#define FLNW_SIGNAL_RESPONSES 2 + +typedef void (*flnw_signal_callback)(int64_t kind); + +FLNW_EXPORT +void flutter_local_notifications_watchos_set_callback(flnw_signal_callback callback); + +// Sets the categories and the default presentation flags, starts handing +// taps to Dart instead of keeping them as the launch tap, and asks for the +// permissions the settings request. `json` is {"categories": [{"identifier", +// "actions": [{"identifier", "title", "options"}], "options"}], +// "defaults": {"presentAlert", "presentSound", "presentBadge", +// "presentBanner", "presentList"}, "request": {"alert", "sound", "badge", +// "provisional", "critical"}}. Result: {"granted": }. +FLNW_EXPORT +int64_t flutter_local_notifications_watchos_initialize(const char* json); + +// `json` is {"alert", "sound", "badge", "provisional", "critical"}. +// Result: {"granted": }. +FLNW_EXPORT +int64_t flutter_local_notifications_watchos_request_permissions(const char* json); + +// Result: {"isEnabled", "isAlertEnabled", "isSoundEnabled", +// "isBadgeEnabled", "isProvisionalEnabled", "isCriticalEnabled"}. +FLNW_EXPORT +int64_t flutter_local_notifications_watchos_check_permissions(void); + +// Adds a notification request. `json` is {"id", "title", "body", "payload", +// "details": {...DarwinNotificationDetails...}, "trigger": {"type": "now"} | +// {"type": "calendar", "epochMs", "timeZone", "match"} | {"type": +// "interval", "seconds", "repeats"}}. Result: {}. +FLNW_EXPORT +int64_t flutter_local_notifications_watchos_add(const char* json); + +// Removes `notification` (its id), pending and delivered. +FLNW_EXPORT +void flutter_local_notifications_watchos_cancel(int64_t notification); + +// Removes every notification this app scheduled or delivered; with +// `pending_only` nonzero, only those not yet delivered. +FLNW_EXPORT +void flutter_local_notifications_watchos_cancel_all(int pending_only); + +// Result: a JSON array of {"id", "title", "body", "payload"}. +FLNW_EXPORT +int64_t flutter_local_notifications_watchos_pending(void); + +// Result: a JSON array of {"id", "title", "body", "payload", +// "threadIdentifier"}. +FLNW_EXPORT +int64_t flutter_local_notifications_watchos_active(void); + +// {"launched": , "response": {"id", "actionId", "payload", "type"} or +// null}: the tap that launched the app, if one did. +FLNW_EXPORT +char* flutter_local_notifications_watchos_launch_details(void); + +// Drains the taps queued since `initialize`, as a JSON array of {"id", +// "actionId", "payload", "type"}; NULL when none are queued. +FLNW_EXPORT +char* flutter_local_notifications_watchos_poll_responses(void); + +// The result of `request` as JSON, or NULL while it runs. A failed +// request is {"error": {"code", "message"}}. +FLNW_EXPORT +char* flutter_local_notifications_watchos_take_request_result(int64_t request); + +// Releases a string this file returned. +FLNW_EXPORT +void flutter_local_notifications_watchos_release(char* string); + +#endif // FLUTTER_LOCAL_NOTIFICATIONS_WATCHOS_FFI_H diff --git a/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m new file mode 100644 index 0000000..376712b --- /dev/null +++ b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m @@ -0,0 +1,576 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// Local notifications for watchOS. See the header for the contract. +// +// Each notification this package adds carries its id, its payload and its +// presentation flags in `userInfo`, under keys of its own; a callback for a +// notification without them belongs to someone else (a push, another +// plugin) and is left alone. + +#import "flutter_local_notifications_watchos_ffi.h" + +#import +#import +#import +#import +#import + +static NSString* const kIdKey = @"dev.flutterwatch.flnw.id"; +static NSString* const kPayloadKey = @"dev.flutterwatch.flnw.payload"; +static NSString* const kPresentKey = @"dev.flutterwatch.flnw.present"; + +// Posted by the flutter-watchos host's application delegate. +static NSString* const kWillPresent = @"FlutterWatchOSNotificationWillPresent"; +static NSString* const kDidReceiveResponse = + @"FlutterWatchOSNotificationDidReceiveResponse"; + +// How many taps wait for Dart before the oldest is dropped. +static const NSUInteger kResponseBuffer = 64; + +static os_unfair_lock _lock = OS_UNFAIR_LOCK_INIT; +static flnw_signal_callback _callback = NULL; +static BOOL _initialized = NO; +static NSDictionary* _launch = nil; +static NSMutableArray* _responses = nil; +static NSDictionary* _defaults = nil; +static NSMutableDictionary* _results = nil; +static int64_t _nextRequest = 1; + +static void _signal(int64_t kind) { + os_unfair_lock_lock(&_lock); + flnw_signal_callback callback = _callback; + os_unfair_lock_unlock(&_lock); + if (callback != NULL) { + callback(kind); + } +} + +static char* _json(id object) { + if (object == nil) { + return NULL; + } + NSData* data = [NSJSONSerialization dataWithJSONObject:object options:0 error:nil]; + if (data == nil) { + return NULL; + } + char* out = malloc(data.length + 1); + if (out == NULL) { + return NULL; + } + memcpy(out, data.bytes, data.length); + out[data.length] = '\0'; + return out; +} + +static NSDictionary* _parse(const char* json) { + if (json == NULL) { + return @{}; + } + NSData* data = [NSData dataWithBytes:json length:strlen(json)]; + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [parsed isKindOfClass:[NSDictionary class]] ? parsed : @{}; +} + +static int64_t _newRequest(void) { + os_unfair_lock_lock(&_lock); + int64_t request = _nextRequest++; + os_unfair_lock_unlock(&_lock); + return request; +} + +static void _finish(int64_t request, id result) { + os_unfair_lock_lock(&_lock); + if (_results == nil) { + _results = [NSMutableDictionary dictionary]; + } + _results[@(request)] = result; + os_unfair_lock_unlock(&_lock); + _signal(FLNW_SIGNAL_REQUEST); +} + +static void _fail(int64_t request, NSString* code, NSError* error) { + _finish(request, @{ + @"error" : @{ + @"code" : code, + @"message" : error.localizedDescription ?: @"", + } + }); +} + +static BOOL _flag(NSDictionary* dict, NSString* key) { + id value = dict[key]; + return [value isKindOfClass:[NSNumber class]] && [value boolValue]; +} + +// A presentation flag: the notification's own when it set one, the +// initialization default otherwise, and on when neither did. +static BOOL _present(NSDictionary* own, NSString* key) { + id value = own[key]; + if ([value isKindOfClass:[NSNumber class]]) { + return [value boolValue]; + } + os_unfair_lock_lock(&_lock); + id fallback = _defaults[key]; + os_unfair_lock_unlock(&_lock); + return [fallback isKindOfClass:[NSNumber class]] ? [fallback boolValue] : YES; +} + +static UNAuthorizationOptions _authorizationOptions(NSDictionary* request) { + UNAuthorizationOptions options = 0; + if (_flag(request, @"alert")) { + options |= UNAuthorizationOptionAlert; + } + if (_flag(request, @"sound")) { + options |= UNAuthorizationOptionSound; + } + if (_flag(request, @"badge")) { + options |= UNAuthorizationOptionBadge; + } + if (_flag(request, @"provisional")) { + options |= UNAuthorizationOptionProvisional; + } + if (_flag(request, @"critical")) { + options |= UNAuthorizationOptionCriticalAlert; + } + return options; +} + +static void _requestAuthorization(int64_t request, UNAuthorizationOptions options) { + [[UNUserNotificationCenter currentNotificationCenter] + requestAuthorizationWithOptions:options + completionHandler:^(BOOL granted, NSError* error) { + if (error != nil) { + _fail(request, @"permission", error); + return; + } + _finish(request, @{@"granted" : @(granted)}); + }]; +} + +// ---- Callbacks from the host's notification-center delegate --------------- + +static void _onWillPresent(NSNotification* note) { + NSDictionary* payload = note.userInfo[@"payload"]; + NSMutableDictionary* options = note.userInfo[@"options"]; + if (![payload isKindOfClass:[NSDictionary class]] || payload[kIdKey] == nil || + ![options isKindOfClass:[NSMutableDictionary class]]) { + return; + } + id own = payload[kPresentKey]; + NSDictionary* flags = [own isKindOfClass:[NSDictionary class]] ? own : @{}; + BOOL alert = _present(flags, @"presentAlert"); + BOOL banner = [flags[@"presentBanner"] isKindOfClass:[NSNumber class]] + ? [flags[@"presentBanner"] boolValue] + : alert && _present(flags, @"presentBanner"); + BOOL list = [flags[@"presentList"] isKindOfClass:[NSNumber class]] + ? [flags[@"presentList"] boolValue] + : alert && _present(flags, @"presentList"); + UNNotificationPresentationOptions presentation = 0; + if (banner) { + presentation |= UNNotificationPresentationOptionBanner; + } + if (list) { + presentation |= UNNotificationPresentationOptionList; + } + if (_present(flags, @"presentSound")) { + presentation |= UNNotificationPresentationOptionSound; + } + if (_present(flags, @"presentBadge")) { + presentation |= UNNotificationPresentationOptionBadge; + } + options[@"options"] = @(presentation); +} + +static void _onResponse(NSNotification* note) { + NSDictionary* payload = note.userInfo[@"payload"]; + NSString* action = note.userInfo[@"actionIdentifier"]; + if (![payload isKindOfClass:[NSDictionary class]] || payload[kIdKey] == nil) { + return; + } + BOOL tapped = action == nil || + [action isEqualToString:UNNotificationDefaultActionIdentifier]; + BOOL dismissed = [action isEqualToString:UNNotificationDismissActionIdentifier]; + NSDictionary* response = @{ + @"id" : payload[kIdKey], + @"actionId" : tapped || dismissed ? [NSNull null] : action, + @"payload" : payload[kPayloadKey] ?: [NSNull null], + // NotificationResponseType: selectedNotification, + // selectedNotificationAction, notificationDismissed. + @"type" : @(tapped ? 0 : (dismissed ? 2 : 1)), + }; + os_unfair_lock_lock(&_lock); + BOOL initialized = _initialized; + if (!initialized) { + if (_launch == nil) { + _launch = response; + } + } else { + if (_responses == nil) { + _responses = [NSMutableArray array]; + } + [_responses addObject:response]; + if (_responses.count > kResponseBuffer) { + [_responses removeObjectAtIndex:0]; + } + } + os_unfair_lock_unlock(&_lock); + if (initialized) { + _signal(FLNW_SIGNAL_RESPONSES); + } +} + +// Runs when the binary loads, before the host's delegate can post the tap +// that launched the app, which it does not keep for later. +__attribute__((constructor)) static void _observe(void) { + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserverForName:kWillPresent + object:nil + queue:nil + usingBlock:^(NSNotification* note) { + _onWillPresent(note); + }]; + [center addObserverForName:kDidReceiveResponse + object:nil + queue:nil + usingBlock:^(NSNotification* note) { + _onResponse(note); + }]; +} + +// ---- Building requests ------------------------------------------------------ + +static UNNotificationCategory* _category(NSDictionary* json) { + NSMutableArray* actions = [NSMutableArray array]; + for (NSDictionary* action in json[@"actions"]) { + if (![action isKindOfClass:[NSDictionary class]]) { + continue; + } + // DarwinNotificationActionOption values match UNNotificationActionOptions. + UNNotificationActionOptions options = + (UNNotificationActionOptions)[action[@"options"] unsignedIntegerValue]; + [actions addObject:[UNNotificationAction actionWithIdentifier:action[@"identifier"] + title:action[@"title"] + options:options]]; + } + // DarwinNotificationCategoryOption values match UNNotificationCategoryOptions. + UNNotificationCategoryOptions options = + (UNNotificationCategoryOptions)[json[@"options"] unsignedIntegerValue]; + return [UNNotificationCategory categoryWithIdentifier:json[@"identifier"] + actions:actions + intentIdentifiers:@[] + options:options]; +} + +static UNNotificationTrigger* _trigger(NSDictionary* json) { + NSString* type = json[@"type"]; + if ([type isEqualToString:@"interval"]) { + return [UNTimeIntervalNotificationTrigger + triggerWithTimeInterval:MAX(1.0, [json[@"seconds"] doubleValue]) + repeats:[json[@"repeats"] boolValue]]; + } + if ([type isEqualToString:@"calendar"]) { + NSTimeZone* zone = [NSTimeZone timeZoneWithName:json[@"timeZone"] ?: @""] + ?: [NSTimeZone localTimeZone]; + NSCalendar* calendar = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian]; + calendar.timeZone = zone; + NSDate* date = + [NSDate dateWithTimeIntervalSince1970:[json[@"epochMs"] doubleValue] / 1000.0]; + id match = json[@"match"]; + NSCalendarUnit time = + NSCalendarUnitHour | NSCalendarUnitMinute | NSCalendarUnitSecond; + NSCalendarUnit units = NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay | time; + if ([match isKindOfClass:[NSNumber class]]) { + // DateTimeComponents: time, dayOfWeekAndTime, dayOfMonthAndTime, + // dateAndTime. + switch ([match integerValue]) { + case 0: + units = time; + break; + case 1: + units = NSCalendarUnitWeekday | time; + break; + case 2: + units = NSCalendarUnitDay | time; + break; + default: + units = NSCalendarUnitMonth | NSCalendarUnitDay | time; + break; + } + } + NSDateComponents* components = [calendar components:units fromDate:date]; + components.calendar = calendar; + components.timeZone = zone; + return [UNCalendarNotificationTrigger + triggerWithDateMatchingComponents:components + repeats:[match isKindOfClass:[NSNumber class]]]; + } + return nil; +} + +static UNMutableNotificationContent* _content(NSDictionary* json, NSError** error) { + UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init]; + id title = json[@"title"]; + id body = json[@"body"]; + if ([title isKindOfClass:[NSString class]]) { + content.title = title; + } + if ([body isKindOfClass:[NSString class]]) { + content.body = body; + } + id raw = json[@"details"]; + NSDictionary* details = [raw isKindOfClass:[NSDictionary class]] ? raw : @{}; + if ([details[@"subtitle"] isKindOfClass:[NSString class]]) { + content.subtitle = details[@"subtitle"]; + } + if ([details[@"threadIdentifier"] isKindOfClass:[NSString class]]) { + content.threadIdentifier = details[@"threadIdentifier"]; + } + if ([details[@"categoryIdentifier"] isKindOfClass:[NSString class]]) { + content.categoryIdentifier = details[@"categoryIdentifier"]; + } + if ([details[@"badgeNumber"] isKindOfClass:[NSNumber class]]) { + content.badge = details[@"badgeNumber"]; + } + NSMutableDictionary* present = [NSMutableDictionary dictionary]; + for (NSString* key in @[ @"presentAlert", @"presentSound", @"presentBadge", + @"presentBanner", @"presentList" ]) { + if ([details[key] isKindOfClass:[NSNumber class]]) { + present[key] = details[key]; + } + } + // watchOS plays only the default sound: there is no soundNamed: here. + if (_present(present, @"presentSound")) { + content.sound = [UNNotificationSound defaultSound]; + } + if (@available(watchOS 8.0, *)) { + id level = details[@"interruptionLevel"]; + if ([level isKindOfClass:[NSNumber class]]) { + // InterruptionLevel: passive, active, timeSensitive, critical. + switch ([level integerValue]) { + case 0: + content.interruptionLevel = UNNotificationInterruptionLevelPassive; + break; + case 2: + content.interruptionLevel = UNNotificationInterruptionLevelTimeSensitive; + break; + case 3: + content.interruptionLevel = UNNotificationInterruptionLevelCritical; + break; + default: + content.interruptionLevel = UNNotificationInterruptionLevelActive; + break; + } + } + } + NSMutableArray* attachments = [NSMutableArray array]; + for (NSDictionary* item in details[@"attachments"]) { + if (![item isKindOfClass:[NSDictionary class]] || + ![item[@"filePath"] isKindOfClass:[NSString class]]) { + continue; + } + NSDictionary* options = _flag(item, @"hideThumbnail") + ? @{UNNotificationAttachmentOptionsThumbnailHiddenKey : @YES} + : nil; + UNNotificationAttachment* attachment = [UNNotificationAttachment + attachmentWithIdentifier:[item[@"identifier"] isKindOfClass:[NSString class]] + ? item[@"identifier"] + : @"" + URL:[NSURL fileURLWithPath:item[@"filePath"]] + options:options + error:error]; + if (attachment == nil) { + return nil; + } + [attachments addObject:attachment]; + } + content.attachments = attachments; + content.userInfo = @{ + kIdKey : json[@"id"] ?: @0, + kPayloadKey : [json[@"payload"] isKindOfClass:[NSString class]] ? json[@"payload"] : @"", + kPresentKey : present, + }; + return content; +} + +static NSDictionary* _describe(UNNotificationRequest* request) { + NSDictionary* info = request.content.userInfo; + return @{ + @"id" : info[kIdKey], + @"title" : request.content.title ?: @"", + @"body" : request.content.body ?: @"", + @"payload" : info[kPayloadKey] ?: [NSNull null], + @"threadIdentifier" : request.content.threadIdentifier ?: @"", + }; +} + +// ---- FFI -------------------------------------------------------------------- + +void flutter_local_notifications_watchos_set_callback(flnw_signal_callback callback) { + os_unfair_lock_lock(&_lock); + _callback = callback; + os_unfair_lock_unlock(&_lock); +} + +int64_t flutter_local_notifications_watchos_initialize(const char* json) { + int64_t request = _newRequest(); + NSDictionary* settings = _parse(json); + NSMutableSet* categories = [NSMutableSet set]; + for (NSDictionary* category in settings[@"categories"]) { + if ([category isKindOfClass:[NSDictionary class]] && + [category[@"identifier"] isKindOfClass:[NSString class]]) { + [categories addObject:_category(category)]; + } + } + if (categories.count > 0) { + [[UNUserNotificationCenter currentNotificationCenter] + setNotificationCategories:categories]; + } + os_unfair_lock_lock(&_lock); + id defaults = settings[@"defaults"]; + _defaults = [defaults isKindOfClass:[NSDictionary class]] ? defaults : nil; + _initialized = YES; + os_unfair_lock_unlock(&_lock); + + id asked = settings[@"request"]; + UNAuthorizationOptions options = + _authorizationOptions([asked isKindOfClass:[NSDictionary class]] ? asked : @{}); + if (options == 0) { + _finish(request, @{@"granted" : @YES}); + } else { + _requestAuthorization(request, options); + } + return request; +} + +int64_t flutter_local_notifications_watchos_request_permissions(const char* json) { + int64_t request = _newRequest(); + _requestAuthorization(request, _authorizationOptions(_parse(json))); + return request; +} + +int64_t flutter_local_notifications_watchos_check_permissions(void) { + int64_t request = _newRequest(); + [[UNUserNotificationCenter currentNotificationCenter] + getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* s) { + BOOL enabled = s.authorizationStatus == UNAuthorizationStatusAuthorized || + s.authorizationStatus == UNAuthorizationStatusProvisional; + _finish(request, @{ + @"isEnabled" : @(enabled), + @"isAlertEnabled" : @(s.alertSetting == UNNotificationSettingEnabled), + @"isSoundEnabled" : @(s.soundSetting == UNNotificationSettingEnabled), + // watchOS shows no badges and has no badge setting. + @"isBadgeEnabled" : @NO, + @"isProvisionalEnabled" : + @(s.authorizationStatus == UNAuthorizationStatusProvisional), + @"isCriticalEnabled" : @(s.criticalAlertSetting == UNNotificationSettingEnabled), + }); + }]; + return request; +} + +int64_t flutter_local_notifications_watchos_add(const char* json) { + int64_t request = _newRequest(); + NSDictionary* notification = _parse(json); + NSError* error = nil; + UNMutableNotificationContent* content = _content(notification, &error); + if (content == nil) { + _fail(request, @"attachment", error); + return request; + } + id rawTrigger = notification[@"trigger"]; + UNNotificationTrigger* trigger = + _trigger([rawTrigger isKindOfClass:[NSDictionary class]] ? rawTrigger : @{}); + NSString* identifier = [notification[@"id"] stringValue] ?: @"0"; + UNNotificationRequest* unRequest = [UNNotificationRequest requestWithIdentifier:identifier + content:content + trigger:trigger]; + [[UNUserNotificationCenter currentNotificationCenter] + addNotificationRequest:unRequest + withCompletionHandler:^(NSError* addError) { + if (addError != nil) { + _fail(request, @"add", addError); + return; + } + _finish(request, @{}); + }]; + return request; +} + +void flutter_local_notifications_watchos_cancel(int64_t notification) { + NSString* identifier = [NSString stringWithFormat:@"%lld", (long long)notification]; + UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; + [center removePendingNotificationRequestsWithIdentifiers:@[ identifier ]]; + [center removeDeliveredNotificationsWithIdentifiers:@[ identifier ]]; +} + +void flutter_local_notifications_watchos_cancel_all(int pending_only) { + UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; + [center removeAllPendingNotificationRequests]; + if (pending_only == 0) { + [center removeAllDeliveredNotifications]; + } +} + +int64_t flutter_local_notifications_watchos_pending(void) { + int64_t request = _newRequest(); + [[UNUserNotificationCenter currentNotificationCenter] + getPendingNotificationRequestsWithCompletionHandler:^( + NSArray* requests) { + NSMutableArray* out = [NSMutableArray array]; + for (UNNotificationRequest* r in requests) { + if (r.content.userInfo[kIdKey] != nil) { + [out addObject:_describe(r)]; + } + } + _finish(request, out); + }]; + return request; +} + +int64_t flutter_local_notifications_watchos_active(void) { + int64_t request = _newRequest(); + [[UNUserNotificationCenter currentNotificationCenter] + getDeliveredNotificationsWithCompletionHandler:^( + NSArray* notifications) { + NSMutableArray* out = [NSMutableArray array]; + for (UNNotification* n in notifications) { + if (n.request.content.userInfo[kIdKey] != nil) { + [out addObject:_describe(n.request)]; + } + } + _finish(request, out); + }]; + return request; +} + +char* flutter_local_notifications_watchos_launch_details(void) { + os_unfair_lock_lock(&_lock); + NSDictionary* launch = _launch; + os_unfair_lock_unlock(&_lock); + return _json(@{ + @"launched" : @(launch != nil), + @"response" : launch ?: [NSNull null], + }); +} + +char* flutter_local_notifications_watchos_poll_responses(void) { + os_unfair_lock_lock(&_lock); + NSArray* drained = _responses.count == 0 ? nil : [_responses copy]; + [_responses removeAllObjects]; + os_unfair_lock_unlock(&_lock); + return _json(drained); +} + +char* flutter_local_notifications_watchos_take_request_result(int64_t request) { + os_unfair_lock_lock(&_lock); + id result = _results[@(request)]; + [_results removeObjectForKey:@(request)]; + os_unfair_lock_unlock(&_lock); + return _json(result); +} + +void flutter_local_notifications_watchos_release(char* string) { + free(string); +} diff --git a/packages/flutter_local_notifications_watchos/watchos/Package.swift b/packages/flutter_local_notifications_watchos/watchos/Package.swift new file mode 100644 index 0000000..86d0745 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/watchos/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version:5.9 +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// FFI plugin manifest. The flutter-watchos CLI discovers the plugin through +// this file, compiles `Classes/*.m` into a static archive force-loaded into +// the watch binary, and links the frameworks declared below. Naming +// UserNotifications also makes the CLI check that the app installs the +// host's application delegate, which forwards the notification callbacks. +import PackageDescription + +let package = Package( + name: "flutter_local_notifications_watchos", + platforms: [ + .watchOS("7.0"), + ], + products: [ + .library(name: "flutter-local-notifications-watchos", + targets: ["flutter_local_notifications_watchos"]), + ], + targets: [ + .target( + name: "flutter_local_notifications_watchos", + path: "Classes", + publicHeadersPath: ".", + cSettings: [ + .headerSearchPath("."), + ], + linkerSettings: [ + .linkedFramework("Foundation"), + .linkedFramework("UserNotifications"), + ] + ), + ] +) diff --git a/tool/upstream_versions.yaml b/tool/upstream_versions.yaml index 08ec57e..ec840e6 100644 --- a/tool/upstream_versions.yaml +++ b/tool/upstream_versions.yaml @@ -15,6 +15,7 @@ upstreams: firebase_core: 4.15.0 firebase_messaging: 16.7.0 firebase_storage: 13.6.0 + flutter_local_notifications: 22.3.2 flutter_secure_storage: 11.2.0 games_services: 5.3.0 geolocator: 14.1.1 diff --git a/tool/words/forbidden_words_allow.txt b/tool/words/forbidden_words_allow.txt index 8d13002..113aad6 100644 --- a/tool/words/forbidden_words_allow.txt +++ b/tool/words/forbidden_words_allow.txt @@ -38,6 +38,7 @@ packages/flutter_secure_storage_watchos/lib/flutter_secure_storage_watchos.dart packages/firebase_*_watchos/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. packages/flutter_secure_storage_watchos/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. packages/flutter_watch_link/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. +packages/flutter_local_notifications_watchos/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. packages/games_services_watchos/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. packages/in_app_purchase_watchos/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. packages/network_info_plus_watchos/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. From 2d190bc9a89a7d66a034936cb723dc2d64a9ed36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20Arslanta=C5=9F?= Date: Wed, 21 Oct 2026 11:44:25 +0200 Subject: [PATCH 2/5] flutter_local_notifications_watchos: launch details and permission flags 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 --- .../PORTING_REPORT.md | 9 +- .../README.md | 4 +- .../watchos_notifications_test.dart | 94 +++++++++++++++++++ .../example/lib/main.dart | 12 ++- .../flutter_local_notifications_watchos.dart | 6 +- ...tter_local_notifications_watchos_test.dart | 20 ++++ .../flutter_local_notifications_watchos_ffi.h | 6 +- .../flutter_local_notifications_watchos_ffi.m | 28 ++++-- 8 files changed, 157 insertions(+), 22 deletions(-) create mode 100644 packages/flutter_local_notifications_watchos/example/integration_test/watchos_notifications_test.dart diff --git a/packages/flutter_local_notifications_watchos/PORTING_REPORT.md b/packages/flutter_local_notifications_watchos/PORTING_REPORT.md index 4906204..0e4282d 100644 --- a/packages/flutter_local_notifications_watchos/PORTING_REPORT.md +++ b/packages/flutter_local_notifications_watchos/PORTING_REPORT.md @@ -39,8 +39,8 @@ flutter-watchos host is (`FlutterWatchOSAppDelegate`), and it rebroadcasts "will present" and "did receive response" as NSNotifications. The native file observes them from a load-time constructor, so the tap that launched the app, which the host does not keep, is caught before Dart runs; a tap -that arrives before `initialize` is the launch tap, later ones queue for -Dart. Each notification carries its id, payload and presentation flags in +that arrives before `initialize` and before Dart reads the launch details +is the launch tap, later ones queue for the response callback. Each notification carries its id, payload and presentation flags in `userInfo` under keys of this package's own, and callbacks for notifications without them (pushes, other plugins) are left alone. @@ -53,7 +53,10 @@ flutter_local_notifications' own example app is a phone demo that needs `flutter_timezone`, which has no watchOS implementation, so `example/lib/main.dart` is a small watch demo instead. `example/integration_test/flutter_local_notifications_test.dart` is -upstream's test, unchanged. +upstream's test, unchanged. `watchos_notifications_test.dart` next to it +checks what upstream's does not reach on the watch: the permission flags, +the launch details, and scheduling, listing and cancelling against the real +notification center. ## Interface coverage diff --git a/packages/flutter_local_notifications_watchos/README.md b/packages/flutter_local_notifications_watchos/README.md index 7c7bd81..ba0780a 100644 --- a/packages/flutter_local_notifications_watchos/README.md +++ b/packages/flutter_local_notifications_watchos/README.md @@ -49,8 +49,8 @@ missing. | `periodicallyShow` / `periodicallyShowWithDuration` | supported: a repeating interval trigger | | `cancel` / `cancelAll` / `cancelAllPendingNotifications` | supported | | `pendingNotificationRequests` / `getActiveNotifications` | supported, for this package's notifications | -| `getNotificationAppLaunchDetails` | supported: the tap that launched the app | -| `onDidReceiveNotificationResponse` | taps and action taps, with the notification's id and payload | +| `getNotificationAppLaunchDetails` | supported: the tap that launched the app. A launch tap that comes in only after this was asked is not lost: it reaches `onDidReceiveNotificationResponse` once `initialize` has run | +| `onDidReceiveNotificationResponse` | taps and action taps, with the notification's id and payload. A banner shown while the app is in front closes when tapped and sends no response: watchOS does not report that tap | Of `DarwinNotificationDetails`, these reach the notification: the five `present…` flags (for a notification arriving while the app is in front), diff --git a/packages/flutter_local_notifications_watchos/example/integration_test/watchos_notifications_test.dart b/packages/flutter_local_notifications_watchos/example/integration_test/watchos_notifications_test.dart new file mode 100644 index 0000000..e08bb59 --- /dev/null +++ b/packages/flutter_local_notifications_watchos/example/integration_test/watchos_notifications_test.dart @@ -0,0 +1,94 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// Supplementary watchOS test, next to upstream's +// flutter_local_notifications_test.dart (which it does not replace). A run +// that installs the app afresh asks for permission to notify: allow it, as +// the permission test expects. +// +// Native hands every answer to Dart as JSON, and a flag that reaches JSON as +// 1 instead of true reads as false in Dart. These tests read the flags from +// the real notification center, where a fake backend cannot catch that. + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:timezone/timezone.dart' as tz; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final FlutterLocalNotificationsPlugin plugin = + FlutterLocalNotificationsPlugin(); + + setUpAll(() async { + tzdata.initializeTimeZones(); + await plugin.initialize( + settings: + const InitializationSettings(iOS: DarwinInitializationSettings()), + ); + }); + + tearDown(() => plugin.cancelAll()); + + testWidgets('a launch no notification started reports none', + (WidgetTester _) async { + final NotificationAppLaunchDetails? details = + await plugin.getNotificationAppLaunchDetails(); + expect(details!.didNotificationLaunchApp, isFalse); + expect(details.notificationResponse, isNull); + }); + + testWidgets('checkPermissions reports the alerts the app was allowed', + (WidgetTester _) async { + final NotificationsEnabledOptions? options = await plugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>()! + .checkPermissions(); + expect(options!.isEnabled, isTrue); + expect(options.isAlertEnabled, isTrue); + expect(options.isBadgeEnabled, isFalse); + }); + + testWidgets('a scheduled notification is pending until it is cancelled', + (WidgetTester _) async { + await plugin.zonedSchedule( + id: 1001, + title: 'Stretch', + body: 'In an hour', + scheduledDate: tz.TZDateTime.now(tz.UTC).add(const Duration(hours: 1)), + notificationDetails: const NotificationDetails(), + androidScheduleMode: AndroidScheduleMode.inexact, + payload: 'stretch', + ); + final PendingNotificationRequest pending = + (await plugin.pendingNotificationRequests()) + .singleWhere((PendingNotificationRequest p) => p.id == 1001); + expect(pending.title, 'Stretch'); + expect(pending.payload, 'stretch'); + + await plugin.cancel(id: 1001); + expect( + (await plugin.pendingNotificationRequests()) + .where((PendingNotificationRequest p) => p.id == 1001), + isEmpty, + ); + }); + + testWidgets('a shown notification is listed as delivered', + (WidgetTester _) async { + await plugin.show(id: 1002, title: 'Lap 1', body: '5:30 /km'); + // Delivery is asynchronous; give the notification center a moment. + List active = []; + for (int i = 0; i < 20; i++) { + active = await plugin.getActiveNotifications(); + if (active.any((ActiveNotification n) => n.id == 1002)) { + break; + } + await Future.delayed(const Duration(milliseconds: 250)); + } + expect(active.singleWhere((ActiveNotification n) => n.id == 1002).title, + 'Lap 1'); + }); +} diff --git a/packages/flutter_local_notifications_watchos/example/lib/main.dart b/packages/flutter_local_notifications_watchos/example/lib/main.dart index b753b6a..e96a5a5 100644 --- a/packages/flutter_local_notifications_watchos/example/lib/main.dart +++ b/packages/flutter_local_notifications_watchos/example/lib/main.dart @@ -65,11 +65,13 @@ class _NotificationsPageState extends State { }, ); setState(() { - _status = launch?.didNotificationLaunchApp == true - ? 'launched by #${launch!.notificationResponse?.id}' - : granted == true - ? 'allowed' - : 'not allowed'; + if (launch?.didNotificationLaunchApp == true) { + _status = 'launched by #${launch!.notificationResponse?.id}'; + } else if (!_status.startsWith('tapped')) { + // A launch tap that came in after the launch details were read + // arrived as a response; keep it on screen. + _status = granted == true ? 'allowed' : 'not allowed'; + } }); } diff --git a/packages/flutter_local_notifications_watchos/lib/flutter_local_notifications_watchos.dart b/packages/flutter_local_notifications_watchos/lib/flutter_local_notifications_watchos.dart index e815677..44b4d33 100644 --- a/packages/flutter_local_notifications_watchos/lib/flutter_local_notifications_watchos.dart +++ b/packages/flutter_local_notifications_watchos/lib/flutter_local_notifications_watchos.dart @@ -427,7 +427,11 @@ class FlutterLocalNotificationsWatchos ]; /// The tap that launched the app, if one did: a tap that arrived before - /// [initialize] ran. + /// this was first asked and before [initialize] ran. + /// + /// The host hands the tap over on the main thread while Dart runs on its + /// own, so it can come in after this was asked. It is not lost then: it + /// reaches `onDidReceiveNotificationResponse` once [initialize] has run. @override Future getNotificationAppLaunchDetails() async { diff --git a/packages/flutter_local_notifications_watchos/test/flutter_local_notifications_watchos_test.dart b/packages/flutter_local_notifications_watchos/test/flutter_local_notifications_watchos_test.dart index 206935b..1d6c766 100644 --- a/packages/flutter_local_notifications_watchos/test/flutter_local_notifications_watchos_test.dart +++ b/packages/flutter_local_notifications_watchos/test/flutter_local_notifications_watchos_test.dart @@ -190,6 +190,26 @@ void main() { NotificationResponseType.selectedNotificationAction); }); + test('a tap queued before initialize reaches the callback once it runs', + () async { + // What native does with a launch tap watchOS hands over after Dart has + // already asked for the launch details. + fake.tap({ + 'id': 8, + 'actionId': null, + 'payload': 'late', + 'type': 0, + }); + final List taps = []; + await plugin.initialize( + settings: + const InitializationSettings(iOS: DarwinInitializationSettings()), + onDidReceiveNotificationResponse: taps.add, + ); + expect(taps.single.id, 8); + expect(taps.single.payload, 'late'); + }); + test('the launch tap is reported by getNotificationAppLaunchDetails', () async { fake.launch = jsonEncode({ diff --git a/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.h b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.h index d59c3c1..c9b5b7e 100644 --- a/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.h +++ b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.h @@ -82,11 +82,13 @@ FLNW_EXPORT int64_t flutter_local_notifications_watchos_active(void); // {"launched": , "response": {"id", "actionId", "payload", "type"} or -// null}: the tap that launched the app, if one did. +// null}: the tap that launched the app, if it arrived before this was first +// asked and before `initialize`. A tap after that queues for the response +// callback instead, so it is never lost. FLNW_EXPORT char* flutter_local_notifications_watchos_launch_details(void); -// Drains the taps queued since `initialize`, as a JSON array of {"id", +// Drains the queued taps (all but the launch tap), as a JSON array of {"id", // "actionId", "payload", "type"}; NULL when none are queued. FLNW_EXPORT char* flutter_local_notifications_watchos_poll_responses(void); diff --git a/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m index 376712b..a62177c 100644 --- a/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m +++ b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m @@ -32,6 +32,9 @@ static os_unfair_lock _lock = OS_UNFAIR_LOCK_INIT; static flnw_signal_callback _callback = NULL; static BOOL _initialized = NO; +// Set once Dart has asked for the launch tap: a tap after that is no longer +// the answer it got, so it queues for the response callback instead. +static BOOL _launchRead = NO; static NSDictionary* _launch = nil; static NSMutableArray* _responses = nil; static NSDictionary* _defaults = nil; @@ -202,11 +205,14 @@ static void _onResponse(NSNotification* note) { }; os_unfair_lock_lock(&_lock); BOOL initialized = _initialized; - if (!initialized) { - if (_launch == nil) { - _launch = response; - } + if (!initialized && !_launchRead && _launch == nil) { + // The tap that launched the app, for getNotificationAppLaunchDetails. + _launch = response; } else { + // For the response callback: at once when `initialize` has run, + // otherwise when it does. The host posts the tap from the main thread + // while Dart runs on its own, so the launch tap can also come in after + // Dart has read the launch details; it must not be lost then. if (_responses == nil) { _responses = [NSMutableArray array]; } @@ -456,15 +462,18 @@ int64_t flutter_local_notifications_watchos_check_permissions(void) { getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* s) { BOOL enabled = s.authorizationStatus == UNAuthorizationStatusAuthorized || s.authorizationStatus == UNAuthorizationStatusProvisional; + // A comparison is an int in C, so it is cast to BOOL to reach JSON + // as true or false, not 1 or 0, which Dart would read as false. _finish(request, @{ @"isEnabled" : @(enabled), - @"isAlertEnabled" : @(s.alertSetting == UNNotificationSettingEnabled), - @"isSoundEnabled" : @(s.soundSetting == UNNotificationSettingEnabled), + @"isAlertEnabled" : @((BOOL)(s.alertSetting == UNNotificationSettingEnabled)), + @"isSoundEnabled" : @((BOOL)(s.soundSetting == UNNotificationSettingEnabled)), // watchOS shows no badges and has no badge setting. @"isBadgeEnabled" : @NO, @"isProvisionalEnabled" : - @(s.authorizationStatus == UNAuthorizationStatusProvisional), - @"isCriticalEnabled" : @(s.criticalAlertSetting == UNNotificationSettingEnabled), + @((BOOL)(s.authorizationStatus == UNAuthorizationStatusProvisional)), + @"isCriticalEnabled" : + @((BOOL)(s.criticalAlertSetting == UNNotificationSettingEnabled)), }); }]; return request; @@ -548,9 +557,10 @@ int64_t flutter_local_notifications_watchos_active(void) { char* flutter_local_notifications_watchos_launch_details(void) { os_unfair_lock_lock(&_lock); NSDictionary* launch = _launch; + _launchRead = YES; os_unfair_lock_unlock(&_lock); return _json(@{ - @"launched" : @(launch != nil), + @"launched" : @((BOOL)(launch != nil)), @"response" : launch ?: [NSNull null], }); } From 396e2254415e8377db8d1320d953ba3c62434708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20Arslanta=C5=9F?= Date: Wed, 21 Oct 2026 12:03:31 +0200 Subject: [PATCH 3/5] flutter_local_notifications_watchos: format upstream's integration test 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 --- .../PORTING_REPORT.md | 3 ++- .../flutter_local_notifications_test.dart | 21 +++++++------------ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/flutter_local_notifications_watchos/PORTING_REPORT.md b/packages/flutter_local_notifications_watchos/PORTING_REPORT.md index 0e4282d..63e545c 100644 --- a/packages/flutter_local_notifications_watchos/PORTING_REPORT.md +++ b/packages/flutter_local_notifications_watchos/PORTING_REPORT.md @@ -53,7 +53,8 @@ flutter_local_notifications' own example app is a phone demo that needs `flutter_timezone`, which has no watchOS implementation, so `example/lib/main.dart` is a small watch demo instead. `example/integration_test/flutter_local_notifications_test.dart` is -upstream's test, unchanged. `watchos_notifications_test.dart` next to it +upstream's test, unchanged but for the formatting this repo's CI checks. +`watchos_notifications_test.dart` next to it checks what upstream's does not reach on the watch: the permission flags, the launch details, and scheduling, listing and cancelling against the real notification center. diff --git a/packages/flutter_local_notifications_watchos/example/integration_test/flutter_local_notifications_test.dart b/packages/flutter_local_notifications_watchos/example/integration_test/flutter_local_notifications_test.dart index fc112bb..4f56bef 100644 --- a/packages/flutter_local_notifications_watchos/example/integration_test/flutter_local_notifications_test.dart +++ b/packages/flutter_local_notifications_watchos/example/integration_test/flutter_local_notifications_test.dart @@ -14,9 +14,9 @@ void main() { DarwinInitializationSettings(); final LinuxInitializationSettings initializationSettingsLinux = LinuxInitializationSettings( - defaultActionName: 'Open notification', - defaultIcon: AssetsLinuxIcon('icons/app_icon.png'), - ); + defaultActionName: 'Open notification', + defaultIcon: AssetsLinuxIcon('icons/app_icon.png'), + ); final InitializationSettings initializationSettings = InitializationSettings( android: initializationSettingsAndroid, iOS: initializationSettingsIOS, @@ -86,10 +86,8 @@ void main() { WidgetTester tester, ) async { expect( - flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), + flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>(), isA(), ); }); @@ -102,8 +100,7 @@ void main() { expect( flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), + AndroidFlutterLocalNotificationsPlugin>(), isA(), ); }, @@ -118,8 +115,7 @@ void main() { expect( flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), + AndroidFlutterLocalNotificationsPlugin>(), isNull, ); }, @@ -133,8 +129,7 @@ void main() { expect( flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), + IOSFlutterLocalNotificationsPlugin>(), isNull, ); }, From 7d903c95cd2f8227404683c52d2650476ebcc673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20Arslanta=C5=9F?= Date: Sat, 24 Oct 2026 00:18:53 +0200 Subject: [PATCH 4/5] flutter_local_notifications_watchos: the review's fixes From the review of PR #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 --- .../CHANGELOG.md | 16 ++- .../LICENSE | 9 +- .../PORTING_REPORT.md | 12 +- .../README.md | 37 ++++-- .../watchos_notifications_test.dart | 30 +++-- .../flutter_local_notifications_watchos.dart | 21 +++- ...tter_local_notifications_watchos_test.dart | 25 ++++ .../flutter_local_notifications_watchos_ffi.m | 115 ++++++++++++++---- tool/words/forbidden_words_allow.txt | 2 +- 9 files changed, 210 insertions(+), 57 deletions(-) diff --git a/packages/flutter_local_notifications_watchos/CHANGELOG.md b/packages/flutter_local_notifications_watchos/CHANGELOG.md index 8020e6e..5989b6b 100644 --- a/packages/flutter_local_notifications_watchos/CHANGELOG.md +++ b/packages/flutter_local_notifications_watchos/CHANGELOG.md @@ -12,7 +12,21 @@ physical watch. launch tap. * Taps reach `onDidReceiveNotificationResponse` through the flutter-watchos host's application delegate, observed from a load-time constructor so the - launch tap is not missed. + launch tap is not missed. As upstream, only a tap on the notification or + on an action that opens the app is the launch tap, and a dismissal is + reported only for a notification whose details set `dismissIsolate`; + background actions and those dismissals reach + `onDidReceiveNotificationResponse`, since the watch has no background + isolate. +* As upstream 22.3.2, an error in `UNErrorDomain` from adding a request is + ignored: watchOS 27, like iOS 27, reports a missing permission that way. +* As upstream on iOS 14 and later, `presentAlert` is not read: the banner + and the list have flags of their own. `checkPermissions` reports + provisional authorization apart from `isEnabled`, and `initialize` answers + false when it asks for nothing. +* `providesAppNotificationSettings` is asked for and reported; + `criticalSoundVolume` plays the default critical sound at that volume. +* LICENSE carries Michael Bui's notice, as upstream's does. * The example's `lib/` is a small watch demo, because the upstream example needs `flutter_timezone`; its official integration test is upstream's, unchanged. diff --git a/packages/flutter_local_notifications_watchos/LICENSE b/packages/flutter_local_notifications_watchos/LICENSE index 05260aa..e157c28 100644 --- a/packages/flutter_local_notifications_watchos/LICENSE +++ b/packages/flutter_local_notifications_watchos/LICENSE @@ -1,16 +1,17 @@ +Copyright 2018 Michael Bui. All rights reserved. Copyright 2026 The FlutterWatch Authors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright + * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - * Neither the name of FlutterWatch nor the names of its + * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. @@ -24,4 +25,4 @@ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/flutter_local_notifications_watchos/PORTING_REPORT.md b/packages/flutter_local_notifications_watchos/PORTING_REPORT.md index 63e545c..1f294b3 100644 --- a/packages/flutter_local_notifications_watchos/PORTING_REPORT.md +++ b/packages/flutter_local_notifications_watchos/PORTING_REPORT.md @@ -57,7 +57,9 @@ upstream's test, unchanged but for the formatting this repo's CI checks. `watchos_notifications_test.dart` next to it checks what upstream's does not reach on the watch: the permission flags, the launch details, and scheduling, listing and cancelling against the real -notification center. +notification center. The cases that need permission ask for it only with +`--dart-define=FLNW_PROMPT=true`, when someone answers the prompt; without it +they are skipped and the suite asks for nothing, so it cannot hang. ## Interface coverage @@ -67,10 +69,10 @@ Every member of `IOSFlutterLocalNotificationsPlugin` and the | Member | watchOS | |---|---| -| `initialize` | ◐ `onDidReceiveBackgroundNotificationResponse` is not called | -| `requestPermissions` | ✅ `carPlay` and `providesAppNotificationSettings` have nothing to ask for | -| `checkPermissions` | ◐ `isBadgeEnabled` is always false | -| `show` / `zonedSchedule` / `periodicallyShow` / `periodicallyShowWithDuration` | ◐ the default sound only | +| `initialize` | ◐ `onDidReceiveBackgroundNotificationResponse` is not called: background actions, and dismissals of notifications with `dismissIsolate` set, reach `onDidReceiveNotificationResponse` | +| `requestPermissions` | ✅ `providesAppNotificationSettings` (watchOS 5+) is asked for; `carPlay` has nothing to ask for. False when none of alert, sound, badge, critical and app settings is asked, as on iOS | +| `checkPermissions` | ◐ `isBadgeEnabled` is always false; `isEnabled` is for full authorization only, provisional is `isProvisionalEnabled`, as on iOS | +| `show` / `zonedSchedule` / `periodicallyShow` / `periodicallyShowWithDuration` | ◐ the default sound only; `criticalSoundVolume` plays the default critical sound at that volume, since watchOS has no named critical sound. A missing permission is ignored, as upstream 22.3.2 ignores `UNErrorDomain` errors | | `cancel` / `cancelAll` / `cancelAllPendingNotifications` | ✅ | | `pendingNotificationRequests` / `getActiveNotifications` | ✅ | | `getNotificationAppLaunchDetails` | ✅ | diff --git a/packages/flutter_local_notifications_watchos/README.md b/packages/flutter_local_notifications_watchos/README.md index ba0780a..4279791 100644 --- a/packages/flutter_local_notifications_watchos/README.md +++ b/packages/flutter_local_notifications_watchos/README.md @@ -50,12 +50,25 @@ missing. | `cancel` / `cancelAll` / `cancelAllPendingNotifications` | supported | | `pendingNotificationRequests` / `getActiveNotifications` | supported, for this package's notifications | | `getNotificationAppLaunchDetails` | supported: the tap that launched the app. A launch tap that comes in only after this was asked is not lost: it reaches `onDidReceiveNotificationResponse` once `initialize` has run | -| `onDidReceiveNotificationResponse` | taps and action taps, with the notification's id and payload. A banner shown while the app is in front closes when tapped and sends no response: watchOS does not report that tap | - -Of `DarwinNotificationDetails`, these reach the notification: the five -`present…` flags (for a notification arriving while the app is in front), -`subtitle`, `threadIdentifier`, `categoryIdentifier`, `badgeNumber`, -`interruptionLevel` and `attachments`. +| `onDidReceiveNotificationResponse` | taps and action taps, with the notification's id and payload; background actions too, since the watch app has no background isolate, and dismissals of notifications whose details set `dismissIsolate`. A banner shown while the app is in front closes when tapped and sends no response: watchOS does not report that tap | + +Of `DarwinNotificationDetails`, these reach the notification: the +`present…` flags (for a notification arriving while the app is in front; +`presentBanner` and `presentList` decide, and `presentAlert` is not read, as +on iOS 14 and later), `subtitle`, `threadIdentifier`, `categoryIdentifier`, +`badgeNumber`, `interruptionLevel`, `criticalSoundVolume`, `dismissIsolate` +and `attachments`. + +`InterruptionLevel.timeSensitive` needs the Time Sensitive Notifications +capability (`com.apple.developer.usernotifications.time-sensitive`) in the +watch app's entitlements, and critical alerts, `requestCriticalPermission` +and `criticalSoundVolume` need the Critical Alerts entitlement +(`com.apple.developer.usernotifications.critical-alerts`), which Apple grants +on request. + +`flutter_timezone` has no watchOS implementation, so `tz.local` stays UTC on +the watch unless the app sets it: pass `zonedSchedule` a `TZDateTime` in the +zone you mean, for example from `tz.getLocation('Europe/Istanbul')`. ## Not supported on watchOS @@ -66,6 +79,16 @@ Of `DarwinNotificationDetails`, these reach the notification: the five | `onDidReceiveBackgroundNotificationResponse` | not called; every tap reaches `onDidReceiveNotificationResponse` | the watch app handles taps in its own isolate | | `openAppNotificationSettings` | returns false | watchOS has no settings page an app can open | +## Following upstream + +This class extends upstream's concrete `IOSFlutterLocalNotificationsPlugin`, +as flutter_local_notifications requires on iOS-reporting platforms. A method +an upstream release adds to that class, a minor release included, reaches +its method channel here and throws `MissingPluginException` until this +package overrides it too. 23.0.0 is in development. + ## License -The FlutterWatch Authors under a BSD-3-Clause license. See `LICENSE` for the full text. +BSD-3-Clause, as upstream flutter_local_notifications: `LICENSE` carries +Michael Bui's notice and The FlutterWatch Authors'. See `LICENSE` for the +full text. diff --git a/packages/flutter_local_notifications_watchos/example/integration_test/watchos_notifications_test.dart b/packages/flutter_local_notifications_watchos/example/integration_test/watchos_notifications_test.dart index e08bb59..18c69ee 100644 --- a/packages/flutter_local_notifications_watchos/example/integration_test/watchos_notifications_test.dart +++ b/packages/flutter_local_notifications_watchos/example/integration_test/watchos_notifications_test.dart @@ -3,9 +3,13 @@ // found in the LICENSE file. // // Supplementary watchOS test, next to upstream's -// flutter_local_notifications_test.dart (which it does not replace). A run -// that installs the app afresh asks for permission to notify: allow it, as -// the permission test expects. +// flutter_local_notifications_test.dart (which it does not replace). +// +// The cases that need permission to notify ask for it, which shows a system +// prompt with no one to answer it on an unattended run, so they run only with +// --dart-define=FLNW_PROMPT=true, where someone allows the prompt (a run +// installs the app afresh, so it comes back each time). Without it the suite +// asks for nothing and those cases are skipped. // // Native hands every answer to Dart as JSON, and a flag that reaches JSON as // 1 instead of true reads as false in Dart. These tests read the flags from @@ -17,6 +21,11 @@ import 'package:integration_test/integration_test.dart'; import 'package:timezone/data/latest.dart' as tzdata; import 'package:timezone/timezone.dart' as tz; +/// Whether someone answers the permission prompt on this run +/// (`--dart-define=FLNW_PROMPT=true`). The cases that need permission to +/// notify are skipped without it. +const bool _prompt = bool.fromEnvironment('FLNW_PROMPT'); + void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final FlutterLocalNotificationsPlugin plugin = @@ -25,8 +34,13 @@ void main() { setUpAll(() async { tzdata.initializeTimeZones(); await plugin.initialize( - settings: - const InitializationSettings(iOS: DarwinInitializationSettings()), + settings: const InitializationSettings( + iOS: DarwinInitializationSettings( + requestAlertPermission: _prompt, + requestSoundPermission: _prompt, + requestBadgePermission: _prompt, + ), + ), ); }); @@ -49,7 +63,7 @@ void main() { expect(options!.isEnabled, isTrue); expect(options.isAlertEnabled, isTrue); expect(options.isBadgeEnabled, isFalse); - }); + }, skip: !_prompt); testWidgets('a scheduled notification is pending until it is cancelled', (WidgetTester _) async { @@ -74,7 +88,7 @@ void main() { .where((PendingNotificationRequest p) => p.id == 1001), isEmpty, ); - }); + }, skip: !_prompt); testWidgets('a shown notification is listed as delivered', (WidgetTester _) async { @@ -90,5 +104,5 @@ void main() { } expect(active.singleWhere((ActiveNotification n) => n.id == 1002).title, 'Lap 1'); - }); + }, skip: !_prompt); } diff --git a/packages/flutter_local_notifications_watchos/lib/flutter_local_notifications_watchos.dart b/packages/flutter_local_notifications_watchos/lib/flutter_local_notifications_watchos.dart index 44b4d33..04b59a0 100644 --- a/packages/flutter_local_notifications_watchos/lib/flutter_local_notifications_watchos.dart +++ b/packages/flutter_local_notifications_watchos/lib/flutter_local_notifications_watchos.dart @@ -151,6 +151,8 @@ class FlutterLocalNotificationsWatchos 'threadIdentifier': d.threadIdentifier, 'categoryIdentifier': d.categoryIdentifier, 'interruptionLevel': d.interruptionLevel?.index, + 'criticalSoundVolume': d.criticalSoundVolume, + 'dismissIsolate': d.dismissIsolate?.index, 'attachments': d.attachments ?.map((DarwinNotificationAttachment a) => { 'filePath': a.filePath, @@ -187,7 +189,12 @@ class FlutterLocalNotificationsWatchos /// /// [onDidReceiveBackgroundNotificationResponse] is not used: on the watch /// every tap reaches the app's own isolate, through - /// [onDidReceiveNotificationResponse]. + /// [onDidReceiveNotificationResponse]. That includes background actions, + /// and dismissals of notifications whose details set `dismissIsolate`, + /// whichever isolate it names. + /// + /// As on iOS, the result is false when [settings] ask for none of the + /// alert, sound, badge, critical and app-settings permissions. @override Future initialize({ required DarwinInitializationSettings settings, @@ -233,6 +240,8 @@ class FlutterLocalNotificationsWatchos 'badge': settings.requestBadgePermission, 'provisional': settings.requestProvisionalPermission, 'critical': settings.requestCriticalPermission, + 'providesAppNotificationSettings': + settings.requestProvidesAppNotificationSettings, }, }, ))); @@ -258,6 +267,7 @@ class FlutterLocalNotificationsWatchos 'badge': badge, 'provisional': provisional, 'critical': critical, + 'providesAppNotificationSettings': providesAppNotificationSettings, }, ))); return (result as Map?)?['granted'] == true; @@ -275,7 +285,8 @@ class FlutterLocalNotificationsWatchos isBadgeEnabled: flag('isBadgeEnabled'), isProvisionalEnabled: flag('isProvisionalEnabled'), isCriticalEnabled: flag('isCriticalEnabled'), - isProvidesAppNotificationSettingsEnabled: false, + isProvidesAppNotificationSettingsEnabled: + flag('isProvidesAppNotificationSettingsEnabled'), ); } @@ -426,8 +437,10 @@ class FlutterLocalNotificationsWatchos ), ]; - /// The tap that launched the app, if one did: a tap that arrived before - /// this was first asked and before [initialize] ran. + /// The tap that launched the app, if one did: a tap on a notification, or + /// on an action that opens the app, that arrived before this was first + /// asked and before [initialize] ran. A background action or a dismissal + /// is never the launch. /// /// The host hands the tap over on the main thread while Dart runs on its /// own, so it can come in after this was asked. It is not lost then: it diff --git a/packages/flutter_local_notifications_watchos/test/flutter_local_notifications_watchos_test.dart b/packages/flutter_local_notifications_watchos/test/flutter_local_notifications_watchos_test.dart index 1d6c766..04b9c32 100644 --- a/packages/flutter_local_notifications_watchos/test/flutter_local_notifications_watchos_test.dart +++ b/packages/flutter_local_notifications_watchos/test/flutter_local_notifications_watchos_test.dart @@ -167,6 +167,25 @@ void main() { (sent['defaults']! as Map)['presentBanner'], false); expect((sent['request']! as Map)['sound'], false); expect((sent['request']! as Map)['alert'], true); + expect( + (sent['request']! + as Map)['providesAppNotificationSettings'], + false); + }); + + test('requestPermissions asks for app notification settings too', () async { + fake.defaultAnswer = {'granted': true}; + await plugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>()! + .requestPermissions(alert: true, providesAppNotificationSettings: true); + final String call = fake.calls + .singleWhere((String c) => c.startsWith('requestPermissions ')); + final Map options = + jsonDecode(call.substring('requestPermissions '.length)) + as Map; + expect(options['alert'], true); + expect(options['providesAppNotificationSettings'], true); }); test('a tap after initialize reaches the callback', () async { @@ -241,6 +260,8 @@ void main() { threadIdentifier: 'run', categoryIdentifier: 'lap', interruptionLevel: InterruptionLevel.timeSensitive, + criticalSoundVolume: 0.5, + dismissIsolate: NotificationDismissedIsolate.main, ), ), payload: 'lap-3', @@ -257,6 +278,8 @@ void main() { expect(d['threadIdentifier'], 'run'); expect(d['categoryIdentifier'], 'lap'); expect(d['interruptionLevel'], InterruptionLevel.timeSensitive.index); + expect(d['criticalSoundVolume'], 0.5); + expect(d['dismissIsolate'], NotificationDismissedIsolate.main.index); }); test('zonedSchedule sends the instant, its zone and the repeat', () async { @@ -377,6 +400,7 @@ void main() { 'isBadgeEnabled': false, 'isProvisionalEnabled': false, 'isCriticalEnabled': false, + 'isProvidesAppNotificationSettingsEnabled': true, }; final NotificationsEnabledOptions? options = await plugin .resolvePlatformSpecificImplementation< @@ -384,5 +408,6 @@ void main() { .checkPermissions(); expect(options!.isEnabled, isTrue); expect(options.isSoundEnabled, isFalse); + expect(options.isProvidesAppNotificationSettingsEnabled, isTrue); }); } diff --git a/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m index a62177c..6a2a2eb 100644 --- a/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m +++ b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m @@ -20,6 +20,11 @@ static NSString* const kIdKey = @"dev.flutterwatch.flnw.id"; static NSString* const kPayloadKey = @"dev.flutterwatch.flnw.payload"; static NSString* const kPresentKey = @"dev.flutterwatch.flnw.present"; +// DarwinNotificationDetails.dismissIsolate's index, when the notification +// asked for its dismissal to be reported. +static NSString* const kDismissKey = @"dev.flutterwatch.flnw.dismiss"; +// The notification's category's actions that open the app. +static NSString* const kForegroundKey = @"dev.flutterwatch.flnw.foreground"; // Posted by the flutter-watchos host's application delegate. static NSString* const kWillPresent = @"FlutterWatchOSNotificationWillPresent"; @@ -38,6 +43,9 @@ static NSDictionary* _launch = nil; static NSMutableArray* _responses = nil; static NSDictionary* _defaults = nil; +// Category identifier -> the identifiers of its actions that open the app, as +// the last `initialize` set them. +static NSDictionary*>* _foregroundActions = nil; static NSMutableDictionary* _results = nil; static int64_t _nextRequest = 1; @@ -137,10 +145,23 @@ static UNAuthorizationOptions _authorizationOptions(NSDictionary* request) { if (_flag(request, @"critical")) { options |= UNAuthorizationOptionCriticalAlert; } + if (_flag(request, @"providesAppNotificationSettings")) { + options |= UNAuthorizationOptionProvidesAppNotificationSettings; + } return options; } static void _requestAuthorization(int64_t request, UNAuthorizationOptions options) { + // As upstream: with none of alert, sound, badge, critical and app + // notification settings asked for, nothing is asked and the answer is no. + UNAuthorizationOptions asked = UNAuthorizationOptionAlert | UNAuthorizationOptionSound | + UNAuthorizationOptionBadge | + UNAuthorizationOptionCriticalAlert | + UNAuthorizationOptionProvidesAppNotificationSettings; + if ((options & asked) == 0) { + _finish(request, @{@"granted" : @NO}); + return; + } [[UNUserNotificationCenter currentNotificationCenter] requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError* error) { @@ -163,18 +184,13 @@ static void _onWillPresent(NSNotification* note) { } id own = payload[kPresentKey]; NSDictionary* flags = [own isKindOfClass:[NSDictionary class]] ? own : @{}; - BOOL alert = _present(flags, @"presentAlert"); - BOOL banner = [flags[@"presentBanner"] isKindOfClass:[NSNumber class]] - ? [flags[@"presentBanner"] boolValue] - : alert && _present(flags, @"presentBanner"); - BOOL list = [flags[@"presentList"] isKindOfClass:[NSNumber class]] - ? [flags[@"presentList"] boolValue] - : alert && _present(flags, @"presentList"); + // As upstream from iOS 14 on: the banner and the list have flags of their + // own, and presentAlert, which the watch has no option for, is not read. UNNotificationPresentationOptions presentation = 0; - if (banner) { + if (_present(flags, @"presentBanner")) { presentation |= UNNotificationPresentationOptionBanner; } - if (list) { + if (_present(flags, @"presentList")) { presentation |= UNNotificationPresentationOptionList; } if (_present(flags, @"presentSound")) { @@ -195,6 +211,17 @@ static void _onResponse(NSNotification* note) { BOOL tapped = action == nil || [action isEqualToString:UNNotificationDefaultActionIdentifier]; BOOL dismissed = [action isEqualToString:UNNotificationDismissActionIdentifier]; + id dismissIsolate = payload[kDismissKey]; + if (dismissed && ![dismissIsolate isKindOfClass:[NSNumber class]]) { + // As upstream: a dismissal is reported only for a notification whose + // details set dismissIsolate. + return; + } + id foreground = payload[kForegroundKey]; + // What upstream takes as the launch: a tap on the notification, or an + // action that opens the app. Background actions and dismissals never are. + BOOL opensApp = tapped || (!dismissed && [foreground isKindOfClass:[NSArray class]] && + [foreground containsObject:action]); NSDictionary* response = @{ @"id" : payload[kIdKey], @"actionId" : tapped || dismissed ? [NSNull null] : action, @@ -205,14 +232,19 @@ static void _onResponse(NSNotification* note) { }; os_unfair_lock_lock(&_lock); BOOL initialized = _initialized; - if (!initialized && !_launchRead && _launch == nil) { + if (!initialized && opensApp && !_launchRead && _launch == nil) { // The tap that launched the app, for getNotificationAppLaunchDetails. _launch = response; + } else if (!initialized && dismissed && [dismissIsolate integerValue] == 0) { + // As upstream: a dismissal for the main isolate that comes in before + // `initialize` is not kept. } else { // For the response callback: at once when `initialize` has run, - // otherwise when it does. The host posts the tap from the main thread - // while Dart runs on its own, so the launch tap can also come in after - // Dart has read the launch details; it must not be lost then. + // otherwise when it does. Background actions and dismissals go there + // too, since the watch app has no background isolate. The host posts + // the tap from the main thread while Dart runs on its own, so the + // launch tap can also come in after Dart has read the launch details; + // it must not be lost then. if (_responses == nil) { _responses = [NSMutableArray array]; } @@ -346,10 +378,16 @@ static void _onResponse(NSNotification* note) { present[key] = details[key]; } } - // watchOS plays only the default sound: there is no soundNamed: here. + // watchOS plays only the default sound: there is no soundNamed: here, nor + // criticalSoundNamed:. A critical alert's volume is kept, as upstream + // keeps it, on the default critical sound. if (_present(present, @"presentSound")) { content.sound = [UNNotificationSound defaultSound]; } + if ([details[@"criticalSoundVolume"] isKindOfClass:[NSNumber class]]) { + content.sound = [UNNotificationSound + defaultCriticalSoundWithAudioVolume:[details[@"criticalSoundVolume"] floatValue]]; + } if (@available(watchOS 8.0, *)) { id level = details[@"interruptionLevel"]; if ([level isKindOfClass:[NSNumber class]]) { @@ -392,11 +430,24 @@ static void _onResponse(NSNotification* note) { [attachments addObject:attachment]; } content.attachments = attachments; - content.userInfo = @{ + NSMutableDictionary* info = [@{ kIdKey : json[@"id"] ?: @0, kPayloadKey : [json[@"payload"] isKindOfClass:[NSString class]] ? json[@"payload"] : @"", kPresentKey : present, - }; + } mutableCopy]; + if ([details[@"dismissIsolate"] isKindOfClass:[NSNumber class]]) { + info[kDismissKey] = details[@"dismissIsolate"]; + } + // Kept with the notification, since a response can arrive in a launch + // that has not run `initialize` yet. + os_unfair_lock_lock(&_lock); + NSArray* foreground = + content.categoryIdentifier.length > 0 ? _foregroundActions[content.categoryIdentifier] : nil; + os_unfair_lock_unlock(&_lock); + if (foreground.count > 0) { + info[kForegroundKey] = foreground; + } + content.userInfo = info; return content; } @@ -423,10 +474,20 @@ int64_t flutter_local_notifications_watchos_initialize(const char* json) { int64_t request = _newRequest(); NSDictionary* settings = _parse(json); NSMutableSet* categories = [NSMutableSet set]; + NSMutableDictionary*>* foreground = + [NSMutableDictionary dictionary]; for (NSDictionary* category in settings[@"categories"]) { if ([category isKindOfClass:[NSDictionary class]] && [category[@"identifier"] isKindOfClass:[NSString class]]) { - [categories addObject:_category(category)]; + UNNotificationCategory* built = _category(category); + [categories addObject:built]; + NSMutableArray* opening = [NSMutableArray array]; + for (UNNotificationAction* action in built.actions) { + if ((action.options & UNNotificationActionOptionForeground) != 0) { + [opening addObject:action.identifier]; + } + } + foreground[built.identifier] = opening; } } if (categories.count > 0) { @@ -436,17 +497,13 @@ int64_t flutter_local_notifications_watchos_initialize(const char* json) { os_unfair_lock_lock(&_lock); id defaults = settings[@"defaults"]; _defaults = [defaults isKindOfClass:[NSDictionary class]] ? defaults : nil; + _foregroundActions = foreground; _initialized = YES; os_unfair_lock_unlock(&_lock); id asked = settings[@"request"]; - UNAuthorizationOptions options = - _authorizationOptions([asked isKindOfClass:[NSDictionary class]] ? asked : @{}); - if (options == 0) { - _finish(request, @{@"granted" : @YES}); - } else { - _requestAuthorization(request, options); - } + _requestAuthorization( + request, _authorizationOptions([asked isKindOfClass:[NSDictionary class]] ? asked : @{})); return request; } @@ -460,8 +517,8 @@ int64_t flutter_local_notifications_watchos_check_permissions(void) { int64_t request = _newRequest(); [[UNUserNotificationCenter currentNotificationCenter] getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* s) { - BOOL enabled = s.authorizationStatus == UNAuthorizationStatusAuthorized || - s.authorizationStatus == UNAuthorizationStatusProvisional; + // As upstream: provisional is reported apart, not as enabled. + BOOL enabled = s.authorizationStatus == UNAuthorizationStatusAuthorized; // A comparison is an int in C, so it is cast to BOOL to reach JSON // as true or false, not 1 or 0, which Dart would read as false. _finish(request, @{ @@ -474,6 +531,7 @@ int64_t flutter_local_notifications_watchos_check_permissions(void) { @((BOOL)(s.authorizationStatus == UNAuthorizationStatusProvisional)), @"isCriticalEnabled" : @((BOOL)(s.criticalAlertSetting == UNNotificationSettingEnabled)), + @"isProvidesAppNotificationSettingsEnabled" : @(s.providesAppNotificationSettings), }); }]; return request; @@ -498,7 +556,10 @@ int64_t flutter_local_notifications_watchos_add(const char* json) { [[UNUserNotificationCenter currentNotificationCenter] addNotificationRequest:unRequest withCompletionHandler:^(NSError* addError) { - if (addError != nil) { + // As upstream 22.3.2: watchOS 27, like iOS 27, reports in + // UNErrorDomain what earlier versions let pass silently, such as + // notifications not being allowed, so those errors are ignored. + if (addError != nil && ![addError.domain isEqualToString:UNErrorDomain]) { _fail(request, @"add", addError); return; } diff --git a/tool/words/forbidden_words_allow.txt b/tool/words/forbidden_words_allow.txt index 113aad6..a37f698 100644 --- a/tool/words/forbidden_words_allow.txt +++ b/tool/words/forbidden_words_allow.txt @@ -36,9 +36,9 @@ packages/local_auth_watchos/**/*.dart | malloc.free( | package:ffi's malloc.free packages/shared_preferences_watchos/**/*.dart | malloc.free( | package:ffi's malloc.free, which releases native memory; a Dart API call. packages/flutter_secure_storage_watchos/lib/flutter_secure_storage_watchos.dart | ..free( | calloc.free as a cascade on package:ffi's calloc; a Dart API call. packages/firebase_*_watchos/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. +packages/flutter_local_notifications_watchos/**/*.m | free( | C's free(), which releases native memory; here the body of the plugin's exported *_release function. packages/flutter_secure_storage_watchos/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. packages/flutter_watch_link/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. -packages/flutter_local_notifications_watchos/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. packages/games_services_watchos/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. packages/in_app_purchase_watchos/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. packages/network_info_plus_watchos/**/*.m | free( | C's free(), which releases native memory; also the tail of each plugin's exported *_free function. From ced2c4c06fa81bad48e2762dcd1c1cbb3099e615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20Arslanta=C5=9F?= Date: Sat, 24 Oct 2026 19:55:33 +0200 Subject: [PATCH 5/5] flutter_local_notifications_watchos: the second review round From the second review of PR #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 --- .../CHANGELOG.md | 7 ++ .../PORTING_REPORT.md | 41 +++++++--- .../README.md | 2 +- .../watchos_notifications_test.dart | 24 ++++++ .../example/lib/main.dart | 77 +++++++++++++++++-- .../flutter_local_notifications_watchos_ffi.h | 34 +++++--- .../flutter_local_notifications_watchos_ffi.m | 18 +++-- 7 files changed, 170 insertions(+), 33 deletions(-) diff --git a/packages/flutter_local_notifications_watchos/CHANGELOG.md b/packages/flutter_local_notifications_watchos/CHANGELOG.md index 5989b6b..e1ab242 100644 --- a/packages/flutter_local_notifications_watchos/CHANGELOG.md +++ b/packages/flutter_local_notifications_watchos/CHANGELOG.md @@ -27,6 +27,13 @@ physical watch. * `providesAppNotificationSettings` is asked for and reported; `criticalSoundVolume` plays the default critical sound at that volume. * LICENSE carries Michael Bui's notice, as upstream's does. +* `DarwinNotificationDetails` without attachments no longer ends the app: + their null reached native as NSNull, which it walked as a list. +* As upstream, an error from asking for permission is not reported; the + answer is whether it was granted. +* The example's "With actions" buttons schedule a notification with an + action that opens the app, a background action, and a reported + dismissal, or one without it. * The example's `lib/` is a small watch demo, because the upstream example needs `flutter_timezone`; its official integration test is upstream's, unchanged. diff --git a/packages/flutter_local_notifications_watchos/PORTING_REPORT.md b/packages/flutter_local_notifications_watchos/PORTING_REPORT.md index 1f294b3..e6df65a 100644 --- a/packages/flutter_local_notifications_watchos/PORTING_REPORT.md +++ b/packages/flutter_local_notifications_watchos/PORTING_REPORT.md @@ -12,11 +12,24 @@ Marking: ✅ full / passes · ◐ partial — reason given · ○ not applicable (no upstream test) · ✗ unsupported on watchOS. -On the Simulator the example also scheduled a notification ten seconds out, -listed it as pending, showed it on the watch face with the app in the -background, and delivered the tap with its id and payload. A notification -shown while the app is in front was added but not seen on screen; that path -(the host's `willPresent` and the presentation flags) is not yet checked. +On the watchOS 27.0 Simulator the example scheduled notifications ten +seconds out and these responses were checked by hand, after the review's +routing change: + +| Case | Result | +|---|---| +| Tap on the notification, app not running | ✅ the app launches, `getNotificationAppLaunchDetails` reports the tap with its id and payload | +| "Resume" (an action that opens the app), app not running | ◐ the app launches and the action reaches `onDidReceiveNotificationResponse` with its id, action id and payload; not as the launch details, because the host delivers it about 0.2 s after Dart read them (seen in a log), so it takes the queue meant for a late launch response | +| "Skip" (a background action), app in the background | ✅ the app stays in the background; the action reaches `onDidReceiveNotificationResponse` | +| Dismissal (the ✕), with `dismissIsolate` | ✅ reaches `onDidReceiveNotificationResponse` as `notificationDismissed` | +| Dismissal (the ✕), without `dismissIsolate` | ✅ not reported, as upstream | + +The unattended watchOS integration test shows a notification without +permission: on watchOS 27 the add fails in `UNErrorDomain` (code 2003, +"Source is not authorized"), which the old code turned into a +`PlatformException`; it now completes, as upstream. A notification shown +while the app is in front was added but not seen on screen; that path (the +host's `willPresent` and the presentation flags) is not yet checked. Generated by `flutter-watchos plugin port` on 2026-10-21, then finished by hand. @@ -38,9 +51,15 @@ The iOS plugin is the notification-center delegate. On the watch the flutter-watchos host is (`FlutterWatchOSAppDelegate`), and it rebroadcasts "will present" and "did receive response" as NSNotifications. The native file observes them from a load-time constructor, so the tap that launched -the app, which the host does not keep, is caught before Dart runs; a tap -that arrives before `initialize` and before Dart reads the launch details -is the launch tap, later ones queue for the response callback. Each notification carries its id, payload and presentation flags in +the app, which the host does not keep, is caught before Dart runs. As +upstream, the launch is a tap on the notification or on an action that +opens the app (kept with each notification, since the launch comes before +`initialize`), if it arrives before `initialize` and before Dart reads the +launch details; later ones queue for the response callback. Background +actions, and dismissals of notifications that set `dismissIsolate`, queue +for it too, since the watch app has no background isolate; a dismissal for +the main isolate that comes in before `initialize` is dropped, as upstream +drops it. Each notification carries its id, payload and presentation flags in `userInfo` under keys of this package's own, and callbacks for notifications without them (pushes, other plugins) are left alone. @@ -59,7 +78,11 @@ checks what upstream's does not reach on the watch: the permission flags, the launch details, and scheduling, listing and cancelling against the real notification center. The cases that need permission ask for it only with `--dart-define=FLNW_PROMPT=true`, when someone answers the prompt; without it -they are skipped and the suite asks for nothing, so it cannot hang. +they are skipped and the suite asks for nothing, so it cannot hang. That +unattended run instead shows a notification without permission, which +checks the `UNErrorDomain` case above. The demo's "With actions" and +"Actions, no dismissal" buttons schedule the `lap` notifications the routing +table above was checked with; its status line shows the last response. ## Interface coverage diff --git a/packages/flutter_local_notifications_watchos/README.md b/packages/flutter_local_notifications_watchos/README.md index 4279791..600f415 100644 --- a/packages/flutter_local_notifications_watchos/README.md +++ b/packages/flutter_local_notifications_watchos/README.md @@ -49,7 +49,7 @@ missing. | `periodicallyShow` / `periodicallyShowWithDuration` | supported: a repeating interval trigger | | `cancel` / `cancelAll` / `cancelAllPendingNotifications` | supported | | `pendingNotificationRequests` / `getActiveNotifications` | supported, for this package's notifications | -| `getNotificationAppLaunchDetails` | supported: the tap that launched the app. A launch tap that comes in only after this was asked is not lost: it reaches `onDidReceiveNotificationResponse` once `initialize` has run | +| `getNotificationAppLaunchDetails` | supported: the tap that launched the app. A launch response that comes in only after this was asked is not lost: it reaches `onDidReceiveNotificationResponse` once `initialize` has run. On the Simulator a tap on the notification arrived in time, an action that opens the app did not, so read both | | `onDidReceiveNotificationResponse` | taps and action taps, with the notification's id and payload; background actions too, since the watch app has no background isolate, and dismissals of notifications whose details set `dismissIsolate`. A banner shown while the app is in front closes when tapped and sends no response: watchOS does not report that tap | Of `DarwinNotificationDetails`, these reach the notification: the diff --git a/packages/flutter_local_notifications_watchos/example/integration_test/watchos_notifications_test.dart b/packages/flutter_local_notifications_watchos/example/integration_test/watchos_notifications_test.dart index 18c69ee..0a0e89d 100644 --- a/packages/flutter_local_notifications_watchos/example/integration_test/watchos_notifications_test.dart +++ b/packages/flutter_local_notifications_watchos/example/integration_test/watchos_notifications_test.dart @@ -54,6 +54,30 @@ void main() { expect(details.notificationResponse, isNull); }); + testWidgets('show completes without permission', (WidgetTester _) async { + // watchOS 27 reports a missing permission as a UNErrorDomain error from + // adding the request, which upstream 22.3.2 ignores; so does this + // package. Runs on an unattended run, where nothing was allowed. + final NotificationsEnabledOptions? options = await plugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>()! + .checkPermissions(); + if (options!.isEnabled) { + // Allowed on an earlier run that kept the install: nothing to check. + markTestSkipped('notifications are allowed, so none is refused'); + return; + } + // With iOS details but no attachments, whose null once ended the app. + await plugin.show( + id: 1003, + title: 'Lap 2', + body: '5:31 /km', + notificationDetails: const NotificationDetails( + iOS: DarwinNotificationDetails(subtitle: 'Run'), + ), + ); + }, skip: _prompt); + testWidgets('checkPermissions reports the alerts the app was allowed', (WidgetTester _) async { final NotificationsEnabledOptions? options = await plugin diff --git a/packages/flutter_local_notifications_watchos/example/lib/main.dart b/packages/flutter_local_notifications_watchos/example/lib/main.dart index e96a5a5..2a9ad6c 100644 --- a/packages/flutter_local_notifications_watchos/example/lib/main.dart +++ b/packages/flutter_local_notifications_watchos/example/lib/main.dart @@ -6,6 +6,11 @@ // flutter_timezone, which has no watchOS implementation, so this // watch-sized demo stands in for it. The official integration test in // integration_test/ is upstream's, unchanged. +// +// "With actions" schedules a notification of the `lap` category: "Resume" +// opens the app, "Skip" does not, and the category reports dismissals. The +// status line shows the last response, so a run on the watch can check what +// each reaches. import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -15,6 +20,21 @@ import 'package:timezone/timezone.dart' as tz; final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); +/// The category of the notifications "With actions" schedules. +const String _lapCategory = 'lap'; + +/// One line for [r]: what kind of response, its action, its id and payload. +String _describe(NotificationResponse r) => [ + switch (r.notificationResponseType) { + NotificationResponseType.selectedNotification => 'tapped', + NotificationResponseType.selectedNotificationAction => + 'action ${r.actionId}', + NotificationResponseType.notificationDismissed => 'dismissed', + }, + '#${r.id}', + if (r.payload != null && r.payload!.isNotEmpty) r.payload!, + ].join(' '); + Future main() async { WidgetsFlutterBinding.ensureInitialized(); tzdata.initializeTimeZones(); @@ -57,17 +77,34 @@ class _NotificationsPageState extends State { final NotificationAppLaunchDetails? launch = await _notifications.getNotificationAppLaunchDetails(); final bool? granted = await _notifications.initialize( - settings: const InitializationSettings( - iOS: DarwinInitializationSettings(), + settings: InitializationSettings( + iOS: DarwinInitializationSettings( + notificationCategories: [ + DarwinNotificationCategory( + _lapCategory, + actions: [ + DarwinNotificationAction.plain('resume', 'Resume', + options: { + DarwinNotificationActionOption.foreground, + }), + DarwinNotificationAction.plain('skip', 'Skip'), + ], + options: { + DarwinNotificationCategoryOption.customDismissAction, + }, + ), + ], + ), ), onDidReceiveNotificationResponse: (NotificationResponse r) { - setState(() => _status = 'tapped #${r.id} ${r.payload ?? ''}'); + setState(() => _status = _describe(r)); }, ); setState(() { - if (launch?.didNotificationLaunchApp == true) { - _status = 'launched by #${launch!.notificationResponse?.id}'; - } else if (!_status.startsWith('tapped')) { + final NotificationResponse? launched = launch?.notificationResponse; + if (launch?.didNotificationLaunchApp == true && launched != null) { + _status = 'launched: ${_describe(launched)}'; + } else if (_status == 'starting') { // A launch tap that came in after the launch details were read // arrived as a response; keep it on screen. _status = granted == true ? 'allowed' : 'not allowed'; @@ -102,6 +139,28 @@ class _NotificationsPageState extends State { setState(() => _status = 'scheduled #$id, $pending pending'); } + /// Schedules a `lap` notification ten seconds out; [reportDismissal] + /// sets `dismissIsolate`, without which a dismissal is not reported. + Future _scheduleWithActions({required bool reportDismissal}) async { + final int id = _next++; + await _notifications.zonedSchedule( + id: id, + title: 'Lap ${id + 1}', + body: reportDismissal ? 'Resume, skip or dismiss' : 'Resume or skip', + scheduledDate: tz.TZDateTime.now(tz.UTC).add(const Duration(seconds: 10)), + notificationDetails: NotificationDetails( + iOS: DarwinNotificationDetails( + categoryIdentifier: _lapCategory, + dismissIsolate: + reportDismissal ? NotificationDismissedIsolate.main : null, + ), + ), + androidScheduleMode: AndroidScheduleMode.inexact, + payload: 'lap-$id', + ); + setState(() => _status = 'scheduled #$id with actions'); + } + Future _cancelAll() async { await _notifications.cancelAll(); setState(() => _status = 'cancelled'); @@ -118,6 +177,12 @@ class _NotificationsPageState extends State { FilledButton(onPressed: _show, child: const Text('Show now')), FilledButton.tonal( onPressed: _schedule, child: const Text('In 10 seconds')), + FilledButton.tonal( + onPressed: () => _scheduleWithActions(reportDismissal: true), + child: const Text('With actions')), + FilledButton.tonal( + onPressed: () => _scheduleWithActions(reportDismissal: false), + child: const Text('Actions, no dismissal')), TextButton(onPressed: _cancelAll, child: const Text('Cancel all')), ], ), diff --git a/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.h b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.h index c9b5b7e..f9ec7d4 100644 --- a/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.h +++ b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.h @@ -42,24 +42,32 @@ void flutter_local_notifications_watchos_set_callback(flnw_signal_callback callb // "actions": [{"identifier", "title", "options"}], "options"}], // "defaults": {"presentAlert", "presentSound", "presentBadge", // "presentBanner", "presentList"}, "request": {"alert", "sound", "badge", -// "provisional", "critical"}}. Result: {"granted": }. +// "provisional", "critical", "providesAppNotificationSettings"}}. +// Result: {"granted": }; false, without asking, when none of alert, +// sound, badge, critical and providesAppNotificationSettings is asked for. FLNW_EXPORT int64_t flutter_local_notifications_watchos_initialize(const char* json); -// `json` is {"alert", "sound", "badge", "provisional", "critical"}. -// Result: {"granted": }. +// `json` is {"alert", "sound", "badge", "provisional", "critical", +// "providesAppNotificationSettings"}. Result: {"granted": }, as for +// initialize; an error from the system is not reported, as upstream. FLNW_EXPORT int64_t flutter_local_notifications_watchos_request_permissions(const char* json); // Result: {"isEnabled", "isAlertEnabled", "isSoundEnabled", -// "isBadgeEnabled", "isProvisionalEnabled", "isCriticalEnabled"}. +// "isBadgeEnabled", "isProvisionalEnabled", "isCriticalEnabled", +// "isProvidesAppNotificationSettingsEnabled"}. "isEnabled" is full +// authorization only; provisional is "isProvisionalEnabled". FLNW_EXPORT int64_t flutter_local_notifications_watchos_check_permissions(void); // Adds a notification request. `json` is {"id", "title", "body", "payload", // "details": {...DarwinNotificationDetails...}, "trigger": {"type": "now"} | // {"type": "calendar", "epochMs", "timeZone", "match"} | {"type": -// "interval", "seconds", "repeats"}}. Result: {}. +// "interval", "seconds", "repeats"}}. Of the details, "dismissIsolate" (an +// index) asks for the dismissal to be reported. Result: {}; an error in +// UNErrorDomain, which watchOS 27 gives for a missing permission, is not +// reported, as upstream. FLNW_EXPORT int64_t flutter_local_notifications_watchos_add(const char* json); @@ -82,14 +90,20 @@ FLNW_EXPORT int64_t flutter_local_notifications_watchos_active(void); // {"launched": , "response": {"id", "actionId", "payload", "type"} or -// null}: the tap that launched the app, if it arrived before this was first -// asked and before `initialize`. A tap after that queues for the response -// callback instead, so it is never lost. +// null}: what launched the app, a tap on the notification or on an action +// that opens the app, if it arrived before this was first asked and before +// `initialize`. A background action or a dismissal is never the launch. A +// launch response after that queues for the response callback instead, so +// it is never lost; on the Simulator an action that opens the app arrived +// that way, about 0.2 s after this was read. FLNW_EXPORT char* flutter_local_notifications_watchos_launch_details(void); -// Drains the queued taps (all but the launch tap), as a JSON array of {"id", -// "actionId", "payload", "type"}; NULL when none are queued. +// Drains the queued responses (all but the launch), as a JSON array of +// {"id", "actionId", "payload", "type"}; NULL when none are queued. They are +// taps and actions, background ones included, and the dismissals of +// notifications that set dismissIsolate; one for the main isolate that came +// in before `initialize` is dropped, as upstream drops it. FLNW_EXPORT char* flutter_local_notifications_watchos_poll_responses(void); diff --git a/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m index 6a2a2eb..b5c367a 100644 --- a/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m +++ b/packages/flutter_local_notifications_watchos/watchos/Classes/flutter_local_notifications_watchos_ffi.m @@ -162,13 +162,11 @@ static void _requestAuthorization(int64_t request, UNAuthorizationOptions option _finish(request, @{@"granted" : @NO}); return; } + // As upstream, an error is not reported: the answer is whether it was + // granted. [[UNUserNotificationCenter currentNotificationCenter] requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError* error) { - if (error != nil) { - _fail(request, @"permission", error); - return; - } _finish(request, @{@"granted" : @(granted)}); }]; } @@ -281,7 +279,8 @@ static void _onResponse(NSNotification* note) { static UNNotificationCategory* _category(NSDictionary* json) { NSMutableArray* actions = [NSMutableArray array]; - for (NSDictionary* action in json[@"actions"]) { + id actionList = json[@"actions"]; + for (NSDictionary* action in [actionList isKindOfClass:[NSArray class]] ? actionList : @[]) { if (![action isKindOfClass:[NSDictionary class]]) { continue; } @@ -409,7 +408,10 @@ static void _onResponse(NSNotification* note) { } } NSMutableArray* attachments = [NSMutableArray array]; - for (NSDictionary* item in details[@"attachments"]) { + // Details without attachments send null, which JSON makes NSNull: only a + // list is walked. + id items = details[@"attachments"]; + for (NSDictionary* item in [items isKindOfClass:[NSArray class]] ? items : @[]) { if (![item isKindOfClass:[NSDictionary class]] || ![item[@"filePath"] isKindOfClass:[NSString class]]) { continue; @@ -476,7 +478,9 @@ int64_t flutter_local_notifications_watchos_initialize(const char* json) { NSMutableSet* categories = [NSMutableSet set]; NSMutableDictionary*>* foreground = [NSMutableDictionary dictionary]; - for (NSDictionary* category in settings[@"categories"]) { + id categoryList = settings[@"categories"]; + for (NSDictionary* category in + [categoryList isKindOfClass:[NSArray class]] ? categoryList : @[]) { if ([category isKindOfClass:[NSDictionary class]] && [category[@"identifier"] isKindOfClass:[NSString class]]) { UNNotificationCategory* built = _category(category);