diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f9ef166a9..1010ca75b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,10 @@ updates: directory: "/flutter_local_notifications_linux" schedule: interval: "daily" + - package-ecosystem: "pub" + directory: "/flutter_local_notifications_windows" + schedule: + interval: "daily" - package-ecosystem: "pub" directory: "/flutter_local_notifications" schedule: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 31ffa6bec..83b5b5a8a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -61,15 +61,15 @@ jobs: run: ./.github/workflows/scripts/install-tools.sh - name: Build run: melos run build:example_android - build_example_android_3_13: - name: Build Android example app (3.13) + build_example_android_3_19: + name: Build Android example app (3.19) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: 3.13.0 + flutter-version: 3.19.0 cache: true cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:' - name: Install Tools @@ -90,15 +90,15 @@ jobs: run: ./.github/workflows/scripts/install-tools.sh - name: Build run: melos run build:example_ios - build_example_ios_3_13: - name: Build iOS example app (3.13) + build_example_ios_3_19: + name: Build iOS example app (3.19) runs-on: macos-latest steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: 3.13.0 + flutter-version: 3.19.0 cache: true cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:' - name: Install Tools @@ -119,15 +119,15 @@ jobs: run: ./.github/workflows/scripts/install-tools.sh - name: Build run: melos run build:example_macos - build_example_macos_3_13: - name: Build macOS example app (3.13) + build_example_macos_3_19: + name: Build macOS example app (3.19) runs-on: macos-latest steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: 3.13.0 + flutter-version: 3.19.0 cache: true cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:' - name: Install Tools @@ -152,15 +152,15 @@ jobs: - run: flutter config --enable-linux-desktop - name: Build run: melos run build:example_linux - build_example_linux_3_13: - name: Build Linux example app (3.13) + build_example_linux_3_19: + name: Build Linux example app (3.19) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: 3.13.0 + flutter-version: 3.19.0 cache: true cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:' - name: Install Tools @@ -171,6 +171,57 @@ jobs: - run: flutter config --enable-linux-desktop - name: Build run: melos run build:example_linux + build_example_windows_stable: + name: Build Windows example app (stable channel) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:' + - name: Install Tools + run: | + dart pub global activate melos + melos bootstrap + # Windows has a filename length limit, which this repo just hits + # This saves us precious characters during the compilation + - name: Rename directory + run: | + move flutter_local_notifications f + move f\example f\e + - name: Build + run: | + cd f\e + dart pub get + dart run msix:create + build_example_windows_3_19: + name: Build Windows example app (3.19) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: 3.19.0 + cache: true + cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:' + - name: Install Tools + run: | + dart pub global activate melos + melos bootstrap + # Windows has a filename length limit, which this repo just hits + # This saves us precious characters during the compilation + - name: Rename directory + run: | + move flutter_local_notifications f + move f\example f\e + - name: Build + run: | + cd f\e + dart pub get + dart run msix:create unit_tests_dart: name: Run all unit tests (Dart) runs-on: ubuntu-latest @@ -199,6 +250,22 @@ jobs: run: ./.github/workflows/scripts/install-tools.sh - name: Run Tests run: melos run test:unit:android + unit_tests_windows: + name: Run all unit tests (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + cache-key: 'flutter-:os:-:channel:-:version:-:arch:-:hash:' + - name: Install tools + run: | + dart pub global activate melos + melos bootstrap + - name: Run Tests + run: melos run test:unit:windows integration_tests_android: name: Run integration tests (Android) runs-on: ubuntu-latest @@ -245,6 +312,3 @@ jobs: brew install applesimutils applesimutils --byId ${{ steps.simulator-action.outputs.udid}} --bundle com.dexterous.flutterLocalNotificationsExample --setPermissions notifications=YES - run: melos run test:integration - - - diff --git a/flutter_local_notifications/README.md b/flutter_local_notifications/README.md index c27d778ea..0716a91dd 100644 --- a/flutter_local_notifications/README.md +++ b/flutter_local_notifications/README.md @@ -5,7 +5,9 @@ A cross platform plugin for displaying local notifications. ->[!IMPORTANT] +>[!IMPORTANT] +> Given how both quickly both Flutter ecosystem and Android ecosystem evolves, the minimum Flutter SDK version will be bumped to make it easier to maintain the plugin. Note that official plugins already follow a similar approach e.g. have a minimum Flutter SDK version of 3.13. This is being called out as if this affects your applications (e.g. supported OS versions) then you may need to consider maintaining your own fork in the future +>[!IMPORTANT] > Given how both quickly both Flutter ecosystem and Android ecosystem evolves, the minimum Flutter SDK version will occasionally be bumped to make it easier to maintain the plugin. Note that official plugins already follow a similar approach. This is being called out as if this affects your applications (e.g. supported OS versions) then you may need to consider maintaining your own fork in the future ## Table of contents @@ -59,6 +61,7 @@ A cross platform plugin for displaying local notifications. * **iOS** Uses the [UserNotification APIs](https://developer.apple.com/documentation/usernotifications) (aka the User Notifications Framework) * **macOS** Uses the [UserNotification APIs](https://developer.apple.com/documentation/usernotifications) (aka the User Notifications Framework) * **Linux**. Uses the [Desktop Notifications Specification](https://specifications.freedesktop.org/notification-spec/) +* **Windows** Uses the [C++/WinRT](https://learn.microsoft.com/en-us/windows/uwp/cpp-and-winrt-apis/) implementation of [Toast Notifications](https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-notifications-overview) Note: the plugin requires Flutter SDK 3.13 at a minimum. The list of support platforms for Flutter 3.13 itself can be found [here](https://github.com/flutter/website/blob/3d18ab48218101493af84953b71eac0cc6781fdd/src/reference/supported-platforms.md) @@ -109,6 +112,10 @@ Note: the plugin requires Flutter SDK 3.13 at a minimum. The list of support pla * [Linux] Ability to set custom hints * [Linux] Ability to suppress sound * [Linux] Resident and transient notifications +* [Windows] Can show raw XML (see the [Notifications Visualizer](https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/notifications-visualizer)) +* [Windows] A full Dart API for all the options supported by toast notifications +* [Windows] Can configure images, buttons, dropdowns, text input, and launch behavior +* [Windows] Can dynamically update notifications after they've been shown ## ⚠ Caveats and limitations @@ -154,6 +161,11 @@ Scheduled/pending notifications is currently not supported due to the lack of a The `onDidReceiveNotificationResponse` callback runs on the main isolate of the running application and cannot be launched in the background if the application is not running. To respond to notification after the application is terminated, your application should be registered as DBus activatable (please see [DBusApplicationLaunching](https://wiki.gnome.org/HowDoI/DBusApplicationLaunching) for more information), and register action before activating the application. This is difficult to do in a plugin because plugins instantiate during application activation, so `getNotificationAppLaunchDetails` can't be implemented without changing the main user application. +### Windows limitations + +- Windows does not support repeating notifications, so [`periodicallyShow`](https://pub.dev/documentation/flutter_local_notifications/latest/flutter_local_notifications/FlutterLocalNotificationsPlugin/periodicallyShow.html) and [`periodicallyShowWithDuration`](https://pub.dev/documentation/flutter_local_notifications/latest/flutter_local_notifications/FlutterLocalNotificationsPlugin/periodicallyShowWithDuration.html) will throw `UnsupportedError`s. +- Windows only allows apps with package identity to retrieve previously shown notifications. This means that on an app that was not packaged as an [MSIX](https://learn.microsoft.com/en-us/windows/msix/overview) installer, [`cancel`](https://pub.dev/documentation/flutter_local_notifications/latest/flutter_local_notifications/FlutterLocalNotificationsPlugin/cancel.html) does nothing and [`getActiveNotifications`](https://pub.dev/documentation/flutter_local_notifications/latest/flutter_local_notifications/FlutterLocalNotificationsPlugin/getActiveNotifications.html) will return an empty list. To package your app as an MSIX, see [`package:msix`](https://pub.dev/packages/msix) and the `msix` section in [the example's `pubspec.yaml`](https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/pubspec.yaml). + ### Notification payload Due to some limitations on iOS with how it treats null values in dictionaries, a null notification payload is coalesced to an empty string behind the scenes on all platforms for consistency. @@ -166,6 +178,7 @@ Due to some limitations on iOS with how it treats null values in dictionaries, a | iOS | | | macOS | | | Linux | | +| Windows | | ## 👏 Acknowledgements @@ -174,6 +187,7 @@ Due to some limitations on iOS with how it treats null values in dictionaries, a * [Jeff Scaturro](https://github.com/JeffScaturro) for submitting the PR to fix the iOS issue around showing daily and weekly notifications and migrating the plugin to AndroidX * [Ian Cavanaugh](https://github.com/icavanaugh95) for helping create a sample to reproduce the problem reported in [issue #88](https://github.com/MaikuB/flutter_local_notifications/issues/88) * [Zhang Jing](https://github.com/byrdkm17) for adding 'ticker' support for Android notifications +* [Kenneth](https://github.com/kennethnym), [lightrabbit](https://github.com/lightrabbit), and [Levi Lesches](https://github.com/Levi-Lesches) for adding Windows support * ...and everyone else for their contributions. They are greatly appreciated ## 🔧 Android Setup @@ -270,7 +284,7 @@ For apps that need the following functionality please complete the following in * Declare the service exposed by the plugin by adding the following between `` tags. An example of what this looks like is below where `` should be replaced with the foreground service type(s) your app needs. If you want your foreground service to be stopped if your app is stopped, set `android:stopWithTask` to `true` ```xml @@ -386,7 +400,7 @@ then extend `didFinishLaunchingWithOptions` and register the callback: - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { [GeneratedPluginRegistrant registerWithRegistry:self]; - // Add this method + // Add this method [FlutterLocalNotificationsPlugin setPluginRegistrantCallback:registerPlugins]; } ``` @@ -529,11 +543,18 @@ final DarwinInitializationSettings initializationSettingsDarwin = final LinuxInitializationSettings initializationSettingsLinux = LinuxInitializationSettings( defaultActionName: 'Open notification'); +final WindowsInitializationSettings initializationSettingsWindows = + WindowsInitializationSettings( + appName: 'Flutter Local Notifications Example', + appUserModelId: 'Com.Dexterous.FlutterLocalNotificationsExample', + // Search online for GUID generators to make your own + guid: 'd49b0314-ee7a-4626-bf79-97cdb8a991bb') final InitializationSettings initializationSettings = InitializationSettings( android: initializationSettingsAndroid, iOS: initializationSettingsDarwin, macOS: initializationSettingsDarwin, - linux: initializationSettingsLinux); + linux: initializationSettingsLinux, + windows: initializationSettingsWindows); await flutterLocalNotificationsPlugin.initialize(initializationSettings, onDidReceiveNotificationResponse: onDidReceiveNotificationResponse); ``` @@ -557,9 +578,9 @@ void onDidReceiveNotificationResponse(NotificationResponse notificationResponse) In the real world, this payload could represent the id of the item you want to display the details of. Once the initialisation is complete, then you can manage the displaying of notifications. Note that this callback is only intended to work when the app is running. For scenarios where your application needs to handle when a notification launched the app refer to [here](#getting-details-on-if-the-app-was-launched-via-a-notification-created-by-this-plugin) -The `DarwinInitializationSettings` class provides default settings on how the notification be presented when it is triggered and the application is in the foreground on iOS/macOS. There are optional named parameters that can be modified to suit your application's purposes. Here, it is omitted and the default values for these named properties is set such that all presentation options (alert, sound, badge) are enabled. +The `DarwinInitializationSettings` class provides default settings on how the notification be presented when it is triggered and the application is in the foreground on iOS/macOS. There are optional named parameters that can be modified to suit your application's purposes. Here, it is omitted and the default values for these named properties is set such that all presentation options (alert, sound, badge) are enabled. -The `LinuxInitializationSettings` class requires a name for the default action that calls the `onDidReceiveNotificationResponse` callback when the notification is clicked. +The `LinuxInitializationSettings` class requires a name for the default action that calls the `onDidReceiveNotificationResponse` callback when the notification is clicked. On iOS and macOS, initialisation may show a prompt to requires users to give the application permission to display notifications (note: permissions don't need to be requested on Android). Depending on when this happens, this may not be the ideal user experience for your application. If so, please refer to the next section on how to work around this. @@ -647,7 +668,7 @@ The details specific to the Android platform are also specified. This includes t ### Scheduling a notification -Starting in version 2.0 of the plugin, scheduling notifications now requires developers to specify a date and time relative to a specific time zone. This is to solve issues with daylight saving time that existed in the `schedule` method that is now deprecated. A new `zonedSchedule` method is provided that expects an instance `TZDateTime` class provided by the [`timezone`](https://pub.dev/packages/timezone) package. Even though the `timezone` package is be a transitive dependency via this plugin, it is recommended based on [this lint rule](https://dart-lang.github.io/linter/lints/depend_on_referenced_packages.html) that you also add the `timezone` package as a direct dependency. +Starting in version 2.0 of the plugin, scheduling notifications now requires developers to specify a date and time relative to a specific time zone. This is to solve issues with daylight saving time that existed in the `schedule` method that is now deprecated. A new `zonedSchedule` method is provided that expects an instance `TZDateTime` class provided by the [`timezone`](https://pub.dev/packages/timezone) package. Even though the `timezone` package is be a transitive dependency via this plugin, it is recommended based on [this lint rule](https://dart-lang.github.io/linter/lints/depend_on_referenced_packages.html) that you also add the `timezone` package as a direct dependency. Once the depdendency as been added, usage of the `timezone` package requires initialisation that is covered in the package's readme. For convenience the following are code snippets used by the example app. @@ -699,6 +720,8 @@ If you are trying to update your code so it doesn't use the deprecated methods f ### Periodically show a notification with a specified interval +**Note** This is not supported on Windows + ```dart const AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( @@ -720,8 +743,7 @@ final List pendingNotificationRequests = ### Retrieving active notifications - - +**Note** On Windows, your app must be packaged as an MSIX to do this. See the limitations section. ```dart final List activeNotifications = @@ -805,6 +827,8 @@ await flutterLocalNotificationsPlugin.show( ### Cancelling/deleting a notification +**Note** On Windows, your app must be packaged as an MSIX to do this. See the limitations section. + ```dart // cancel the notification with id value of zero await flutterLocalNotificationsPlugin.cancel(0); diff --git a/flutter_local_notifications/example/lib/main.dart b/flutter_local_notifications/example/lib/main.dart index b80c04fc2..bcfc0b06a 100644 --- a/flutter_local_notifications/example/lib/main.dart +++ b/flutter_local_notifications/example/lib/main.dart @@ -16,13 +16,15 @@ import 'package:path_provider/path_provider.dart'; import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/timezone.dart' as tz; -int id = 0; +import 'padded_button.dart'; +import 'plugin.dart'; +import 'repeating.dart' as repeating; +import 'windows.dart' as windows; -final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin(); - -final StreamController selectNotificationStream = - StreamController.broadcast(); +/// Streams are created so that app can respond to notification-related events +/// since the plugin is initialized in the `main` function +final StreamController selectNotificationStream = + StreamController.broadcast(); const MethodChannel platform = MethodChannel('dexterx.dev/flutter_local_notifications_example'); @@ -35,12 +37,14 @@ class ReceivedNotification { required this.title, required this.body, required this.payload, + this.data, }); final int id; final String? title; final String? body; final String? payload; + final Map? data; } String? selectedNotificationPayload; @@ -81,17 +85,6 @@ Future main() async { await _configureLocalTimeZone(); - final NotificationAppLaunchDetails? notificationAppLaunchDetails = !kIsWeb && - Platform.isLinux - ? null - : await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); - String initialRoute = HomePage.routeName; - if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) { - selectedNotificationPayload = - notificationAppLaunchDetails!.notificationResponse?.payload; - initialRoute = SecondPage.routeName; - } - const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('app_icon'); @@ -149,34 +142,38 @@ Future main() async { requestSoundPermission: false, notificationCategories: darwinNotificationCategories, ); + final LinuxInitializationSettings initializationSettingsLinux = LinuxInitializationSettings( defaultActionName: 'Open notification', defaultIcon: AssetsLinuxIcon('icons/app_icon.png'), ); + final InitializationSettings initializationSettings = InitializationSettings( android: initializationSettingsAndroid, iOS: initializationSettingsDarwin, macOS: initializationSettingsDarwin, linux: initializationSettingsLinux, + windows: windows.initSettings, ); + await flutterLocalNotificationsPlugin.initialize( initializationSettings, - onDidReceiveNotificationResponse: - (NotificationResponse notificationResponse) { - switch (notificationResponse.notificationResponseType) { - case NotificationResponseType.selectedNotification: - selectNotificationStream.add(notificationResponse.payload); - break; - case NotificationResponseType.selectedNotificationAction: - if (notificationResponse.actionId == navigationActionId) { - selectNotificationStream.add(notificationResponse.payload); - } - break; - } - }, + onDidReceiveNotificationResponse: selectNotificationStream.add, onDidReceiveBackgroundNotificationResponse: notificationTapBackground, ); + + final NotificationAppLaunchDetails? notificationAppLaunchDetails = !kIsWeb && + Platform.isLinux + ? null + : await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); + String initialRoute = HomePage.routeName; + if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) { + selectedNotificationPayload = + notificationAppLaunchDetails!.notificationResponse?.payload; + initialRoute = SecondPage.routeName; + } + runApp( MaterialApp( initialRoute: initialRoute, @@ -193,30 +190,13 @@ Future _configureLocalTimeZone() async { return; } tz.initializeTimeZones(); + if (Platform.isWindows) { + return; + } final String? timeZoneName = await FlutterTimezone.getLocalTimezone(); tz.setLocalLocation(tz.getLocation(timeZoneName!)); } -class PaddedElevatedButton extends StatelessWidget { - const PaddedElevatedButton({ - required this.buttonText, - required this.onPressed, - Key? key, - }) : super(key: key); - - final String buttonText; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 8), - child: ElevatedButton( - onPressed: onPressed, - child: Text(buttonText), - ), - ); -} - class HomePage extends StatefulWidget { const HomePage( this.notificationAppLaunchDetails, { @@ -238,6 +218,9 @@ class _HomePageState extends State { final TextEditingController _linuxIconPathController = TextEditingController(); + final TextEditingController _windowsRawXmlController = + TextEditingController(); + bool _notificationsEnabled = false; @override @@ -294,9 +277,11 @@ class _HomePageState extends State { } void _configureSelectNotificationSubject() { - selectNotificationStream.stream.listen((String? payload) async { + selectNotificationStream.stream + .listen((NotificationResponse? response) async { await Navigator.of(context).push(MaterialPageRoute( - builder: (BuildContext context) => SecondPage(payload), + builder: (BuildContext context) => + SecondPage(response?.payload, data: response?.data), )); }); } @@ -392,50 +377,6 @@ class _HomePageState extends State { await _zonedScheduleAlarmClockNotification(); }, ), - PaddedElevatedButton( - buttonText: 'Repeat notification every minute', - onPressed: () async { - await _repeatNotification(); - }, - ), - PaddedElevatedButton( - buttonText: 'Repeat notification every 5 minutes', - onPressed: () async { - await _repeatPeriodicallyWithDurationNotification(); - }, - ), - PaddedElevatedButton( - buttonText: - 'Schedule daily 10:00:00 am notification in your ' - 'local time zone', - onPressed: () async { - await _scheduleDailyTenAMNotification(); - }, - ), - PaddedElevatedButton( - buttonText: - 'Schedule daily 10:00:00 am notification in your ' - "local time zone using last year's date", - onPressed: () async { - await _scheduleDailyTenAMLastYearNotification(); - }, - ), - PaddedElevatedButton( - buttonText: - 'Schedule weekly 10:00:00 am notification in your ' - 'local time zone', - onPressed: () async { - await _scheduleWeeklyTenAMNotification(); - }, - ), - PaddedElevatedButton( - buttonText: - 'Schedule weekly Monday 10:00:00 am notification ' - 'in your local time zone', - onPressed: () async { - await _scheduleWeeklyMondayTenAMNotification(); - }, - ), PaddedElevatedButton( buttonText: 'Check pending notifications', onPressed: () async { @@ -449,22 +390,6 @@ class _HomePageState extends State { }, ), ], - PaddedElevatedButton( - buttonText: - 'Schedule monthly Monday 10:00:00 am notification in ' - 'your local time zone', - onPressed: () async { - await _scheduleMonthlyMondayTenAMNotification(); - }, - ), - PaddedElevatedButton( - buttonText: - 'Schedule yearly Monday 10:00:00 am notification in ' - 'your local time zone', - onPressed: () async { - await _scheduleYearlyMondayTenAMNotification(); - }, - ), PaddedElevatedButton( buttonText: 'Show notification from silent channel', onPressed: () async { @@ -490,6 +415,7 @@ class _HomePageState extends State { await _cancelAllNotifications(); }, ), + if (!Platform.isWindows) ...repeating.examples(context), const Divider(), const Text( 'Notifications with actions', @@ -1040,6 +966,11 @@ class _HomePageState extends State { }, ), ], + if (!kIsWeb && Platform.isWindows) + ...windows.examples( + xmlController: _windowsRawXmlController, + showXmlNotification: _showWindowsNotificationWithRawXml, + ), ], ), ), @@ -1119,11 +1050,33 @@ class _HomePageState extends State { ], ); - const NotificationDetails notificationDetails = NotificationDetails( + final WindowsNotificationDetails windowsNotificationsDetails = + WindowsNotificationDetails( + subtitle: 'Click the three dots for another button', + actions: [ + const WindowsAction( + content: 'Text', + arguments: 'text', + ), + WindowsAction( + content: 'Image', + arguments: 'image', + image: File('icons/coworker.png').absolute, + ), + const WindowsAction( + content: 'Context', + arguments: 'context', + placement: WindowsActionPlacement.contextMenu, + ), + ], + ); + + final NotificationDetails notificationDetails = NotificationDetails( android: androidNotificationDetails, iOS: iosNotificationDetails, macOS: macOSNotificationDetails, linux: linuxNotificationDetails, + windows: windowsNotificationsDetails, ); await flutterLocalNotificationsPlugin.show( id++, 'plain title', 'plain body', notificationDetails, @@ -1158,10 +1111,23 @@ class _HomePageState extends State { categoryIdentifier: darwinNotificationCategoryText, ); + const WindowsNotificationDetails windowsNotificationDetails = + WindowsNotificationDetails( + actions: [ + WindowsAction( + content: 'Send', arguments: 'send-reply', inputId: 'text'), + ], + inputs: [ + WindowsTextInput( + id: 'text', title: 'Send a reply?', placeHolderContent: 'Message'), + ], + ); + const NotificationDetails notificationDetails = NotificationDetails( android: androidNotificationDetails, iOS: darwinNotificationDetails, macOS: darwinNotificationDetails, + windows: windowsNotificationDetails, ); await flutterLocalNotificationsPlugin.show(id++, 'Text Input Notification', @@ -1218,10 +1184,29 @@ class _HomePageState extends State { categoryIdentifier: darwinNotificationCategoryText, ); + const WindowsNotificationDetails windowsNotificationDetails = + WindowsNotificationDetails( + actions: [ + WindowsAction( + content: 'Submit', arguments: 'submit', inputId: 'choice'), + ], + inputs: [ + WindowsSelectionInput( + id: 'choice', + defaultItem: 'abc', + items: [ + WindowsSelection(id: 'abc', content: 'abc'), + WindowsSelection(id: 'def', content: 'def'), + ], + ), + ], + ); + const NotificationDetails notificationDetails = NotificationDetails( android: androidNotificationDetails, iOS: darwinNotificationDetails, macOS: darwinNotificationDetails, + windows: windowsNotificationDetails, ); await flutterLocalNotificationsPlugin.show( id++, 'plain title', 'plain body', notificationDetails, @@ -1346,11 +1331,17 @@ class _HomePageState extends State { LinuxNotificationDetails( sound: AssetsLinuxSound('sound/slow_spring_board.mp3'), ); + final WindowsNotificationDetails windowsNotificationDetails = + WindowsNotificationDetails( + audio: WindowsNotificationAudio.preset( + sound: WindowsNotificationSound.alarm5), + ); final NotificationDetails notificationDetails = NotificationDetails( android: androidNotificationDetails, iOS: darwinNotificationDetails, macOS: darwinNotificationDetails, linux: linuxPlatformChannelSpecifics, + windows: windowsNotificationDetails, ); await flutterLocalNotificationsPlugin.show( id++, @@ -1429,7 +1420,10 @@ class _HomePageState extends State { DarwinNotificationDetails( presentSound: false, ); - const NotificationDetails notificationDetails = NotificationDetails( + final WindowsNotificationDetails windowsDetails = + WindowsNotificationDetails(audio: WindowsNotificationAudio.silent()); + final NotificationDetails notificationDetails = NotificationDetails( + windows: windowsDetails, android: androidNotificationDetails, iOS: darwinNotificationDetails, macOS: darwinNotificationDetails); @@ -1449,7 +1443,10 @@ class _HomePageState extends State { DarwinNotificationDetails( presentSound: false, ); - const NotificationDetails notificationDetails = NotificationDetails( + final WindowsNotificationDetails windowsDetails = + WindowsNotificationDetails(audio: WindowsNotificationAudio.silent()); + final NotificationDetails notificationDetails = NotificationDetails( + windows: windowsDetails, android: androidNotificationDetails, iOS: darwinNotificationDetails, macOS: darwinNotificationDetails); @@ -1864,166 +1861,6 @@ class _HomePageState extends State { notificationDetails); } - Future _repeatNotification() async { - const AndroidNotificationDetails androidNotificationDetails = - AndroidNotificationDetails( - 'repeating channel id', 'repeating channel name', - channelDescription: 'repeating description'); - const NotificationDetails notificationDetails = - NotificationDetails(android: androidNotificationDetails); - await flutterLocalNotificationsPlugin.periodicallyShow( - id++, - 'repeating title', - 'repeating body', - RepeatInterval.everyMinute, - notificationDetails, - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - ); - } - - Future _repeatPeriodicallyWithDurationNotification() async { - const AndroidNotificationDetails androidNotificationDetails = - AndroidNotificationDetails( - 'repeating channel id', 'repeating channel name', - channelDescription: 'repeating description'); - const NotificationDetails notificationDetails = - NotificationDetails(android: androidNotificationDetails); - await flutterLocalNotificationsPlugin.periodicallyShowWithDuration( - id++, - 'repeating period title', - 'repeating period body', - const Duration(minutes: 5), - notificationDetails, - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - ); - } - - Future _scheduleDailyTenAMNotification() async { - await flutterLocalNotificationsPlugin.zonedSchedule( - 0, - 'daily scheduled notification title', - 'daily scheduled notification body', - _nextInstanceOfTenAM(), - const NotificationDetails( - android: AndroidNotificationDetails('daily notification channel id', - 'daily notification channel name', - channelDescription: 'daily notification description'), - ), - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - matchDateTimeComponents: DateTimeComponents.time); - } - - /// To test we don't validate past dates when using `matchDateTimeComponents` - Future _scheduleDailyTenAMLastYearNotification() async { - await flutterLocalNotificationsPlugin.zonedSchedule( - 0, - 'daily scheduled notification title', - 'daily scheduled notification body', - _nextInstanceOfTenAMLastYear(), - const NotificationDetails( - android: AndroidNotificationDetails('daily notification channel id', - 'daily notification channel name', - channelDescription: 'daily notification description'), - ), - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - matchDateTimeComponents: DateTimeComponents.time); - } - - Future _scheduleWeeklyTenAMNotification() async { - await flutterLocalNotificationsPlugin.zonedSchedule( - 0, - 'weekly scheduled notification title', - 'weekly scheduled notification body', - _nextInstanceOfTenAM(), - const NotificationDetails( - android: AndroidNotificationDetails('weekly notification channel id', - 'weekly notification channel name', - channelDescription: 'weekly notificationdescription'), - ), - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime); - } - - Future _scheduleWeeklyMondayTenAMNotification() async { - await flutterLocalNotificationsPlugin.zonedSchedule( - 0, - 'weekly scheduled notification title', - 'weekly scheduled notification body', - _nextInstanceOfMondayTenAM(), - const NotificationDetails( - android: AndroidNotificationDetails('weekly notification channel id', - 'weekly notification channel name', - channelDescription: 'weekly notificationdescription'), - ), - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime); - } - - Future _scheduleMonthlyMondayTenAMNotification() async { - await flutterLocalNotificationsPlugin.zonedSchedule( - 0, - 'monthly scheduled notification title', - 'monthly scheduled notification body', - _nextInstanceOfMondayTenAM(), - const NotificationDetails( - android: AndroidNotificationDetails('monthly notification channel id', - 'monthly notification channel name', - channelDescription: 'monthly notificationdescription'), - ), - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - matchDateTimeComponents: DateTimeComponents.dayOfMonthAndTime); - } - - Future _scheduleYearlyMondayTenAMNotification() async { - await flutterLocalNotificationsPlugin.zonedSchedule( - 0, - 'yearly scheduled notification title', - 'yearly scheduled notification body', - _nextInstanceOfMondayTenAM(), - const NotificationDetails( - android: AndroidNotificationDetails('yearly notification channel id', - 'yearly notification channel name', - channelDescription: 'yearly notification description'), - ), - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - matchDateTimeComponents: DateTimeComponents.dateAndTime); - } - - tz.TZDateTime _nextInstanceOfTenAM() { - final tz.TZDateTime now = tz.TZDateTime.now(tz.local); - tz.TZDateTime scheduledDate = - tz.TZDateTime(tz.local, now.year, now.month, now.day, 10); - if (scheduledDate.isBefore(now)) { - scheduledDate = scheduledDate.add(const Duration(days: 1)); - } - return scheduledDate; - } - - tz.TZDateTime _nextInstanceOfTenAMLastYear() { - final tz.TZDateTime now = tz.TZDateTime.now(tz.local); - return tz.TZDateTime(tz.local, now.year - 1, now.month, now.day, 10); - } - - tz.TZDateTime _nextInstanceOfMondayTenAM() { - tz.TZDateTime scheduledDate = _nextInstanceOfTenAM(); - while (scheduledDate.weekday != DateTime.monday) { - scheduledDate = scheduledDate.add(const Duration(days: 1)); - } - return scheduledDate; - } - Future _showNotificationWithNoBadge() async { const AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails('no badge channel', 'no badge name', @@ -2869,6 +2706,16 @@ class _HomePageState extends State { platformChannelSpecifics, ); } + + Future? _showWindowsNotificationWithRawXml() => + flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + FlutterLocalNotificationsWindows>() + ?.showRawXml( + id: id++, + xml: _windowsRawXmlController.text, + bindings: {'message': 'Hello, World!'}, + ); } Future _showLinuxNotificationWithBodyMarkup() async { @@ -2905,7 +2752,7 @@ Future _showLinuxNotificationWithByteDataIcon() async { 'icons/app_icon_density.png', ); final image.Image? iconData = image.decodePng( - assetIcon.buffer.asUint8List().toList(), + assetIcon.buffer.asUint8List(), ); final Uint8List iconBytes = iconData!.getBytes(); final LinuxNotificationDetails linuxPlatformChannelSpecifics = @@ -3083,12 +2930,14 @@ Future getLinuxCapabilities() => class SecondPage extends StatefulWidget { const SecondPage( this.payload, { + this.data, Key? key, }) : super(key: key); static const String routeName = '/secondPage'; final String? payload; + final Map? data; @override State createState() => SecondPageState(); @@ -3096,11 +2945,13 @@ class SecondPage extends StatefulWidget { class SecondPageState extends State { String? _payload; + Map? _data; @override void initState() { super.initState(); _payload = widget.payload; + _data = widget.data; } @override @@ -3113,6 +2964,7 @@ class SecondPageState extends State { mainAxisSize: MainAxisSize.min, children: [ Text('payload ${_payload ?? ''}'), + Text('data ${_data ?? ''}'), ElevatedButton( onPressed: () { Navigator.pop(context); diff --git a/flutter_local_notifications/example/lib/padded_button.dart b/flutter_local_notifications/example/lib/padded_button.dart new file mode 100644 index 000000000..5867d5e3e --- /dev/null +++ b/flutter_local_notifications/example/lib/padded_button.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class PaddedElevatedButton extends StatelessWidget { + const PaddedElevatedButton({ + required this.buttonText, + required this.onPressed, + Key? key, + }) : super(key: key); + + final String buttonText; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 8), + child: ElevatedButton( + onPressed: onPressed, + child: Text(buttonText), + ), + ); +} diff --git a/flutter_local_notifications/example/lib/plugin.dart b/flutter_local_notifications/example/lib/plugin.dart new file mode 100644 index 000000000..b64f21809 --- /dev/null +++ b/flutter_local_notifications/example/lib/plugin.dart @@ -0,0 +1,6 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + +int id = 0; diff --git a/flutter_local_notifications/example/lib/repeating.dart b/flutter_local_notifications/example/lib/repeating.dart new file mode 100644 index 000000000..8eea896a4 --- /dev/null +++ b/flutter_local_notifications/example/lib/repeating.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/timezone.dart' as tz; + +import 'padded_button.dart'; +import 'plugin.dart'; + +List examples(BuildContext context) => [ + const Divider(), + const Text( + 'Repeating notifications', + style: TextStyle(fontWeight: FontWeight.bold), + ), + PaddedElevatedButton( + buttonText: 'Repeat notification every minute', + onPressed: () async { + await _repeatNotification(); + }, + ), + PaddedElevatedButton( + buttonText: 'Repeat notification every 5 minutes', + onPressed: () async { + await _repeatPeriodicallyWithDurationNotification(); + }, + ), + PaddedElevatedButton( + buttonText: 'Schedule daily 10:00:00 am notification in your ' + 'local time zone', + onPressed: () async { + await _scheduleDailyTenAMNotification(); + }, + ), + PaddedElevatedButton( + buttonText: 'Schedule daily 10:00:00 am notification in your ' + "local time zone using last year's date", + onPressed: () async { + await _scheduleDailyTenAMLastYearNotification(); + }, + ), + PaddedElevatedButton( + buttonText: 'Schedule weekly 10:00:00 am notification in your ' + 'local time zone', + onPressed: () async { + await _scheduleWeeklyTenAMNotification(); + }, + ), + PaddedElevatedButton( + buttonText: 'Schedule weekly Monday 10:00:00 am notification ' + 'in your local time zone', + onPressed: () async { + await _scheduleWeeklyMondayTenAMNotification(); + }, + ), + PaddedElevatedButton( + buttonText: 'Schedule monthly Monday 10:00:00 am notification in ' + 'your local time zone', + onPressed: () async { + await _scheduleMonthlyMondayTenAMNotification(); + }, + ), + PaddedElevatedButton( + buttonText: 'Schedule yearly Monday 10:00:00 am notification in ' + 'your local time zone', + onPressed: () async { + await _scheduleYearlyMondayTenAMNotification(); + }, + ), + ]; + +/// To test we don't validate past dates when using `matchDateTimeComponents` +Future _scheduleDailyTenAMLastYearNotification() async { + await flutterLocalNotificationsPlugin.zonedSchedule( + 0, + 'daily scheduled notification title', + 'daily scheduled notification body', + _nextInstanceOfTenAMLastYear(), + const NotificationDetails( + android: AndroidNotificationDetails( + 'daily notification channel id', 'daily notification channel name', + channelDescription: 'daily notification description'), + ), + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + matchDateTimeComponents: DateTimeComponents.time); +} + +Future _scheduleWeeklyTenAMNotification() async { + await flutterLocalNotificationsPlugin.zonedSchedule( + 0, + 'weekly scheduled notification title', + 'weekly scheduled notification body', + _nextInstanceOfTenAM(), + const NotificationDetails( + android: AndroidNotificationDetails('weekly notification channel id', + 'weekly notification channel name', + channelDescription: 'weekly notificationdescription'), + ), + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime); +} + +Future _scheduleWeeklyMondayTenAMNotification() async { + await flutterLocalNotificationsPlugin.zonedSchedule( + 0, + 'weekly scheduled notification title', + 'weekly scheduled notification body', + _nextInstanceOfMondayTenAM(), + const NotificationDetails( + android: AndroidNotificationDetails('weekly notification channel id', + 'weekly notification channel name', + channelDescription: 'weekly notificationdescription'), + ), + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime); +} + +Future _scheduleMonthlyMondayTenAMNotification() async { + await flutterLocalNotificationsPlugin.zonedSchedule( + 0, + 'monthly scheduled notification title', + 'monthly scheduled notification body', + _nextInstanceOfMondayTenAM(), + const NotificationDetails( + android: AndroidNotificationDetails('monthly notification channel id', + 'monthly notification channel name', + channelDescription: 'monthly notificationdescription'), + ), + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + matchDateTimeComponents: DateTimeComponents.dayOfMonthAndTime); +} + +Future _scheduleYearlyMondayTenAMNotification() async { + await flutterLocalNotificationsPlugin.zonedSchedule( + 0, + 'yearly scheduled notification title', + 'yearly scheduled notification body', + _nextInstanceOfMondayTenAM(), + const NotificationDetails( + android: AndroidNotificationDetails('yearly notification channel id', + 'yearly notification channel name', + channelDescription: 'yearly notification description'), + ), + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + matchDateTimeComponents: DateTimeComponents.dateAndTime); +} + +Future _repeatNotification() async { + const AndroidNotificationDetails androidNotificationDetails = + AndroidNotificationDetails( + 'repeating channel id', 'repeating channel name', + channelDescription: 'repeating description'); + const NotificationDetails notificationDetails = + NotificationDetails(android: androidNotificationDetails); + await flutterLocalNotificationsPlugin.periodicallyShow( + id++, + 'repeating title', + 'repeating body', + RepeatInterval.everyMinute, + notificationDetails, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + ); +} + +Future _repeatPeriodicallyWithDurationNotification() async { + const AndroidNotificationDetails androidNotificationDetails = + AndroidNotificationDetails( + 'repeating channel id', 'repeating channel name', + channelDescription: 'repeating description'); + const NotificationDetails notificationDetails = + NotificationDetails(android: androidNotificationDetails); + await flutterLocalNotificationsPlugin.periodicallyShowWithDuration( + id++, + 'repeating period title', + 'repeating period body', + const Duration(minutes: 5), + notificationDetails, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + ); +} + +Future _scheduleDailyTenAMNotification() async { + await flutterLocalNotificationsPlugin.zonedSchedule( + 0, + 'daily scheduled notification title', + 'daily scheduled notification body', + _nextInstanceOfTenAM(), + const NotificationDetails( + android: AndroidNotificationDetails( + 'daily notification channel id', 'daily notification channel name', + channelDescription: 'daily notification description'), + ), + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + matchDateTimeComponents: DateTimeComponents.time); +} + +tz.TZDateTime _nextInstanceOfTenAM() { + final tz.TZDateTime now = tz.TZDateTime.now(tz.local); + tz.TZDateTime scheduledDate = + tz.TZDateTime(tz.local, now.year, now.month, now.day, 10); + if (scheduledDate.isBefore(now)) { + scheduledDate = scheduledDate.add(const Duration(days: 1)); + } + return scheduledDate; +} + +tz.TZDateTime _nextInstanceOfTenAMLastYear() { + final tz.TZDateTime now = tz.TZDateTime.now(tz.local); + return tz.TZDateTime(tz.local, now.year - 1, now.month, now.day, 10); +} + +tz.TZDateTime _nextInstanceOfMondayTenAM() { + tz.TZDateTime scheduledDate = _nextInstanceOfTenAM(); + while (scheduledDate.weekday != DateTime.monday) { + scheduledDate = scheduledDate.add(const Duration(days: 1)); + } + return scheduledDate; +} diff --git a/flutter_local_notifications/example/lib/windows.dart b/flutter_local_notifications/example/lib/windows.dart new file mode 100644 index 000000000..c80a4e70d --- /dev/null +++ b/flutter_local_notifications/example/lib/windows.dart @@ -0,0 +1,408 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import 'padded_button.dart'; +import 'plugin.dart'; + +const WindowsInitializationSettings initSettings = + WindowsInitializationSettings( + appName: 'Flutter Local Notifications Example', + appUserModelId: 'Com.Dexterous.FlutterLocalNotificationsExample', + guid: 'd49b0314-ee7a-4626-bf79-97cdb8a991bb', +); + +List examples({ + required TextEditingController xmlController, + required VoidCallback showXmlNotification, +}) => + [ + const Text( + 'Windows-specific examples', + style: TextStyle(fontWeight: FontWeight.bold), + ), + PaddedElevatedButton( + buttonText: 'Show short and long notifications notification', + onPressed: () async { + await _showWindowsNotificationWithDuration(); + }, + ), + PaddedElevatedButton( + buttonText: 'Show different scenarios', + onPressed: () async { + await _showWindowsNotificationWithScenarios(); + }, + ), + PaddedElevatedButton( + buttonText: 'Show notifications with some detail', + onPressed: () async { + await _showWindowsNotificationWithDetails(); + }, + ), + PaddedElevatedButton( + buttonText: 'Show notifications with image', + onPressed: () async { + await _showWindowsNotificationWithImages(); + }, + ), + PaddedElevatedButton( + buttonText: 'Show notifications with columns', + onPressed: () async { + await _showWindowsNotificationWithGroups(); + }, + ), + PaddedElevatedButton( + buttonText: 'Show notifications with progress bar', + onPressed: () async { + await _showWindowsNotificationWithProgress(); + }, + ), + PaddedElevatedButton( + buttonText: 'Show notifications with dynamic content', + onPressed: () async { + await _showWindowsNotificationWithDynamic(); + }, + ), + PaddedElevatedButton( + buttonText: 'Show notification with activation', + onPressed: () async { + await _showWindowsNotificationWithActivation(); + }, + ), + PaddedElevatedButton( + buttonText: 'Show notification with button styles', + onPressed: () async { + await _showWindowsNotificationWithButtonStyle(); + }, + ), + PaddedElevatedButton( + buttonText: 'Show notifications in a group', + onPressed: () async { + await _showWindowsNotificationWithHeader(); + }, + ), + PaddedElevatedButton( + buttonText: 'Show notification with raw XML', + onPressed: showXmlNotification, + ), + const SizedBox(height: 8), + SizedBox( + width: 500, + child: ExpansionTile( + title: const Text('Click to expand raw XML'), + children: [ + TextField( + maxLines: 20, + style: const TextStyle(fontFamily: 'RobotoMono'), + controller: xmlController, + decoration: InputDecoration( + hintText: 'Enter the raw xml', + helperText: 'Bindings: {message} --> Hello, World!', + constraints: + const BoxConstraints.tightFor(width: 600, height: 480), + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () => xmlController.clear(), + ), + ), + ), + ]), + ), + ]; + +Future _showWindowsNotificationWithDuration() async { + await flutterLocalNotificationsPlugin.show( + id++, + 'This is a short notification', + 'This will last about 7 seconds', + const NotificationDetails( + windows: WindowsNotificationDetails( + duration: WindowsNotificationDuration.short), + ), + ); + await flutterLocalNotificationsPlugin.show( + id++, + 'This is a long notification', + 'This will last about 25 seconds', + const NotificationDetails( + windows: WindowsNotificationDetails( + duration: WindowsNotificationDuration.long), + ), + ); +} + +Future _showWindowsNotificationWithScenarios() async { + await flutterLocalNotificationsPlugin.show( + id++, + 'This is an alarm', + null, + const NotificationDetails( + windows: WindowsNotificationDetails( + scenario: WindowsNotificationScenario.alarm, + actions: [ + WindowsAction(content: 'Button', arguments: 'button') + ]), + ), + ); + await flutterLocalNotificationsPlugin.show( + id++, + 'This is an incoming call', + null, + const NotificationDetails( + windows: WindowsNotificationDetails( + scenario: WindowsNotificationScenario.incomingCall, + actions: [ + WindowsAction(content: 'Button', arguments: 'button') + ]), + ), + ); + await flutterLocalNotificationsPlugin.show( + id++, + 'This is a reminder', + null, + const NotificationDetails( + windows: WindowsNotificationDetails( + scenario: WindowsNotificationScenario.reminder, + actions: [ + WindowsAction(content: 'Button', arguments: 'button') + ]), + ), + ); + await flutterLocalNotificationsPlugin.show( + id++, + 'This is an urgent notification', + null, + const NotificationDetails( + windows: WindowsNotificationDetails( + scenario: WindowsNotificationScenario.urgent, + actions: [ + WindowsAction(content: 'Button', arguments: 'button') + ]), + ), + ); +} + +Future _showWindowsNotificationWithDetails() => + flutterLocalNotificationsPlugin.show( + id++, + 'This one has more details', + 'And a different timestamp!', + NotificationDetails( + windows: WindowsNotificationDetails( + subtitle: 'This is the subtitle', + timestamp: + DateTime.now().subtract(const Duration(hours: 2, minutes: 5)), + ), + ), + ); + +Future _showWindowsNotificationWithImages() => + flutterLocalNotificationsPlugin.show( + id++, + 'This notification has an image', + 'You can only show images from files', + NotificationDetails( + windows: WindowsNotificationDetails( + images: [ + WindowsImage.file( + File('./icons/4.0x/app_icon_density.png').absolute, + altText: 'A beautiful image', + ), + ], + ), + ), + ); + +Future _showWindowsNotificationWithGroups() => + flutterLocalNotificationsPlugin.show( + id++, + 'This notification has many groups', + 'Each group stays together', + NotificationDetails( + windows: WindowsNotificationDetails( + subtitle: 'Caption text is fainter', + rows: [ + WindowsRow([ + WindowsColumn([ + WindowsImage.file(File('icons/coworker.png').absolute, + altText: 'A coworker'), + const WindowsNotificationText( + text: 'A coworker', isCaption: true), + ]), + WindowsColumn([ + WindowsImage.file( + File('icons/4.0x/app_icon_density.png').absolute, + altText: 'The icon'), + const WindowsNotificationText(text: 'The icon'), + ]), + ]), + ], + ), + ), + ); + +Future _showWindowsNotificationWithProgress() async { + final WindowsProgressBar fastProgress = WindowsProgressBar( + id: 'fast-progress', status: 'Updating quickly...', value: 0); + final WindowsProgressBar slowProgress = WindowsProgressBar( + id: 'slow-progress', + status: 'Updating slowly...', + value: 0, + label: '0 / 10'); + final int notificationId = id++; + final FlutterLocalNotificationsWindows? windows = + flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation< + FlutterLocalNotificationsWindows>(); + await flutterLocalNotificationsPlugin.show( + notificationId, + 'This notification has progress bars', + 'You can have precise or indeterminate', + NotificationDetails( + windows: WindowsNotificationDetails( + progressBars: [ + WindowsProgressBar( + id: 'indeterminate', + title: 'This has indeterminate progress', + status: 'Downloading...', + value: null, + ), + WindowsProgressBar( + id: 'continuous', + title: 'This has continuous progress', + status: 'Uploading...', + value: 0.75, + ), + WindowsProgressBar( + id: 'discrete', + title: 'This has discrete progress', + status: 'Syncing...', + value: 0.75, + label: '9/12 complete'), + fastProgress, + slowProgress, + ], + ), + ), + ); + + int count = 0; + Timer.periodic(const Duration(milliseconds: 100), (Timer timer) async { + fastProgress.value = fastProgress.value! + 0.05; + slowProgress.value = count++ / 50; + fastProgress.value = fastProgress.value!.clamp(0, 1); + slowProgress.value = slowProgress.value!.clamp(0, 1); + if (fastProgress.value == 1 && slowProgress.value == 1) { + return timer.cancel(); + } + count = count.clamp(0, 50); + slowProgress.label = '$count / 50'; + await windows?.updateProgressBar( + notificationId: notificationId, progressBar: fastProgress); + await windows?.updateProgressBar( + notificationId: notificationId, progressBar: slowProgress); + }); +} + +Future _showWindowsNotificationWithDynamic() async { + final DateTime start = DateTime.now(); + final int notificationId = id++; + final FlutterLocalNotificationsWindows? windows = + flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation< + FlutterLocalNotificationsWindows>(); + await flutterLocalNotificationsPlugin.show( + notificationId, + 'Dynamic content', + 'This notification will be updated from Dart code', + const NotificationDetails( + windows: WindowsNotificationDetails( + subtitle: '{stopwatch}', + ), + ), + ); + Map getBindings() => { + 'stopwatch': + 'Elapsed time: ${DateTime.now().difference(start).inSeconds} seconds', + }; + await windows?.updateBindings(id: notificationId, bindings: getBindings()); + Timer.periodic(const Duration(seconds: 1), (Timer timer) async { + if (timer.tick > 10) { + timer.cancel(); + await flutterLocalNotificationsPlugin.cancel(notificationId); + return; + } + await windows?.updateBindings(id: notificationId, bindings: getBindings()); + }); +} + +Future _showWindowsNotificationWithActivation() => + flutterLocalNotificationsPlugin.show( + id++, + 'These buttons do different things', + 'Click on each one!', + const NotificationDetails( + windows: WindowsNotificationDetails( + actions: [ + WindowsAction( + content: 'Loading', + arguments: 'loading', + activationBehavior: WindowsNotificationBehavior.pendingUpdate, + ), + WindowsAction( + content: 'Google', + arguments: 'https://google.com', + activationType: WindowsActivationType.protocol, + activationBehavior: WindowsNotificationBehavior.pendingUpdate, + ), + ], + ), + ), + ); + +Future _showWindowsNotificationWithButtonStyle() => + flutterLocalNotificationsPlugin.show( + id++, + 'Incoming call', + 'Your best friend', + const NotificationDetails( + windows: WindowsNotificationDetails( + actions: [ + WindowsAction( + content: 'Accept', + arguments: 'accept', + buttonStyle: WindowsButtonStyle.success, + ), + WindowsAction( + content: 'Reject', + arguments: 'reject', + buttonStyle: WindowsButtonStyle.critical, + ), + ], + ), + ), + ); + +Future _showWindowsNotificationWithHeader() async { + const WindowsHeader header = WindowsHeader( + id: 'header', + title: 'Cool notifications', + arguments: 'header-clicked', + ); + await flutterLocalNotificationsPlugin.show( + id++, + 'This is the first notification', + null, + const NotificationDetails( + windows: WindowsNotificationDetails(header: header), + ), + ); + await flutterLocalNotificationsPlugin.show( + id++, + 'This is the second notification', + null, + const NotificationDetails( + windows: WindowsNotificationDetails(header: header), + ), + ); +} diff --git a/flutter_local_notifications/example/pubspec.yaml b/flutter_local_notifications/example/pubspec.yaml index 57dfa6023..ade28741c 100644 --- a/flutter_local_notifications/example/pubspec.yaml +++ b/flutter_local_notifications/example/pubspec.yaml @@ -10,9 +10,10 @@ dependencies: flutter_local_notifications: path: ../ flutter_timezone: ^1.0.4 - http: ^0.13.4 - image: ^3.0.8 + http: ^1.2.1 + image: ^4.2.0 path_provider: ^2.0.0 + timezone: ^0.9.4 dev_dependencies: flutter_driver: @@ -21,6 +22,7 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter + msix: ^3.16.7 flutter: uses-material-design: true @@ -31,3 +33,15 @@ flutter: environment: sdk: ^3.1.0 flutter: ">=3.1.3" + +msix_config: + display_name: Flutter Local Notifications Example + identity_name: Com.Example.FlutterLocalNotificationsExample + msix_version: 1.0.0.0 + store: false + install_certificate: false + output_name: example + toast_activator: + clsid: "d49b0314-ee7a-4626-bf79-97cdb8a991bb" + arguments: "msix-args" + display_name: "Flutter Local Notifications" diff --git a/flutter_local_notifications/example/windows/.gitignore b/flutter_local_notifications/example/windows/.gitignore new file mode 100644 index 000000000..571c3131e --- /dev/null +++ b/flutter_local_notifications/example/windows/.gitignore @@ -0,0 +1,21 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +.vs +out +flutter/ephemeral diff --git a/flutter_local_notifications/example/windows/CMakeLists.txt b/flutter_local_notifications/example/windows/CMakeLists.txt new file mode 100644 index 000000000..1633297a0 --- /dev/null +++ b/flutter_local_notifications/example/windows/CMakeLists.txt @@ -0,0 +1,95 @@ +cmake_minimum_required(VERSION 3.14) +project(example LANGUAGES CXX) + +set(BINARY_NAME "example") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Configure build options. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() + +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/flutter_local_notifications/example/windows/flutter/CMakeLists.txt b/flutter_local_notifications/example/windows/flutter/CMakeLists.txt new file mode 100644 index 000000000..4f2af69bb --- /dev/null +++ b/flutter_local_notifications/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,108 @@ +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/flutter_local_notifications/example/windows/flutter/generated_plugin_registrant.cc b/flutter_local_notifications/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..8b6d4680a --- /dev/null +++ b/flutter_local_notifications/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/flutter_local_notifications/example/windows/flutter/generated_plugin_registrant.h b/flutter_local_notifications/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..dc139d85a --- /dev/null +++ b/flutter_local_notifications/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/flutter_local_notifications/example/windows/flutter/generated_plugins.cmake b/flutter_local_notifications/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 000000000..93f25e155 --- /dev/null +++ b/flutter_local_notifications/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/flutter_local_notifications/example/windows/runner/CMakeLists.txt b/flutter_local_notifications/example/windows/runner/CMakeLists.txt new file mode 100644 index 000000000..de2d8916b --- /dev/null +++ b/flutter_local_notifications/example/windows/runner/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) +apply_standard_settings(${BINARY_NAME}) +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/flutter_local_notifications/example/windows/runner/RCa04060 b/flutter_local_notifications/example/windows/runner/RCa04060 new file mode 100644 index 000000000..a890c5b8c Binary files /dev/null and b/flutter_local_notifications/example/windows/runner/RCa04060 differ diff --git a/flutter_local_notifications/example/windows/runner/RCb04060 b/flutter_local_notifications/example/windows/runner/RCb04060 new file mode 100644 index 000000000..a890c5b8c Binary files /dev/null and b/flutter_local_notifications/example/windows/runner/RCb04060 differ diff --git a/flutter_local_notifications/example/windows/runner/Runner.rc b/flutter_local_notifications/example/windows/runner/Runner.rc new file mode 100644 index 000000000..f377c3592 --- /dev/null +++ b/flutter_local_notifications/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.dexterous" "\0" + VALUE "FileDescription", "example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2022 com.dexterous. All rights reserved." "\0" + VALUE "OriginalFilename", "example.exe" "\0" + VALUE "ProductName", "example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/flutter_local_notifications/example/windows/runner/flutter_window.cpp b/flutter_local_notifications/example/windows/runner/flutter_window.cpp new file mode 100644 index 000000000..b43b9095e --- /dev/null +++ b/flutter_local_notifications/example/windows/runner/flutter_window.cpp @@ -0,0 +1,61 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/flutter_local_notifications/example/windows/runner/flutter_window.h b/flutter_local_notifications/example/windows/runner/flutter_window.h new file mode 100644 index 000000000..6da0652f0 --- /dev/null +++ b/flutter_local_notifications/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/flutter_local_notifications/example/windows/runner/main.cpp b/flutter_local_notifications/example/windows/runner/main.cpp new file mode 100644 index 000000000..87602243e --- /dev/null +++ b/flutter_local_notifications/example/windows/runner/main.cpp @@ -0,0 +1,44 @@ +#include +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.CreateAndShow(L"example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/flutter_local_notifications/example/windows/runner/resource.h b/flutter_local_notifications/example/windows/runner/resource.h new file mode 100644 index 000000000..66a65d1e4 --- /dev/null +++ b/flutter_local_notifications/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/flutter_local_notifications/example/windows/runner/resources/app_icon.ico b/flutter_local_notifications/example/windows/runner/resources/app_icon.ico new file mode 100644 index 000000000..c04e20caf Binary files /dev/null and b/flutter_local_notifications/example/windows/runner/resources/app_icon.ico differ diff --git a/flutter_local_notifications/example/windows/runner/runner.exe.manifest b/flutter_local_notifications/example/windows/runner/runner.exe.manifest new file mode 100644 index 000000000..c977c4a42 --- /dev/null +++ b/flutter_local_notifications/example/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/flutter_local_notifications/example/windows/runner/utils.cpp b/flutter_local_notifications/example/windows/runner/utils.cpp new file mode 100644 index 000000000..d19bdbbcc --- /dev/null +++ b/flutter_local_notifications/example/windows/runner/utils.cpp @@ -0,0 +1,64 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr); + if (target_length == 0) { + return std::string(); + } + std::string utf8_string; + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, utf8_string.data(), + target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/flutter_local_notifications/example/windows/runner/utils.h b/flutter_local_notifications/example/windows/runner/utils.h new file mode 100644 index 000000000..3879d5475 --- /dev/null +++ b/flutter_local_notifications/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/flutter_local_notifications/example/windows/runner/win32_window.cpp b/flutter_local_notifications/example/windows/runner/win32_window.cpp new file mode 100644 index 000000000..c10f08dc7 --- /dev/null +++ b/flutter_local_notifications/example/windows/runner/win32_window.cpp @@ -0,0 +1,245 @@ +#include "win32_window.h" + +#include + +#include "resource.h" + +namespace { + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + FreeLibrary(user32_module); + } +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + return OnCreate(); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} diff --git a/flutter_local_notifications/example/windows/runner/win32_window.h b/flutter_local_notifications/example/windows/runner/win32_window.h new file mode 100644 index 000000000..17ba43112 --- /dev/null +++ b/flutter_local_notifications/example/windows/runner/win32_window.h @@ -0,0 +1,98 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates and shows a win32 window with |title| and position and size using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size to will treat the width height passed in to this function + // as logical pixels and scale to appropriate for the default monitor. Returns + // true if the window was created successfully. + bool CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responsponds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/flutter_local_notifications/lib/flutter_local_notifications.dart b/flutter_local_notifications/lib/flutter_local_notifications.dart index 21f06768e..c97d57460 100644 --- a/flutter_local_notifications/lib/flutter_local_notifications.dart +++ b/flutter_local_notifications/lib/flutter_local_notifications.dart @@ -1,14 +1,6 @@ export 'package:flutter_local_notifications_linux/flutter_local_notifications_linux.dart'; -export 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart' - show - DidReceiveBackgroundNotificationResponseCallback, - DidReceiveNotificationResponseCallback, - PendingNotificationRequest, - ActiveNotification, - RepeatInterval, - NotificationAppLaunchDetails, - NotificationResponse, - NotificationResponseType; +export 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart'; +export 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; export 'src/flutter_local_notifications_plugin.dart'; export 'src/initialization_settings.dart'; @@ -44,5 +36,6 @@ export 'src/platform_specifics/darwin/notification_category_option.dart'; export 'src/platform_specifics/darwin/notification_details.dart'; export 'src/platform_specifics/darwin/notification_enabled_options.dart'; export 'src/platform_specifics/ios/enums.dart'; + export 'src/typedefs.dart'; export 'src/types.dart'; diff --git a/flutter_local_notifications/lib/src/flutter_local_notifications_plugin.dart b/flutter_local_notifications/lib/src/flutter_local_notifications_plugin.dart index aae3c05d9..bc0265fe1 100644 --- a/flutter_local_notifications/lib/src/flutter_local_notifications_plugin.dart +++ b/flutter_local_notifications/lib/src/flutter_local_notifications_plugin.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications_linux/flutter_local_notifications_linux.dart'; import 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart'; +import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; import 'package:timezone/timezone.dart'; import 'initialization_settings.dart'; @@ -69,6 +70,11 @@ class FlutterLocalNotificationsPlugin { FlutterLocalNotificationsPlatform.instance is LinuxFlutterLocalNotificationsPlugin) { return FlutterLocalNotificationsPlatform.instance as T?; + } else if (defaultTargetPlatform == TargetPlatform.windows && + T == FlutterLocalNotificationsWindows && + FlutterLocalNotificationsPlatform.instance + is FlutterLocalNotificationsWindows) { + return FlutterLocalNotificationsPlatform.instance as T?; } return null; @@ -167,6 +173,18 @@ class FlutterLocalNotificationsPlugin { initializationSettings.linux!, onDidReceiveNotificationResponse: onDidReceiveNotificationResponse, ); + } else if (defaultTargetPlatform == TargetPlatform.windows) { + if (initializationSettings.windows == null) { + throw ArgumentError( + 'Windows settings must be set when targeting Windows platform.'); + } + + return await resolvePlatformSpecificImplementation< + FlutterLocalNotificationsWindows>() + ?.initialize( + initializationSettings.windows!, + onNotificationReceived: onDidReceiveNotificationResponse, + ); } return true; } @@ -200,6 +218,10 @@ class FlutterLocalNotificationsPlugin { return await resolvePlatformSpecificImplementation< MacOSFlutterLocalNotificationsPlugin>() ?.getNotificationAppLaunchDetails(); + } else if (defaultTargetPlatform == TargetPlatform.windows) { + return await resolvePlatformSpecificImplementation< + FlutterLocalNotificationsWindows>() + ?.getNotificationAppLaunchDetails(); } else { return await FlutterLocalNotificationsPlatform.instance .getNotificationAppLaunchDetails() ?? @@ -242,6 +264,11 @@ class FlutterLocalNotificationsPlugin { ?.show(id, title, body, notificationDetails: notificationDetails?.linux, payload: payload); + } else if (defaultTargetPlatform == TargetPlatform.windows) { + await resolvePlatformSpecificImplementation< + FlutterLocalNotificationsWindows>() + ?.show(id, title, body, + details: notificationDetails?.windows, payload: payload); } else { await FlutterLocalNotificationsPlatform.instance.show(id, title, body); } @@ -307,6 +334,9 @@ class FlutterLocalNotificationsPlugin { /// On Android, this will also require additional setup for the app, /// especially in the app's `AndroidManifest.xml` file. Please see check the /// readme for further details. + /// + /// On Windows, this will only set a notification on the [scheduledDate], and + /// not repeat, regardless of the value for [matchDateTimeComponents]. Future zonedSchedule( int id, String? title, @@ -346,6 +376,17 @@ class FlutterLocalNotificationsPlugin { id, title, body, scheduledDate, notificationDetails.macOS, payload: payload, matchDateTimeComponents: matchDateTimeComponents); + } else if (defaultTargetPlatform == TargetPlatform.windows) { + await resolvePlatformSpecificImplementation< + FlutterLocalNotificationsWindows>() + ?.zonedSchedule( + id, + title, + body, + scheduledDate, + notificationDetails.windows, + payload: payload, + ); } else { throw UnimplementedError('zonedSchedule() has not been implemented'); } @@ -389,6 +430,8 @@ class FlutterLocalNotificationsPlugin { MacOSFlutterLocalNotificationsPlugin>() ?.periodicallyShow(id, title, body, repeatInterval, notificationDetails: notificationDetails.macOS, payload: payload); + } else if (defaultTargetPlatform == TargetPlatform.windows) { + throw UnsupportedError('Notifications do not repeat on Windows'); } else { await FlutterLocalNotificationsPlatform.instance .periodicallyShow(id, title, body, repeatInterval); @@ -457,6 +500,10 @@ class FlutterLocalNotificationsPlugin { /// - macOS: macOS 10.14 or newer /// /// On Linux it will throw an [UnimplementedError]. + /// + /// On Windows, your application must be packaged as an MSIX to be able + /// to use this API. If not, this function will return an empty list. + /// For more details, see: https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/modernize-wpf-tutorial-5 Future> getActiveNotifications() => FlutterLocalNotificationsPlatform.instance.getActiveNotifications(); } diff --git a/flutter_local_notifications/lib/src/initialization_settings.dart b/flutter_local_notifications/lib/src/initialization_settings.dart index edad98426..ddd7b356d 100644 --- a/flutter_local_notifications/lib/src/initialization_settings.dart +++ b/flutter_local_notifications/lib/src/initialization_settings.dart @@ -1,7 +1,4 @@ -import 'package:flutter_local_notifications_linux/flutter_local_notifications_linux.dart'; - -import 'platform_specifics/android/initialization_settings.dart'; -import 'platform_specifics/darwin/initialization_settings.dart'; +import '../flutter_local_notifications.dart'; /// Settings for initializing the plugin for each platform. class InitializationSettings { @@ -11,6 +8,7 @@ class InitializationSettings { this.iOS, this.macOS, this.linux, + this.windows, }); /// Settings for Android. @@ -36,4 +34,7 @@ class InitializationSettings { /// It is nullable, because we don't want to force users to specify settings /// for platforms that they don't target. final LinuxInitializationSettings? linux; + + /// Settings for Windows. + final WindowsInitializationSettings? windows; } diff --git a/flutter_local_notifications/lib/src/notification_details.dart b/flutter_local_notifications/lib/src/notification_details.dart index feb9b1a79..070b03ae1 100644 --- a/flutter_local_notifications/lib/src/notification_details.dart +++ b/flutter_local_notifications/lib/src/notification_details.dart @@ -1,4 +1,5 @@ import 'package:flutter_local_notifications_linux/flutter_local_notifications_linux.dart'; +import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; import 'platform_specifics/android/notification_details.dart'; import 'platform_specifics/darwin/notification_details.dart'; @@ -11,6 +12,7 @@ class NotificationDetails { this.iOS, this.macOS, this.linux, + this.windows, }); /// Notification details for Android. @@ -24,4 +26,7 @@ class NotificationDetails { /// Notification details for Linux. final LinuxNotificationDetails? linux; + + /// Notification details for Windows. + final WindowsNotificationDetails? windows; } diff --git a/flutter_local_notifications/lib/src/platform_flutter_local_notifications.dart b/flutter_local_notifications/lib/src/platform_flutter_local_notifications.dart index fcc059825..d5e69b4ca 100644 --- a/flutter_local_notifications/lib/src/platform_flutter_local_notifications.dart +++ b/flutter_local_notifications/lib/src/platform_flutter_local_notifications.dart @@ -52,8 +52,9 @@ class MethodChannelFlutterLocalNotificationsPlugin result != null && result.containsKey('notificationResponse') ? result['notificationResponse'] : null; - return result != null - ? NotificationAppLaunchDetails( + return result == null + ? null + : NotificationAppLaunchDetails( result['notificationLaunchedApp'], notificationResponse: notificationResponse == null ? null @@ -66,9 +67,11 @@ class MethodChannelFlutterLocalNotificationsPlugin payload: notificationResponse.containsKey('payload') ? notificationResponse['payload'] : null, + data: Map.from( + notificationResponse['data'] ?? {}, + ), ), - ) - : null; + ); } @override @@ -113,7 +116,7 @@ class AndroidFlutterLocalNotificationsPlugin AndroidFlutterLocalNotificationsPlugin(); } - DidReceiveNotificationResponseCallback? _ondidReceiveNotificationResponse; + DidReceiveNotificationResponseCallback? _onDidReceiveNotificationResponse; /// Initializes the plugin. /// @@ -137,7 +140,7 @@ class AndroidFlutterLocalNotificationsPlugin DidReceiveBackgroundNotificationResponseCallback? onDidReceiveBackgroundNotificationResponse, }) async { - _ondidReceiveNotificationResponse = onDidReceiveNotificationResponse; + _onDidReceiveNotificationResponse = onDidReceiveNotificationResponse; _channel.setMethodCallHandler(_handleMethod); final Map arguments = initializationSettings.toMap(); @@ -230,7 +233,7 @@ class AndroidFlutterLocalNotificationsPlugin /// a foreground service with a notification id of 0. /// /// Since not all users of this plugin need such a service, it was not - /// added to this plugins Android manifest. Thie means you have to add + /// added to this plugins Android manifest. This means you have to add /// it if you want to use the foreground service functionality. Add the /// foreground service permission to your apps `AndroidManifest.xml` like /// described in the [official Android documentation](https://developer.android.com/guide/components/foreground-services#request-foreground-service-permissions): @@ -257,11 +260,11 @@ class AndroidFlutterLocalNotificationsPlugin /// The notification of the foreground service can be updated by /// simply calling this method multiple times. /// - /// Information on selecting an appropriate `startType` for your app's usecase - /// should be taken from the official Android documentation, check [`Service.onStartCommand`](https://developer.android.com/reference/android/app/Service#onStartCommand(android.content.Intent,%20int,%20int)). + /// Information on selecting an appropriate `startType` for your app's use + /// case should be taken from the official Android documentation, check [`Service.onStartCommand`](https://developer.android.com/reference/android/app/Service#onStartCommand(android.content.Intent,%20int,%20int)). /// The there mentioned constants can be found in [AndroidServiceStartType]. /// - /// The notification for the foreground service will not be dismissable + /// The notification for the foreground service will not be dismissible /// and automatically removed when using [stopForegroundService]. /// /// `foregroundServiceType` is a set of foreground service types to apply to @@ -581,7 +584,7 @@ class AndroidFlutterLocalNotificationsPlugin Future _handleMethod(MethodCall call) async { switch (call.method) { case 'didReceiveNotificationResponse': - _ondidReceiveNotificationResponse?.call( + _onDidReceiveNotificationResponse?.call( NotificationResponse( id: call.arguments['notificationId'], actionId: call.arguments['actionId'], @@ -613,7 +616,7 @@ class IOSFlutterLocalNotificationsPlugin /// /// Call this method on application before using the plugin further. /// - /// Initialisation may also request notification permissions where users will + /// Initialization may also request notification permissions where users will /// see a permissions prompt. This may be fine in cases where it's acceptable /// to do this when the application runs for the first time. However, if your /// application needs to do this at a later point in time, set the @@ -841,7 +844,7 @@ class MacOSFlutterLocalNotificationsPlugin /// Call this method on application before using the plugin further. /// This should only be done once. /// - /// Initialisation may also request notification permissions where users will + /// Initialization may also request notification permissions where users will /// see a permissions prompt. This may be fine in cases where it's acceptable /// to do this when the application runs for the first time. However, if your /// application needs to do this at a later point in time, set the @@ -1035,7 +1038,7 @@ void _evaluateBackgroundNotificationCallback( final CallbackHandle? callback = PluginUtilities.getCallbackHandle( didReceiveBackgroundNotificationResponseCallback); assert(callback != null, ''' - The backgroundHandler needs to be either a static function or a top + The backgroundHandler needs to be either a static function or a top level function to be accessible as a Flutter entry point.'''); final CallbackHandle? dispatcher = diff --git a/flutter_local_notifications/pubspec.yaml b/flutter_local_notifications/pubspec.yaml index 5c365b0b7..f96d9f61c 100644 --- a/flutter_local_notifications/pubspec.yaml +++ b/flutter_local_notifications/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_local_notifications description: A cross platform plugin for displaying and scheduling local - notifications for Flutter applications with the ability to customise for each + notifications for Flutter applications with the ability to customize for each platform. version: 18.0.0 homepage: https://github.com/MaikuB/flutter_local_notifications/tree/master/flutter_local_notifications @@ -11,7 +11,8 @@ dependencies: flutter: sdk: flutter flutter_local_notifications_linux: ^5.0.0 - flutter_local_notifications_platform_interface: ^8.0.0 + flutter_local_notifications_windows: ^1.0.0 + flutter_local_notifications_platform_interface: ^8.1.0 timezone: ^0.9.0 dev_dependencies: @@ -37,7 +38,9 @@ flutter: dartPluginClass: MacOSFlutterLocalNotificationsPlugin linux: default_package: flutter_local_notifications_linux + windows: + default_package: flutter_local_notifications_windows environment: - sdk: ^3.1.0 - flutter: ">=3.13.0" + sdk: ^3.3.0 + flutter: ">=3.19.0" diff --git a/flutter_local_notifications/test/flutter_local_notifications_test.dart b/flutter_local_notifications/test/flutter_local_notifications_test.dart index c9fdfbc84..b47635843 100644 --- a/flutter_local_notifications/test/flutter_local_notifications_test.dart +++ b/flutter_local_notifications/test/flutter_local_notifications_test.dart @@ -1,6 +1,5 @@ import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; diff --git a/flutter_local_notifications_platform_interface/lib/flutter_local_notifications_platform_interface.dart b/flutter_local_notifications_platform_interface/lib/flutter_local_notifications_platform_interface.dart index 5521a30c0..387968afb 100644 --- a/flutter_local_notifications_platform_interface/lib/flutter_local_notifications_platform_interface.dart +++ b/flutter_local_notifications_platform_interface/lib/flutter_local_notifications_platform_interface.dart @@ -87,10 +87,10 @@ abstract class FlutterLocalNotificationsPlatform extends PlatformInterface { /// Returns the list of active notifications shown by the application that /// haven't been dismissed/removed. /// - /// Throws a [PlatformException] with an `unsupported_os_version` error code - /// when the OS version is older than what is supported to have results - /// returned. On platforms that don't support the method at all, - /// it will throw an [UnimplementedError]. + /// Throws a [PlatformException](https://api.flutter.dev/flutter/services/PlatformException-class.html) + /// with an `unsupported_os_version` error code when the OS version is older + /// than what is supported to have results returned. On platforms that don't + /// support the method at all, it will throw an [UnimplementedError]. Future> getActiveNotifications() { throw UnimplementedError( 'getActiveNotifications() has not been implemented'); diff --git a/flutter_local_notifications_platform_interface/lib/src/types.dart b/flutter_local_notifications_platform_interface/lib/src/types.dart index d4b94a25e..e97734f47 100644 --- a/flutter_local_notifications_platform_interface/lib/src/types.dart +++ b/flutter_local_notifications_platform_interface/lib/src/types.dart @@ -93,6 +93,7 @@ class NotificationResponse { this.actionId, this.input, this.payload, + this.data = const {}, }); /// The notification's id. @@ -106,11 +107,17 @@ class NotificationResponse { /// The value of the input field if the notification action had an input /// field. + /// + /// On Windows, this is always null. Instead, [data] holds the values of + /// each input with the input's ID as the key. final String? input; /// The notification's payload. final String? payload; + /// Any other data returned by the platform. + final Map data; + /// The notification response type. final NotificationResponseType notificationResponseType; } diff --git a/flutter_local_notifications_platform_interface/pubspec.yaml b/flutter_local_notifications_platform_interface/pubspec.yaml index e3b2bd3b2..181f73c6e 100644 --- a/flutter_local_notifications_platform_interface/pubspec.yaml +++ b/flutter_local_notifications_platform_interface/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_local_notifications_platform_interface description: A common platform interface for the flutter_local_notifications plugin. -version: 8.0.0 +version: 8.1.0 homepage: https://github.com/MaikuB/flutter_local_notifications/tree/master/flutter_local_notifications_platform_interface environment: @@ -8,8 +8,6 @@ environment: flutter: ">=3.13.0" dependencies: - flutter: - sdk: flutter plugin_platform_interface: ^2.0.0 dev_dependencies: diff --git a/flutter_local_notifications_windows/.gitignore b/flutter_local_notifications_windows/.gitignore new file mode 100644 index 000000000..ac5aa9893 --- /dev/null +++ b/flutter_local_notifications_windows/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ diff --git a/flutter_local_notifications_windows/.metadata b/flutter_local_notifications_windows/.metadata new file mode 100644 index 000000000..fc2d1c605 --- /dev/null +++ b/flutter_local_notifications_windows/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "761747bfc538b5af34aa0d3fac380f1bc331ec49" + channel: "stable" + +project_type: plugin_ffi + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49 + base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49 + - platform: windows + create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49 + base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/flutter_local_notifications_windows/CHANGELOG.md b/flutter_local_notifications_windows/CHANGELOG.md new file mode 100644 index 000000000..89dfc0ddb --- /dev/null +++ b/flutter_local_notifications_windows/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +* Initial release for Windows diff --git a/flutter_local_notifications_windows/LICENSE b/flutter_local_notifications_windows/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/flutter_local_notifications_windows/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/flutter_local_notifications_windows/README.md b/flutter_local_notifications_windows/README.md new file mode 100644 index 000000000..09b1d0c21 --- /dev/null +++ b/flutter_local_notifications_windows/README.md @@ -0,0 +1,48 @@ +# flutter_local_notifications_windows + +The Windows implementation of `package:flutter_local_notifications` as an FFI package that can be run in plain Dart or as a Flutter plugin. See [the docs on FFI](https://dart.dev/interop/c-interop). + +## Limitations + +- Windows does not support repeating notifications, so [`periodicallyShow`](https://pub.dev/documentation/flutter_local_notifications/latest/flutter_local_notifications/FlutterLocalNotificationsPlugin/periodicallyShow.html) and [`periodicallyShowWithDuration`](https://pub.dev/documentation/flutter_local_notifications/latest/flutter_local_notifications/FlutterLocalNotificationsPlugin/periodicallyShowWithDuration.html) will throw `UnsupportedError`s. +- Windows only allows apps with package identity to retrieve previously shown notifications. This means that on an app that was not packaged as an [MSIX](https://learn.microsoft.com/en-us/windows/msix/overview) installer, [`cancel`](https://pub.dev/documentation/flutter_local_notifications/latest/flutter_local_notifications/FlutterLocalNotificationsPlugin/cancel.html) does nothing and [getActiveNotifications](https://pub.dev/documentation/flutter_local_notifications/latest/flutter_local_notifications/FlutterLocalNotificationsPlugin/getActiveNotifications.html) will return an empty list. To package your app as an MSIX, see [`package:msix`](https://pub.dev/packages/msix) and the `msix` section in [the example's `pubspec.yaml`](https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/pubspec.yaml). + +## Project structure + +This template uses the following structure: + +- `src`: Contains the native source code, and a CmakeFile.txt file for building + that source code into a dynamic library. Within this folder, there are three C++ files: + - `ffi_api.h`/`ffi_api.cpp`: A C-compatible header file with the API that will be used by Dart, and the C++ implementation of that API + - `plugin.hpp`/`plugin.cpp`: A C++ class holding handles to the [C++/WinRT](https://learn.microsoft.com/en-us/windows/uwp/cpp-and-winrt-apis/) SDK, along with some Windows-heavy logic. `ffi_api.cpp` implements its features using this class. + - `utils.hpp`/`utils.cpp` handle copying and allocating data from C structs to WinRT classes and vice-versa. Since FFI is done over C-based APIs, C++ types like strings, maps, and vectors need to be translated. + +- `lib`: Contains the Dart code that defines the API of the plugin, and which + calls into the native code using `dart:ffi`. + - The `details` folder holds all the Windows-specific notification configurations such as `WindowsAction`, `WindowsImage`, etc. + - The `ffi` folder holds the generated bindings (see below) and other FFI utilities. + - The `plugin` folder implements `package:flutter_local_notifications_platform_interface` in two ways: a stub for platforms that don't support FFI, and an FFI-based implementation. + +- The `windows` folder contains the build files for building and bundling the native code library with the platform application. + +## Building and bundling native code + +The code in `src` can be built with CMake. A `build.bat` file is included, which has the following code: + +```batch +@echo off +cd build +cmake ../windows +cmake --build . +cd .. +copy build\shared\Debug\flutter_local_notifications_windows.dll . +``` + +This generates a DLL from the native code and copies it to the current directory. This is useful for testing locally without Flutter. When using Flutter, this step is unnecessary as Flutter will build and bundle the assets for you. + +## Binding to native code + +To use the native code, bindings in Dart are needed. +To avoid writing these by hand, they are generated from the header file +`src/ffi_api.h` by `package:ffigen`. +Regenerate the bindings by running `dart run ffigen --config ffigen.yaml`. diff --git a/flutter_local_notifications_windows/bin/crash.dart b/flutter_local_notifications_windows/bin/crash.dart new file mode 100644 index 000000000..7354403cd --- /dev/null +++ b/flutter_local_notifications_windows/bin/crash.dart @@ -0,0 +1,72 @@ +// This file demonstrates how the plugin is _not_ thread safe. +// +// This crash can happen when running `dart test -j 1`, which would otherwise +// fix other concurrency issues with the tests. This crash is not significant +// for users as it depends on having two plugins instantiated at the same time, +// which is not recommended, but I left it here as a demonstration if needed. +// +// The experimental function `enableMultithreading()` can fix the issues +// demonstrated by this file, but when testing with `dart test -j 1`, a crash +// occurs as `XmlDocument doc;`, a seemingly harmless statement. I have not +// been able to deduce the cause, and `enableMultithreading()` does not fix it. +// If we can figure that out, tests can be run with `-j 1` and race conditions +// would be eliminated from the tests. + +// ignore_for_file: avoid_print + +import 'dart:isolate'; + +import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; +import 'package:timezone/standalone.dart'; + +const WindowsInitializationSettings settings = WindowsInitializationSettings( + appName: 'Test app', + appUserModelId: 'com.test.test', + guid: 'a8c22b55-049e-422f-b30f-863694de08c8', +); + +void main() async { + print('Starting tests'); + await Isolate.spawn(bindingsTest, null); + await Isolate.spawn(scheduledTest, null); + + // This is the critical line. Removing this causes crashes in the Windows SDK + // ignore: invalid_use_of_visible_for_testing_member + FlutterLocalNotificationsWindows().enableMultithreading(); + + await Future.delayed(const Duration(seconds: 5)); + print('Done. Scheduled and binding tests should have completed'); +} + +Future scheduledTest(_) async { + print('Starting scheduled test'); + await Future.delayed(const Duration(seconds: 4)); + final FlutterLocalNotificationsWindows plugin = + FlutterLocalNotificationsWindows(); + await plugin.initialize(settings); + await initializeTimeZone(); + final Location location = getLocation('US/Eastern'); + final TZDateTime now = TZDateTime.now(location); + final TZDateTime later = now.add(const Duration(days: 1)); + await plugin.zonedSchedule(300, null, null, later, null); + await plugin.zonedSchedule(301, null, null, later, null); + await plugin.zonedSchedule(302, null, null, later, null); + print('Scheduled test complete'); +} + +Future bindingsTest(_) async { + print('Starting bindings test'); + final Map bindings = { + 'title': 'Bindings title', + 'body': 'Bindings body' + }; + await Future.delayed(const Duration(seconds: 1)); + final FlutterLocalNotificationsWindows plugin = + FlutterLocalNotificationsWindows(); + await plugin.initialize(settings); + await plugin.show(503, '{title}', '{body}'); + await Future.delayed(const Duration(milliseconds: 100)); + await plugin.updateBindings(id: 503, bindings: bindings); + await plugin.updateBindings(id: 503, bindings: bindings); + print('Bindings test complete'); +} diff --git a/flutter_local_notifications_windows/build.bat b/flutter_local_notifications_windows/build.bat new file mode 100644 index 000000000..bf7478293 --- /dev/null +++ b/flutter_local_notifications_windows/build.bat @@ -0,0 +1,6 @@ +@echo off +cd build +cmake ../windows +cmake --build . +cd .. +copy build\shared\Debug\flutter_local_notifications_windows.dll . diff --git a/flutter_local_notifications_windows/dart_test.yaml b/flutter_local_notifications_windows/dart_test.yaml new file mode 100644 index 000000000..2305630e4 --- /dev/null +++ b/flutter_local_notifications_windows/dart_test.yaml @@ -0,0 +1,3 @@ +platforms: [vm] +test_on: windows +retry: 5 # These tests have concurrency issues. See bin/crash.dart diff --git a/flutter_local_notifications_windows/ffigen.yaml b/flutter_local_notifications_windows/ffigen.yaml new file mode 100644 index 000000000..aad4b5e0c --- /dev/null +++ b/flutter_local_notifications_windows/ffigen.yaml @@ -0,0 +1,31 @@ +# Run with `dart run ffigen --config ffigen.yaml`. +name: NotificationsPluginBindings +description: | + Bindings for `src/ffi_api.h`. + + Regenerate bindings with `dart run ffigen --config ffigen.yaml`. +output: 'lib/src/ffi/bindings.dart' + +silence-enum-warning: true + +headers: + entry-points: + - 'src/ffi_api.h' + include-directives: + - 'src/ffi_api.h' + +preamble: | + // ignore_for_file: always_specify_types + // ignore_for_file: camel_case_types + // ignore_for_file: non_constant_identifier_names + +comments: + style: any + length: full + +type-map: + native-types: + 'char': # Converts `char` to `Utf8` instead of `Char` + 'lib': 'pkg_ffi' + 'c-type': 'Utf8' + 'dart-type': 'Utf8' diff --git a/flutter_local_notifications_windows/flutter_local_notifications_windows.dll b/flutter_local_notifications_windows/flutter_local_notifications_windows.dll new file mode 100644 index 000000000..5f9a8629a Binary files /dev/null and b/flutter_local_notifications_windows/flutter_local_notifications_windows.dll differ diff --git a/flutter_local_notifications_windows/lib/flutter_local_notifications_windows.dart b/flutter_local_notifications_windows/lib/flutter_local_notifications_windows.dart new file mode 100644 index 000000000..06a875848 --- /dev/null +++ b/flutter_local_notifications_windows/lib/flutter_local_notifications_windows.dart @@ -0,0 +1,2 @@ +export 'src/details.dart'; +export 'src/plugin/stub.dart' if (dart.library.ffi) 'src/plugin/ffi.dart'; diff --git a/flutter_local_notifications_windows/lib/src/details.dart b/flutter_local_notifications_windows/lib/src/details.dart new file mode 100644 index 000000000..a69027094 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details.dart @@ -0,0 +1,21 @@ +export 'details/initialization_settings.dart'; +export 'details/notification_action.dart'; +export 'details/notification_audio.dart'; +export 'details/notification_details.dart'; +export 'details/notification_header.dart'; +export 'details/notification_input.dart'; +export 'details/notification_parts.dart'; +export 'details/notification_progress.dart'; +export 'details/notification_row.dart'; + +/// The result of updating a notification. +enum NotificationUpdateResult { + /// The update was successful. + success, + + /// There was an unexpected error updating the notification. + error, + + /// No notification with the provided ID could be found. + notFound, +} diff --git a/flutter_local_notifications_windows/lib/src/details/initialization_settings.dart b/flutter_local_notifications_windows/lib/src/details/initialization_settings.dart new file mode 100644 index 000000000..507d85929 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/initialization_settings.dart @@ -0,0 +1,26 @@ +/// Plugin initialization settings for Windows. +class WindowsInitializationSettings { + /// Creates a new settings object for initializing this plugin on Windows. + const WindowsInitializationSettings({ + required this.appName, + required this.appUserModelId, + required this.guid, + this.iconPath, + }); + + /// The name of the app that should be shown in the notification toast. + final String appName; + + /// The unique app user model ID that identifies the app, + /// in the form of CompanyName.ProductName.SubProduct.VersionInformation. + /// + /// See https://docs.microsoft.com/en-us/windows/win32/shell/appids + /// for more information. + final String appUserModelId; + + /// The GUID that identifies the notification activation callback. + final String guid; + + /// The path to the icon of the notification. + final String? iconPath; +} diff --git a/flutter_local_notifications_windows/lib/src/details/notification_action.dart b/flutter_local_notifications_windows/lib/src/details/notification_action.dart new file mode 100644 index 000000000..946785470 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/notification_action.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +// NOTE: All enum values in this file have Windows RT-specific names. +// If you change their Dart names, be sure to override [Enum.name]. + +/// Decides how the [WindowsAction] will launch the app. +enum WindowsActivationType { + /// The application will launch in the foreground (the default). + foreground, + + /// Any application can be launched using its protocol. + protocol, +} + +/// Decides how a [WindowsAction] will react to being pressed. +enum WindowsNotificationBehavior { + /// The notification will be dismissed. + dismiss('default'), + + /// The notification will remain on screen and show a loading status. + pendingUpdate('pendingUpdate'); + + const WindowsNotificationBehavior(this.name); + + /// The Windows API name for this choice. + final String name; +} + +/// Decides how a [WindowsAction] will be styled. +enum WindowsButtonStyle { + /// A green button. + success('Success'), + + /// A red button. + critical('Critical'); + + const WindowsButtonStyle(this.name); + + /// The Windows API name for this choice. + final String name; +} + +/// Decides how a [WindowsAction] is placed on a notification. +enum WindowsActionPlacement { + /// Instead of a separate button, the action is part of the context menu. + contextMenu, +} + +/// A button in a Windows notification. +/// +/// See https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-action#attributes +class WindowsAction { + /// Constructs a Windows notification button from parameters. + const WindowsAction({ + required this.content, + required this.arguments, + this.activationType = WindowsActivationType.foreground, + this.activationBehavior = WindowsNotificationBehavior.dismiss, + this.placement, + this.image, + this.inputId, + this.buttonStyle, + this.tooltip, + }); + + /// The body text of the button. + final String content; + + /// An app-defined string that will be passed back if the button is pressed. + final String arguments; + + /// How the application should open if the button is pressed. + /// + /// The default value is [WindowsActivationType.foreground]. + final WindowsActivationType activationType; + + /// How the notification should react when the button is pressed. + /// + /// The default value is [WindowsNotificationBehavior.dismiss]. + final WindowsNotificationBehavior activationBehavior; + + /// How the button should be placed on the notification. + /// + /// Null indicates a regular button. + final WindowsActionPlacement? placement; + + /// An image to show on the button. + /// + /// Images must be white with a transparent background, and should be + /// 16x16 pixels with no padding. If you provide an image for one button, + /// you should provide images for all your buttons. + final File? image; + + /// The ID of an input box. + /// + /// If provided, this button will be placed next to the specified input. + final String? inputId; + + /// The style of the button. Null indicates a plain button. + final WindowsButtonStyle? buttonStyle; + + /// The tooltip, useful if [content] is empty. + final String? tooltip; +} diff --git a/flutter_local_notifications_windows/lib/src/details/notification_audio.dart b/flutter_local_notifications_windows/lib/src/details/notification_audio.dart new file mode 100644 index 000000000..cd2c2e8a9 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/notification_audio.dart @@ -0,0 +1,148 @@ +extension on Uri { + String get filename => pathSegments.last; + String get extension => pathSegments.last.split('.').last; +} + +/// A preset sound for a Windows notification. +enum WindowsNotificationSound { + /// The default sound. + defaultSound('ms-winsoundevent:Notification.Default'), + + /// The IM sound. + im('ms-winsoundevent:Notification.IM'), + + /// The Mail sound. + mail('ms-winsoundevent:Notification.Mail'), + + /// The Reminder sound. + reminder('ms-winsoundevent:Notification.Reminder'), + + /// The SMS sound. + sms('ms-winsoundevent:Notification.SMS'), + + /// Alarm sound 1. + alarm1('ms-winsoundevent:Notification.Looping.Alarm1'), + + /// Alarm sound 2. + alarm2('ms-winsoundevent:Notification.Looping.Alarm2'), + + /// Alarm sound 3. + alarm3('ms-winsoundevent:Notification.Looping.Alarm3'), + + /// Alarm sound 4. + alarm4('ms-winsoundevent:Notification.Looping.Alarm4'), + + /// Alarm sound 5. + alarm5('ms-winsoundevent:Notification.Looping.Alarm5'), + + /// Alarm sound 6. + alarm6('ms-winsoundevent:Notification.Looping.Alarm6'), + + /// Alarm sound 7. + alarm7('ms-winsoundevent:Notification.Looping.Alarm7'), + + /// Alarm sound 8. + alarm8('ms-winsoundevent:Notification.Looping.Alarm8'), + + /// Alarm sound 9. + alarm9('ms-winsoundevent:Notification.Looping.Alarm9'), + + /// Alarm sound 10. + alarm10('ms-winsoundevent:Notification.Looping.Alarm10'), + + /// Call sound 1. + call1('ms-winsoundevent:Notification.Looping.Call1'), + + /// Call sound 2. + call2('ms-winsoundevent:Notification.Looping.Call2'), + + /// Call sound 3. + call3('ms-winsoundevent:Notification.Looping.Call3'), + + /// Call sound 4. + call4('ms-winsoundevent:Notification.Looping.Call4'), + + /// Call sound 5. + call5('ms-winsoundevent:Notification.Looping.Call5'), + + /// Call sound 6. + call6('ms-winsoundevent:Notification.Looping.Call6'), + + /// Call sound 7. + call7('ms-winsoundevent:Notification.Looping.Call7'), + + /// Call sound 8. + call8('ms-winsoundevent:Notification.Looping.Call8'), + + /// Call sound 9. + call9('ms-winsoundevent:Notification.Looping.Call9'), + + /// Call sound 10. + call10('ms-winsoundevent:Notification.Looping.Call10'); + + const WindowsNotificationSound(this.name); + + /// The Windows API name for this sound. + final String name; +} + +/// Specifies custom audio to play during a notification. +class WindowsNotificationAudio { + /// No sound will play during this notification. + WindowsNotificationAudio.silent() + : source = WindowsNotificationSound.defaultSound.name, + shouldLoop = false, + isSilent = true; + + /// Audio from a Windows preset. See [WindowsNotificationSound] for options. + WindowsNotificationAudio.preset({ + required WindowsNotificationSound sound, + this.shouldLoop = false, + }) : isSilent = false, + source = sound.name; + + /// Audio from a file. See [allowedSchemes] and [allowedExtensions]. + WindowsNotificationAudio.fromFile({ + required Uri file, + this.shouldLoop = false, + }) : isSilent = false, + source = file.toFilePath() { + if (!allowedSchemes.contains(file.scheme)) { + throw ArgumentError.value( + file.toString(), + 'WindowsNotificationAudio.file', + 'URI scheme must be one of the following schemes: $allowedSchemes', + ); + } + if (!file.filename.contains('.') || + !allowedExtensions.contains(file.extension)) { + throw ArgumentError.value( + file.toString(), + 'WindowsNotificationAudio.file', + 'File extension must be one of the following: $allowedExtensions', + ); + } + } + + /// Allowed Uri schemes for [WindowsNotificationAudio.fromFile]. + static const Set allowedSchemes = {'ms-appx', 'ms-resource'}; + + /// Allowed file extensions for [WindowsNotificationAudio.fromFile]. + static const Set allowedExtensions = { + 'aac', + 'flac', + 'm4a', + 'mp3', + 'wav', + 'wma' + }; + + /// Whether this audio should loop. + final bool shouldLoop; + + /// Whether this notification should be silent. + final bool isSilent; + + /// The source of the audio. + final String source; +} diff --git a/flutter_local_notifications_windows/lib/src/details/notification_details.dart b/flutter_local_notifications_windows/lib/src/details/notification_details.dart new file mode 100644 index 000000000..255224d5d --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/notification_details.dart @@ -0,0 +1,102 @@ +import 'notification_action.dart'; +import 'notification_audio.dart'; +import 'notification_header.dart'; +import 'notification_input.dart'; +import 'notification_parts.dart'; +import 'notification_progress.dart'; +import 'notification_row.dart'; + +export 'notification_parts.dart'; + +/// The duration for a Windows notification. +enum WindowsNotificationDuration { + /// The notification will stay for a long time. + long, + + /// The notification will stay for a short time. + short, +} + +/// The scenario a notification is being used for. +enum WindowsNotificationScenario { + /// Reminders are expanded and remain until manually dismissed. + /// + /// This will be ignored unless the notification also has at least one + /// [WindowsAction] that activates a background task. + reminder, + + /// Alarms are expanded and remain until manually dismissed. + /// + /// By default, alarm notifications loop the standard "alarm" sound. + alarm, + + /// Calls are expanded and show in a special format. + /// + /// By default, call notifications loop the standard "call" sound. + incomingCall, + + /// Urgent notifications can break through Do Not Disturb settings. + urgent, +} + +/// Contains notification details specific to Windows. +/// +/// See: https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts +class WindowsNotificationDetails { + /// Creates a Windows notification from the given options. + const WindowsNotificationDetails({ + this.actions = const [], + this.inputs = const [], + this.images = const [], + this.rows = const [], + this.progressBars = const [], + this.bindings = const {}, + this.header, + this.audio, + this.duration, + this.scenario, + this.timestamp, + this.subtitle, + }); + + /// A list of at most five action buttons. + final List actions; + + /// A list of at most five input elements. + final List inputs; + + /// A custom audio to play during this notification. + final WindowsNotificationAudio? audio; + + /// The duration for this notification. + final WindowsNotificationDuration? duration; + + /// The scenario for this notification. Sets some defaults based on the value. + final WindowsNotificationScenario? scenario; + + /// The header for this group of notifications. + final WindowsHeader? header; + + /// Overrides the timestamp to show on the notification. + final DateTime? timestamp; + + /// A third line to show under the notification body. + final String? subtitle; + + /// A list of images to show. + final List images; + + /// A list of rows to show. + final List rows; + + /// A list of progress bars to show. + final List progressBars; + + /// Custom bindings in the notification. + /// + /// Text elements can contains "bindings", which are entered as + /// `{bindingName}` directly into the string values. You can then update them + /// while or after the notification is launched by using the binding name as + /// the key here, and the value as any string you want. + final Map bindings; +} diff --git a/flutter_local_notifications_windows/lib/src/details/notification_header.dart b/flutter_local_notifications_windows/lib/src/details/notification_header.dart new file mode 100644 index 000000000..a5fbd14f8 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/notification_header.dart @@ -0,0 +1,31 @@ +/// Decides how the application will open when the header is pressed. +enum WindowsHeaderActivation { + /// Opens the app in the foreground. + foreground, + + /// Opens any app using a custom protocol. + protocol, +} + +/// A header that groups multiple Windows notifications. +class WindowsHeader { + /// Creates a Windows header. + const WindowsHeader({ + required this.id, + required this.title, + required this.arguments, + this.activation, + }); + + /// A unique ID for this header. + final String id; + + /// The title of the header. + final String title; + + /// An application-defined payload that will be passed back when pressed. + final String arguments; + + /// Specifies how the application will open. + final WindowsHeaderActivation? activation; +} diff --git a/flutter_local_notifications_windows/lib/src/details/notification_input.dart b/flutter_local_notifications_windows/lib/src/details/notification_input.dart new file mode 100644 index 000000000..f13291efb --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/notification_input.dart @@ -0,0 +1,74 @@ +/// The type of a [WindowsInput]. +enum WindowsInputType { + /// A text input. + text, + + /// A multiple choice input. + selection, +} + +/// A text or multiple choice input element in a Windows notification. +sealed class WindowsInput { + /// Creates an input field in a notification. + const WindowsInput({ + required this.id, + required this.type, + this.title, + }); + + /// A unique ID for this input. + /// + /// Can be used by buttons to be placed next to this input. + final String id; + + /// The type of this input. + final WindowsInputType type; + + /// The title of this input. + final String? title; +} + +/// A text input. +class WindowsTextInput extends WindowsInput { + /// Creates an input field in a notification. + const WindowsTextInput({ + required super.id, + this.placeHolderContent, + super.title, + }) : super(type: WindowsInputType.text); + + /// A placeholder shown before the user enters input, like a hint text. + final String? placeHolderContent; +} + +/// A multiple choice input. +class WindowsSelectionInput extends WindowsInput { + /// Creates a selection input. + const WindowsSelectionInput({ + required super.id, + required this.items, + this.defaultItem, + super.title, + }) : super(type: WindowsInputType.selection); + + /// The items that can be selected. + final List items; + + /// The default item that is selected. + final String? defaultItem; +} + +/// An option that can be selected by a [WindowsSelectionInput]. +class WindowsSelection { + /// Creates a selectable choice. + const WindowsSelection({ + required this.id, + required this.content, + }); + + /// A unique ID for this item. + final String id; + + /// The content of this item in the UI. + final String content; +} diff --git a/flutter_local_notifications_windows/lib/src/details/notification_parts.dart b/flutter_local_notifications_windows/lib/src/details/notification_parts.dart new file mode 100644 index 000000000..43dc29d69 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/notification_parts.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +/// A text or image element in a Windows notification. +/// +/// Note: This should not be used for anything else as notification +/// groups can only contain text and images. +// This class needs to be abstract so [WindowsNotificationText] and +// [WindowsImage] can extend it. Specifically, this class is a marker +// type for classes that are valid as part of a [WindowsColumn]. +// ignore: one_member_abstracts +sealed class WindowsNotificationPart { + /// A const constructor. + const WindowsNotificationPart(); +} + +/// Where a Windows notification image can be placed. +enum WindowsImagePlacement { + /// The image replaces the app logo. + appLogoOverride, + + /// The image is shown on top of the notification body. + hero, +} + +/// How a Windows notification image can be cropped. +enum WindowsImageCrop { + /// The image is cropped into a circle. + circle, +} + +/// An image in a Windows notification. +class WindowsImage extends WindowsNotificationPart { + /// Creates a Windows notification image. + const WindowsImage.file( + this.file, { + required this.altText, + this.addQueryParams = false, + this.placement, + this.crop, + }); + + /// Whether Windows should add URL query parameters when fetching the image. + final bool addQueryParams; + + /// A description of the image to be used by assistive technology. + final String altText; + + /// The source of the image. + final File file; + + /// Where this image will be placed. Null indicates below the notification. + final WindowsImagePlacement? placement; + + /// How the image will be cropped. Null indicates uncropped. + final WindowsImageCrop? crop; +} + +/// Where text can be placed in a Windows notification. +enum WindowsTextPlacement { + /// Shown at the bottom of the notification body in smaller text. + attribution, +} + +/// Text in a Windows notification. +/// +/// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-text +class WindowsNotificationText extends WindowsNotificationPart { + /// Creates text for a Windows notification. + const WindowsNotificationText({ + required this.text, + this.centerIfCall = false, + this.isCaption = false, + this.placement, + this.languageCode, + }); + + /// The text being displayed. + final String text; + + /// Whether to center this text. Only relevant if in an incoming call. + final bool centerIfCall; + + /// Whether the text should be smaller like a caption. + final bool isCaption; + + /// The placement of this text. + /// + /// The default placement (null) is in the main body of the notification. + final WindowsTextPlacement? placement; + + /// The language of this text. + final String? languageCode; +} diff --git a/flutter_local_notifications_windows/lib/src/details/notification_progress.dart b/flutter_local_notifications_windows/lib/src/details/notification_progress.dart new file mode 100644 index 000000000..f7847a422 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/notification_progress.dart @@ -0,0 +1,35 @@ +import '../../flutter_local_notifications_windows.dart'; + +/// A progress bar in a Windows notification. +/// +/// To update the progress after the notification has been shown, +/// use [FlutterLocalNotificationsWindows.updateProgressBar]. +class WindowsProgressBar { + /// Creates a progress bar for a Windows notification. + WindowsProgressBar({ + required this.id, + required this.status, + required this.value, + this.title, + this.label, + }); + + /// A unique ID for this progress bar. + final String id; + + /// An optional title. + final String? title; + + /// Describes what's happening, like `Downloading...` or `Installing...` + final String status; + + /// The value of the progress, from 0.0 to 1.0. + /// + /// Setting this to null indicates a indeterminate progress bar. + double? value; + + /// Overrides the default reading as a percent with a different text. + /// + /// Useful for indicating discrete progress, like `3/10` instead of `30%`. + String? label; +} diff --git a/flutter_local_notifications_windows/lib/src/details/notification_row.dart b/flutter_local_notifications_windows/lib/src/details/notification_row.dart new file mode 100644 index 000000000..8e3f6a9f6 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/notification_row.dart @@ -0,0 +1,21 @@ +import 'notification_parts.dart'; + +/// A group of notification content that must be displayed as a whole row. +/// +/// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-group +class WindowsRow { + /// Makes a group of multiple columns. + const WindowsRow(this.columns); + + /// The different columns being grouped together. + final List columns; +} + +/// A vertical column of text and images in a Windows notification. +class WindowsColumn { + /// A const constructor. + const WindowsColumn(this.parts); + + /// A list of text or images in this column. + final List parts; +} diff --git a/flutter_local_notifications_windows/lib/src/details/notification_to_xml.dart b/flutter_local_notifications_windows/lib/src/details/notification_to_xml.dart new file mode 100644 index 000000000..67184c1b1 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/notification_to_xml.dart @@ -0,0 +1,47 @@ +import 'package:xml/xml.dart'; + +import '../../flutter_local_notifications_windows.dart'; +import 'xml/details.dart'; + +export 'xml/progress.dart'; + +/// Converts a notification with [WindowsNotificationDetails] into XML. +/// +/// For more details, refer to the [Toast Notification XML schema](https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root). +String notificationToXml({ + String? title, + String? body, + String? payload, + WindowsNotificationDetails? details, +}) { + final XmlBuilder builder = XmlBuilder(); + builder.element( + 'toast', + attributes: { + ...details?.attributes ?? {}, + if (payload != null) 'launch': payload, + if (details?.scenario == null) 'useButtonStyle': 'true', + }, + nest: () { + builder.element( + 'visual', + nest: () { + builder.element( + 'binding', + attributes: {'template': 'ToastGeneric'}, + nest: () { + builder + ..element('text', nest: title) + ..element('text', nest: body); + details?.generateBinding(builder); + }, + ); + }, + ); + details?.buildXml(builder); + }, + ); + return builder + .buildDocument() + .toXmlString(pretty: true, indentAttribute: (_) => true); +} diff --git a/flutter_local_notifications_windows/lib/src/details/xml/action.dart b/flutter_local_notifications_windows/lib/src/details/xml/action.dart new file mode 100644 index 000000000..67a39d024 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/xml/action.dart @@ -0,0 +1,34 @@ +import 'package:xml/xml.dart'; +import '../notification_action.dart'; + +/// Converts a [WindowsAction] to XML +extension ActionToXml on WindowsAction { + /// Serializes this notification action as Windows-compatible XML. + /// + /// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-action#syntax + void buildXml(XmlBuilder builder) { + if (image != null && !image!.isAbsolute) { + throw ArgumentError.value( + image!.path, + 'WindowsImage.file', + 'File path must be absolute', + ); + } + builder.element( + 'action', + attributes: { + 'content': content, + 'arguments': arguments, + 'activationType': activationType.name, + 'afterActivationBehavior': activationBehavior.name, + if (placement != null) 'placement': placement!.name, + if (image != null) + 'imageUri': + Uri.file(image!.absolute.path, windows: true).toFilePath(), + if (inputId != null) 'hint-inputId': inputId!, + if (buttonStyle != null) 'hint-buttonStyle': buttonStyle!.name, + if (tooltip != null) 'hint-toolTip': tooltip!, + }, + ); + } +} diff --git a/flutter_local_notifications_windows/lib/src/details/xml/audio.dart b/flutter_local_notifications_windows/lib/src/details/xml/audio.dart new file mode 100644 index 000000000..586a53f0d --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/xml/audio.dart @@ -0,0 +1,17 @@ +import 'package:xml/xml.dart'; +import '../notification_audio.dart'; + +/// Converts a [WindowsNotificationAudio] to XML +extension AudioToXml on WindowsNotificationAudio { + /// Serializes this audio to Windows-compatible XML. + /// + /// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-audio + void buildXml(XmlBuilder builder) => builder.element( + 'audio', + attributes: { + 'src': source, + 'silent': isSilent.toString(), + 'loop': shouldLoop.toString(), + }, + ); +} diff --git a/flutter_local_notifications_windows/lib/src/details/xml/details.dart b/flutter_local_notifications_windows/lib/src/details/xml/details.dart new file mode 100644 index 000000000..98ba8d6a7 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/xml/details.dart @@ -0,0 +1,91 @@ +import 'package:xml/xml.dart'; + +import '../notification_action.dart'; +import '../notification_details.dart'; +import '../notification_input.dart'; +import '../notification_progress.dart'; +import '../notification_row.dart'; + +import 'action.dart'; +import 'audio.dart'; +import 'header.dart'; +import 'image.dart'; +import 'input.dart'; +import 'progress.dart'; +import 'row.dart'; + +extension on DateTime { + String toIso8601StringTz() { + // Get offset + final Duration offset = timeZoneOffset; + final String sign = offset.isNegative ? '-' : '+'; + final String hours = offset.inHours.abs().toString().padLeft(2, '0'); + final String minutes = + offset.inMinutes.abs().remainder(60).toString().padLeft(2, '0'); + final String offsetString = '$sign$hours:$minutes'; + // Get first part of properly formatted ISO 8601 date + final String formattedDate = toIso8601String().split('.').first; + return '$formattedDate$offsetString'; + } +} + +/// Converts a [WindowsNotificationDetails] to XML +extension DetailsToXml on WindowsNotificationDetails { + /// Builds all relevant XML parts under the root `` element. + void buildXml(XmlBuilder builder) { + if (actions.length > 5) { + throw ArgumentError( + 'WindowsNotificationDetails can only have up to 5 actions', + ); + } + if (inputs.length > 5) { + throw ArgumentError( + 'WindowsNotificationDetails can only have up to 5 inputs', + ); + } + builder.element( + 'actions', + nest: () { + for (final WindowsInput input in inputs) { + switch (input) { + case WindowsTextInput(): + input.buildXml(builder); + case WindowsSelectionInput(): + input.buildXml(builder); + } + } + for (final WindowsAction action in actions) { + action.buildXml(builder); + } + }, + ); + audio?.buildXml(builder); + header?.buildXml(builder); + } + + /// Generates the `` element of the notification. + /// + /// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-binding + void generateBinding(XmlBuilder builder) { + if (subtitle != null) { + builder.element('text', nest: subtitle); + } + for (final WindowsImage image in images) { + image.buildXml(builder); + } + for (final WindowsRow row in rows) { + row.buildXml(builder); + } + for (final WindowsProgressBar progressBar in progressBars) { + progressBar.buildXml(builder); + } + } + + /// XML attributes for the toast notification as a whole. + Map get attributes => { + if (duration != null) 'duration': duration!.name, + if (timestamp != null) + 'displayTimestamp': timestamp!.toIso8601StringTz(), + if (scenario != null) 'scenario': scenario!.name, + }; +} diff --git a/flutter_local_notifications_windows/lib/src/details/xml/header.dart b/flutter_local_notifications_windows/lib/src/details/xml/header.dart new file mode 100644 index 000000000..b7de790d4 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/xml/header.dart @@ -0,0 +1,19 @@ +import 'package:xml/xml.dart'; + +import '../notification_header.dart'; + +/// Converts a [WindowsHeader] to XML +extension HeaderToXml on WindowsHeader { + /// Serializes this header to XML. + /// + /// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-header + void buildXml(XmlBuilder builder) => builder.element( + 'header', + attributes: { + 'id': id, + 'title': title, + 'arguments': arguments, + if (activation != null) 'activationType': activation!.name, + }, + ); +} diff --git a/flutter_local_notifications_windows/lib/src/details/xml/image.dart b/flutter_local_notifications_windows/lib/src/details/xml/image.dart new file mode 100644 index 000000000..e652f3db5 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/xml/image.dart @@ -0,0 +1,29 @@ +import 'package:xml/xml.dart'; + +import '../notification_parts.dart'; + +/// Converts a [WindowsImage] to XML +extension ImageToXml on WindowsImage { + /// Serializes this image to Windows-compatible XML. + /// + /// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-image + void buildXml(XmlBuilder builder) { + if (!file.isAbsolute) { + throw ArgumentError.value( + file.path, + 'WindowsImage.file', + 'File path must be absolute', + ); + } + builder.element( + 'image', + attributes: { + 'src': Uri.file(file.absolute.path, windows: true).toFilePath(), + 'alt': altText, + 'addImageQuery': addQueryParams.toString(), + if (placement != null) 'placement': placement!.name, + if (crop != null) 'hint-crop': crop!.name, + }, + ); + } +} diff --git a/flutter_local_notifications_windows/lib/src/details/xml/input.dart b/flutter_local_notifications_windows/lib/src/details/xml/input.dart new file mode 100644 index 000000000..407421a5a --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/xml/input.dart @@ -0,0 +1,55 @@ +import 'package:xml/xml.dart'; + +import '../notification_input.dart'; + +/// Converts a [WindowsTextInput] to XML +extension TextInputToXml on WindowsTextInput { + /// Serializes this input to Windows-compatible XML. + /// + /// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-input + void buildXml(XmlBuilder builder) => builder.element( + 'input', + attributes: { + 'id': id, + 'type': type.name, + if (title != null) 'title': title!, + if (placeHolderContent != null) + 'placeHolderContent': placeHolderContent!, + }, + ); +} + +/// Converts a [WindowsSelectionInput] to XML +extension SelectionInputToXml on WindowsSelectionInput { + /// Serializes this input to Windows-compatible XML. + /// + /// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-input + void buildXml(XmlBuilder builder) => builder.element( + 'input', + attributes: { + 'id': id, + 'type': type.name, + if (title != null) 'title': title!, + if (defaultItem != null) 'defaultInput': defaultItem!, + }, + nest: () { + for (final WindowsSelection item in items) { + item.buildXml(builder); + } + }, + ); +} + +/// Converts a [WindowsSelection] to XML +extension SelectionToXml on WindowsSelection { + /// Serializes this selection to Windows-compatible XML. + /// + /// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-selection + void buildXml(XmlBuilder builder) => builder.element( + 'selection', + attributes: { + 'id': id, + 'content': content, + }, + ); +} diff --git a/flutter_local_notifications_windows/lib/src/details/xml/progress.dart b/flutter_local_notifications_windows/lib/src/details/xml/progress.dart new file mode 100644 index 000000000..ee8ef441b --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/xml/progress.dart @@ -0,0 +1,30 @@ +import 'package:xml/xml.dart'; + +import '../notification_progress.dart'; + +/// Converts a [WindowsProgressBar] to XML +extension ProgressBarToXml on WindowsProgressBar { + /// Serializes this progress bar to Windows-compatible XML. + /// + /// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-progress + void buildXml(XmlBuilder builder) => builder.element( + 'progress', + attributes: { + 'status': status, + 'value': '{$id-progressValue}', + if (title != null) 'title': title!, + if (label != null) 'valueStringOverride': '{$id-progressString}', + }, + ); + + /// The data bindings for this progress bar. + /// + /// To support dynamic updates, [buildXml] will inject placeholder strings + /// called data bindings instead of actual values. This can then be updated + /// dynamically later by calling + /// [FlutterLocalNotificationsWindows.updateProgressBar]. + Map get data => { + '$id-progressValue': value?.toString() ?? 'indeterminate', + if (label != null) '$id-progressString': label!, + }; +} diff --git a/flutter_local_notifications_windows/lib/src/details/xml/row.dart b/flutter_local_notifications_windows/lib/src/details/xml/row.dart new file mode 100644 index 000000000..cada3be4a --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/xml/row.dart @@ -0,0 +1,35 @@ +import 'package:xml/xml.dart'; + +import '../notification_parts.dart'; +import '../notification_row.dart'; + +import 'image.dart'; +import 'text.dart'; + +/// Converts a [WindowsRow] to XML +extension RowToXml on WindowsRow { + /// Serializes this group to XML. + /// + /// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-group + void buildXml(XmlBuilder builder) => builder.element( + 'group', + nest: () { + for (final WindowsColumn column in columns) { + builder.element( + 'subgroup', + attributes: {'hint-weight': '1'}, + nest: () { + for (final WindowsNotificationPart part in column.parts) { + switch (part) { + case WindowsImage(): + part.buildXml(builder); + case WindowsNotificationText(): + part.buildXml(builder); + } + } + }, + ); + } + }, + ); +} diff --git a/flutter_local_notifications_windows/lib/src/details/xml/text.dart b/flutter_local_notifications_windows/lib/src/details/xml/text.dart new file mode 100644 index 000000000..419e66422 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/details/xml/text.dart @@ -0,0 +1,21 @@ +import 'package:xml/xml.dart'; + +import '../notification_parts.dart'; + +/// Converts a [WindowsNotificationText] to XML +extension TextToXml on WindowsNotificationText { + /// Serializes this text to Windows-compatible XML. + /// + /// See: https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-text + void buildXml(XmlBuilder builder) => builder.element( + 'text', + attributes: { + if (languageCode != null) 'lang': languageCode!, + if (placement != null) 'placement': placement!.name, + 'hint-callScenarioCenterAlign': centerIfCall.toString(), + 'hint-align': 'center', + if (isCaption) 'hint-style': 'captionsubtle', + }, + nest: text, + ); +} diff --git a/flutter_local_notifications_windows/lib/src/ffi/bindings.dart b/flutter_local_notifications_windows/lib/src/ffi/bindings.dart new file mode 100644 index 000000000..90c788061 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/ffi/bindings.dart @@ -0,0 +1,367 @@ +// ignore_for_file: always_specify_types +// ignore_for_file: camel_case_types +// ignore_for_file: non_constant_identifier_names + +// AUTO GENERATED FILE, DO NOT EDIT. +// +// Generated by `package:ffigen`. +// ignore_for_file: type=lint +import 'dart:ffi' as ffi; +import 'package:ffi/ffi.dart' as pkg_ffi; + +/// Bindings for `src/ffi_api.h`. +/// +/// Regenerate bindings with `dart run ffigen --config ffigen.yaml`. +/// +class NotificationsPluginBindings { + /// Holds the symbol lookup function. + final ffi.Pointer Function(String symbolName) + _lookup; + + /// The symbols are looked up in [dynamicLibrary]. + NotificationsPluginBindings(ffi.DynamicLibrary dynamicLibrary) + : _lookup = dynamicLibrary.lookup; + + /// The symbols are looked up with [lookup]. + NotificationsPluginBindings.fromLookup( + ffi.Pointer Function(String symbolName) + lookup) + : _lookup = lookup; + + /// Allocates a new plugin that must be released with [disposePlugin]. + ffi.Pointer createPlugin() { + return _createPlugin(); + } + + late final _createPluginPtr = + _lookup Function()>>( + 'createPlugin'); + late final _createPlugin = + _createPluginPtr.asFunction Function()>(); + + /// Releases the plugin and any resources it was holding onto. + void disposePlugin( + ffi.Pointer ptr, + ) { + return _disposePlugin( + ptr, + ); + } + + late final _disposePluginPtr = + _lookup)>>( + 'disposePlugin'); + late final _disposePlugin = + _disposePluginPtr.asFunction)>(); + + /// Initializes the plugin and registers the callback to be run when a notification is pressed. + bool init( + ffi.Pointer plugin, + ffi.Pointer appName, + ffi.Pointer aumId, + ffi.Pointer guid, + ffi.Pointer iconPath, + NativeNotificationCallback callback, + ) { + return _init( + plugin, + appName, + aumId, + guid, + iconPath, + callback, + ); + } + + late final _initPtr = _lookup< + ffi.NativeFunction< + ffi.Bool Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + NativeNotificationCallback)>>('init'); + late final _init = _initPtr.asFunction< + bool Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + NativeNotificationCallback)>(); + + /// Shows the XML as a notification with the given ID. See [updateNotification] for details on bindings. + bool showNotification( + ffi.Pointer plugin, + int id, + ffi.Pointer xml, + NativeStringMap bindings, + ) { + return _showNotification( + plugin, + id, + xml, + bindings, + ); + } + + late final _showNotificationPtr = _lookup< + ffi.NativeFunction< + ffi.Bool Function(ffi.Pointer, ffi.Int, + ffi.Pointer, NativeStringMap)>>('showNotification'); + late final _showNotification = _showNotificationPtr.asFunction< + bool Function(ffi.Pointer, int, ffi.Pointer, + NativeStringMap)>(); + + /// Schedules the notification to be shown at the given time (as a [time_t]). + bool scheduleNotification( + ffi.Pointer plugin, + int id, + ffi.Pointer xml, + int time, + ) { + return _scheduleNotification( + plugin, + id, + xml, + time, + ); + } + + late final _scheduleNotificationPtr = _lookup< + ffi.NativeFunction< + ffi.Bool Function(ffi.Pointer, ffi.Int, + ffi.Pointer, ffi.Int)>>('scheduleNotification'); + late final _scheduleNotification = _scheduleNotificationPtr.asFunction< + bool Function( + ffi.Pointer, int, ffi.Pointer, int)>(); + + /// Updates a notification with the provided bindings after it's been shown. + /// + /// String values in the `` element of the XML can be placeholders instead of values, + /// for example, `{name}` and then call this function with a map with a `name` key, + /// and any string value, and the notification will be updated with that value where `name` was. + NativeUpdateResult updateNotification( + ffi.Pointer plugin, + int id, + NativeStringMap bindings, + ) { + return NativeUpdateResult.fromValue(_updateNotification( + plugin, + id, + bindings, + )); + } + + late final _updateNotificationPtr = _lookup< + ffi.NativeFunction< + ffi.UnsignedInt Function(ffi.Pointer, ffi.Int, + NativeStringMap)>>('updateNotification'); + late final _updateNotification = _updateNotificationPtr.asFunction< + int Function(ffi.Pointer, int, NativeStringMap)>(); + + /// Cancels all notifications. + void cancelAll( + ffi.Pointer plugin, + ) { + return _cancelAll( + plugin, + ); + } + + late final _cancelAllPtr = + _lookup)>>( + 'cancelAll'); + late final _cancelAll = + _cancelAllPtr.asFunction)>(); + + /// Cancels a notification with the given ID. + /// + /// Only applications with "package identity" (ie, installed with an MSIX installer), can use this. + void cancelNotification( + ffi.Pointer plugin, + int id, + ) { + return _cancelNotification( + plugin, + id, + ); + } + + late final _cancelNotificationPtr = _lookup< + ffi.NativeFunction< + ffi.Void Function( + ffi.Pointer, ffi.Int)>>('cancelNotification'); + late final _cancelNotification = _cancelNotificationPtr + .asFunction, int)>(); + + /// Gets all notifications that have already been shown but are still in the Action center. + /// + /// Only applications with "package identity" (ie, installed with an MSIX installer), can use this. + /// When your app does not have identity, such as in debug mode, this will return an empty array. + ffi.Pointer getActiveNotifications( + ffi.Pointer plugin, + ffi.Pointer size, + ) { + return _getActiveNotifications( + plugin, + size, + ); + } + + late final _getActiveNotificationsPtr = _lookup< + ffi.NativeFunction< + ffi.Pointer Function( + ffi.Pointer, + ffi.Pointer)>>('getActiveNotifications'); + late final _getActiveNotifications = _getActiveNotificationsPtr.asFunction< + ffi.Pointer Function( + ffi.Pointer, ffi.Pointer)>(); + + /// Gets all notifications that have been scheduled but not yet shown. + ffi.Pointer getPendingNotifications( + ffi.Pointer plugin, + ffi.Pointer size, + ) { + return _getPendingNotifications( + plugin, + size, + ); + } + + late final _getPendingNotificationsPtr = _lookup< + ffi.NativeFunction< + ffi.Pointer Function( + ffi.Pointer, + ffi.Pointer)>>('getPendingNotifications'); + late final _getPendingNotifications = _getPendingNotificationsPtr.asFunction< + ffi.Pointer Function( + ffi.Pointer, ffi.Pointer)>(); + + /// Releases the memory associated with a [NativeNotificationDetails] array. + void freeDetailsArray( + ffi.Pointer ptr, + ) { + return _freeDetailsArray( + ptr, + ); + } + + late final _freeDetailsArrayPtr = _lookup< + ffi.NativeFunction< + ffi.Void Function( + ffi.Pointer)>>('freeDetailsArray'); + late final _freeDetailsArray = _freeDetailsArrayPtr + .asFunction)>(); + + /// Releases the memory associated with a [NativeLaunchDetails]. + void freeLaunchDetails( + NativeLaunchDetails details, + ) { + return _freeLaunchDetails( + details, + ); + } + + late final _freeLaunchDetailsPtr = + _lookup>( + 'freeLaunchDetails'); + late final _freeLaunchDetails = + _freeLaunchDetailsPtr.asFunction(); + + /// EXPERIMENTAL: Enables multithreading for this application. + /// + /// NOTE: This is only to make tests more stable and is not intended to be used in applications. + void enableMultithreading() { + return _enableMultithreading(); + } + + late final _enableMultithreadingPtr = + _lookup>('enableMultithreading'); + late final _enableMultithreading = + _enableMultithreadingPtr.asFunction(); +} + +final class NativePlugin extends ffi.Opaque {} + +/// A key-value pair in a map where both the keys and values are strings. +final class StringMapEntry extends ffi.Struct { + external ffi.Pointer key; + + external ffi.Pointer value; +} + +/// A map where the keys and values are all strings. +final class NativeStringMap extends ffi.Struct { + external ffi.Pointer entries; + + @ffi.Int() + external int size; +} + +/// Details about a notification. +final class NativeNotificationDetails extends ffi.Struct { + @ffi.Int() + external int id; +} + +/// How the app was launched, either by pressing on the notification or an action within it. +enum NativeLaunchType { + notification(0), + action(1); + + final int value; + const NativeLaunchType(this.value); + + static NativeLaunchType fromValue(int value) => switch (value) { + 0 => notification, + 1 => action, + _ => throw ArgumentError("Unknown value for NativeLaunchType: $value"), + }; +} + +/// Details about how the app was launched. +final class NativeLaunchDetails extends ffi.Struct { + /// Whether the app was launched by a notification + @ffi.Bool() + external bool didLaunch; + + /// What part of the notification launched the app. + @ffi.UnsignedInt() + external int launchType; + + /// The payload sent to the app by the notification. Usually the action that was pressed. + external ffi.Pointer payload; + + /// The IDs and values of any text inputs in the notification. + external NativeStringMap data; +} + +/// See: https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.notificationupdateresult +enum NativeUpdateResult { + success(0), + failed(1), + notFound(2); + + final int value; + const NativeUpdateResult(this.value); + + static NativeUpdateResult fromValue(int value) => switch (value) { + 0 => success, + 1 => failed, + 2 => notFound, + _ => + throw ArgumentError("Unknown value for NativeUpdateResult: $value"), + }; +} + +/// A callback that is run with [NativeLaunchDetails] when a notification is pressed. +/// +/// This may be called at app launch or even while the app is running. +typedef NativeNotificationCallback + = ffi.Pointer>; +typedef NativeNotificationCallbackFunction = ffi.Void Function( + NativeLaunchDetails details); +typedef DartNativeNotificationCallbackFunction = void Function( + NativeLaunchDetails details); diff --git a/flutter_local_notifications_windows/lib/src/ffi/utils.dart b/flutter_local_notifications_windows/lib/src/ffi/utils.dart new file mode 100644 index 000000000..3a63be14b --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/ffi/utils.dart @@ -0,0 +1,75 @@ +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart'; + +import '../details.dart'; +import '../plugin/base.dart'; +import 'bindings.dart'; + +/// Helpful methods on native string maps. +extension NativeStringMapUtils on NativeStringMap { + /// Converts this map to a typical Dart map. + Map toMap() => { + for (int index = 0; index < size; index++) + entries[index].key.toDartString(): + entries[index].value.toDartString(), + }; +} + +/// Gets the [NotificationResponseType] from a [NativeLaunchType]. +NotificationResponseType getResponseType(int launchType) { + switch (NativeLaunchType.fromValue(launchType)) { + case NativeLaunchType.notification: + return NotificationResponseType.selectedNotification; + case NativeLaunchType.action: + return NotificationResponseType.selectedNotificationAction; + } +} + +/// Gets the [NotificationUpdateResult] from a [NativeUpdateResult]. +NotificationUpdateResult getUpdateResult(NativeUpdateResult result) { + switch (result) { + case NativeUpdateResult.success: + return NotificationUpdateResult.success; + case NativeUpdateResult.failed: + return NotificationUpdateResult.error; + case NativeUpdateResult.notFound: + return NotificationUpdateResult.notFound; + } +} + +/// Helpful methods on string maps. +extension MapToNativeMap on Map { + /// Allocates a [NativeStringMap] using the provided arena. + NativeStringMap toNativeMap(Arena arena) { + final Pointer pointer = arena(); + pointer.ref.size = length; + pointer.ref.entries = arena(length); + int index = 0; + for (final MapEntry entry in entries) { + pointer.ref.entries[index].key = entry.key.toNativeUtf8(allocator: arena); + pointer.ref.entries[index].value = + entry.value.toNativeUtf8(allocator: arena); + index++; + } + return pointer.ref; + } +} + +/// Helpful methods on native notification details. +extension NativeNotificationDetailsUtils on Pointer { + /// Parses this array as a list of [ActiveNotification]s. + List asActiveNotifications(int length) => + [ + for (int index = 0; index < length; index++) + ActiveNotification(id: this[index].id), + ]; + + /// Parses this array os a list of [PendingNotificationRequest]s. + List asPendingRequests(int length) => + [ + for (int index = 0; index < length; index++) + PendingNotificationRequest(this[index].id, null, null, null), + ]; +} diff --git a/flutter_local_notifications_windows/lib/src/plugin/base.dart b/flutter_local_notifications_windows/lib/src/plugin/base.dart new file mode 100644 index 000000000..a86128aaf --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/plugin/base.dart @@ -0,0 +1,93 @@ +import 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart'; +import 'package:meta/meta.dart'; +import 'package:timezone/timezone.dart'; + +import '../details.dart'; +import '../details/xml/progress.dart'; + +export 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart'; +export 'package:timezone/timezone.dart'; + +/// The Windows implementation of `package:flutter_local_notifications`. +abstract class WindowsNotificationsBase + extends FlutterLocalNotificationsPlatform { + /// Initializes the plugin. No other method should be called before this. + Future initialize( + WindowsInitializationSettings settings, { + DidReceiveNotificationResponseCallback? onNotificationReceived, + }); + + /// Releases any resources used by this plugin. + void dispose(); + + /// Shows a notification using raw XML passed to the Windows APIs. + /// + /// See https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root. + /// For validation, see [the Windows Notifications Visualizer](https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/notifications-visualizer). + Future showRawXml({ + required int id, + required String xml, + Map bindings = const {}, + }); + + @override + Future show( + int id, + String? title, + String? body, { + String? payload, + WindowsNotificationDetails? details, + }); + + /// Schedules a notification to appear at the given date and time. + Future zonedSchedule( + int id, + String? title, + String? body, + TZDateTime scheduledDate, + WindowsNotificationDetails? details, { + String? payload, + }); + + /// Schedules a notification to appear using raw XML at this date and time. + /// + /// See https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root. + /// For validation, see [the Windows Notifications Visualizer](https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/notifications-visualizer). + Future zonedScheduleRawXml( + int id, + String xml, + TZDateTime scheduledDate, + WindowsNotificationDetails? details, + ); + + /// Updates the progress bar in the notification with the given ID. + /// + /// Note that in order to update [WindowsProgressBar.label], it must + /// not have been set to `null` when the notification was created + Future updateProgressBar({ + required int notificationId, + required WindowsProgressBar progressBar, + }) => + updateBindings( + id: notificationId, + bindings: progressBar.data, + ); + + /// Updates any data binding in the given notification. + /// + /// Instead of a text value, you can replace any value in the `` + /// element with `{name}`, and then use this function to update that value + /// by passing `data: {'name': value}`. + Future updateBindings({ + required int id, + required Map bindings, + }); + + /// EXPERIMENTAL: Enables multithreading + /// + /// NOTE: This is only here to make tests more stable. This has not been + /// tested in an application as it conflicts with Flutter's preferred + /// configuration for Windows APIs. + @visibleForTesting + void enableMultithreading(); +} diff --git a/flutter_local_notifications_windows/lib/src/plugin/ffi.dart b/flutter_local_notifications_windows/lib/src/plugin/ffi.dart new file mode 100644 index 000000000..2a2967317 --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/plugin/ffi.dart @@ -0,0 +1,368 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; +import 'package:meta/meta.dart'; + +import '../details.dart'; +import '../details/notification_to_xml.dart'; +import '../ffi/bindings.dart'; +import '../ffi/utils.dart'; + +import 'base.dart'; + +void _globalLaunchCallback(NativeLaunchDetails details) { + FlutterLocalNotificationsWindows.instance?._onNotificationReceived(details); +} + +extension on String { + bool get isValidGuid => + length == 36 && + this[8] == '-' && + this[13] == '-' && + this[18] == '-' && + this[23] == '-'; +} + +/// The Windows implementation of `package:flutter_local_notifications`. +class FlutterLocalNotificationsWindows extends WindowsNotificationsBase { + /// Creates an instance of the native plugin. + FlutterLocalNotificationsWindows(); + + /// Registers the Windows implementation with Flutter. + static void registerWith() { + FlutterLocalNotificationsPlatform.instance = + FlutterLocalNotificationsWindows(); + } + + /// The global instance of this plugin. Used in [_globalLaunchCallback]. + static FlutterLocalNotificationsWindows? instance; + + /// The FFI generated bindings to the native code. + late final NotificationsPluginBindings _bindings = + NotificationsPluginBindings(_library); + + final DynamicLibrary _library = + DynamicLibrary.open('flutter_local_notifications_windows.dll'); + + /// A pointer to the C++ handler class. + late final Pointer _plugin; + + bool _isReady = false; + + /// The last recorded launch details, if any. + /// + /// If the app is opened with a notification, this can be read with + /// [getNotificationAppLaunchDetails]. If a notification is pressed while the + /// app is running, this will be passed to [userCallback]. + NativeLaunchDetails? _details; + + /// A callback from [initialize] to run when a notification is pressed. + DidReceiveNotificationResponseCallback? userCallback; + + @override + Future initialize( + WindowsInitializationSettings settings, { + DidReceiveNotificationResponseCallback? onNotificationReceived, + }) async => + using((Arena arena) { + if (_isReady) { + return true; + } + _plugin = _bindings.createPlugin(); + // The C++ code will crash if there's an invalid GUID, so check it here + if (!settings.guid.isValidGuid) { + throw ArgumentError.value( + settings.guid, + 'GUID', + 'Invalid GUID. Please use xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + ' format.\nYou can get one by searching GUID generators online', + ); + } + instance = this; + userCallback = onNotificationReceived; + final Pointer appName = + settings.appName.toNativeUtf8(allocator: arena); + final Pointer aumId = + settings.appUserModelId.toNativeUtf8(allocator: arena); + final Pointer guid = settings.guid.toNativeUtf8(allocator: arena); + final Pointer iconPath = + settings.iconPath?.toNativeUtf8(allocator: arena) ?? nullptr; + final Pointer> + callback = + NativeCallable.listener( + _globalLaunchCallback) + .nativeFunction; + final bool result = + _bindings.init(_plugin, appName, aumId, guid, iconPath, callback); + _isReady = result; + return result; + }); + + @override + void dispose() { + if (!_isReady) { + return; + } + _bindings.disposePlugin(_plugin); + instance = null; + _isReady = false; + } + + void _onNotificationReceived(NativeLaunchDetails details) { + if (!_isReady) { + return; + } else if (_details != null) { + _bindings.freeLaunchDetails(_details!); + } + _details = details; + final Map data = details.data.toMap(); + final NotificationResponse response = NotificationResponse( + notificationResponseType: getResponseType(details.launchType), + payload: details.payload.toDartString(), + actionId: details.payload.toDartString(), + data: data, + ); + userCallback?.call(response); + } + + @override + Future cancel(int id) async { + if (!_isReady) { + throw StateError( + 'Flutter Local Notifications must be initialized before use', + ); + } + _bindings.cancelNotification(_plugin, id); + } + + @override + Future cancelAll() async { + if (!_isReady) { + throw StateError( + 'Flutter Local Notifications must be initialized before use', + ); + } + _bindings.cancelAll(_plugin); + } + + @override + Future> getActiveNotifications() async => + using((Arena arena) { + if (!_isReady) { + throw StateError( + 'Flutter Local Notifications must be initialized before use', + ); + } + final Pointer length = arena(); + final Pointer array = + _bindings.getActiveNotifications(_plugin, length); + final List result = + array.asActiveNotifications(length.value); + _bindings.freeDetailsArray(array); + return result; + }); + + @override + Future> + pendingNotificationRequests() async => using((Arena arena) { + if (!_isReady) { + throw StateError( + 'Flutter Local Notifications must be initialized before use', + ); + } + final Pointer length = arena(); + final Pointer array = + _bindings.getPendingNotifications(_plugin, length); + final List result = + array.asPendingRequests(length.value); + _bindings.freeDetailsArray(array); + return result; + }); + + @override + Future + getNotificationAppLaunchDetails() async { + if (!_isReady) { + throw StateError( + 'Flutter Local Notifications must be initialized before use', + ); + } + final NativeLaunchDetails? details = _details; + if (details == null) { + return null; + } + final Map data = details.data.toMap(); + return NotificationAppLaunchDetails( + details.didLaunch, + notificationResponse: NotificationResponse( + notificationResponseType: getResponseType(details.launchType), + payload: details.payload.toDartString(), + actionId: details.payload.toDartString(), + data: data, + ), + ); + } + + @override + Future periodicallyShow( + int id, + String? title, + String? body, + RepeatInterval repeatInterval, + ) async { + throw UnsupportedError( + 'Windows devices cannot periodically show notifications', + ); + } + + @override + Future periodicallyShowWithDuration( + int id, + String? title, + String? body, + Duration repeatDurationInterval, + ) async { + throw UnsupportedError( + 'Windows devices cannot periodically show notifications', + ); + } + + @override + Future show(int id, String? title, String? body, + {String? payload, WindowsNotificationDetails? details}) async => + using((Arena arena) { + if (!_isReady) { + throw StateError( + 'Flutter Local Notifications must be initialized before use', + ); + } + final Map bindings = { + if (details != null) ...details.bindings, + for (final WindowsProgressBar progressBar + in details?.progressBars ?? []) + ...progressBar.data, + }; + final NativeStringMap nativeMap = bindings.toNativeMap(arena); + final String xml = notificationToXml( + title: title, + body: body, + payload: payload, + details: details, + ); + final bool result = _bindings.showNotification( + _plugin, + id, + xml.toNativeUtf8(allocator: arena), + nativeMap, + ); + if (!result) { + throw Exception( + 'Flutter Local Notifications could not show notification', + ); + } + }); + + @override + Future showRawXml({ + required int id, + required String xml, + Map bindings = const {}, + }) async => + using((Arena arena) { + if (!_isReady) { + throw StateError( + 'Flutter Local Notifications must be initialized before use', + ); + } + final bool result = _bindings.showNotification(_plugin, id, + xml.toNativeUtf8(allocator: arena), bindings.toNativeMap(arena)); + if (!result) { + throw ArgumentError('Flutter Local Notifications: Invalid XML'); + } + }); + + @override + Future zonedSchedule( + int id, + String? title, + String? body, + TZDateTime scheduledDate, + WindowsNotificationDetails? details, { + String? payload, + }) async => + using((Arena arena) { + if (!_isReady) { + throw StateError( + 'Flutter Local Notifications must be initialized before use', + ); + } + if (scheduledDate.isBefore(DateTime.now())) { + throw ArgumentError( + 'Flutter Local Notifications cannot' + ' schedule notifications in the past', + ); + } + final String xml = notificationToXml( + title: title, + body: body, + payload: payload, + details: details, + ); + final int secondsSinceEpoch = + scheduledDate.millisecondsSinceEpoch ~/ 1000; + _bindings.scheduleNotification( + _plugin, + id, + xml.toNativeUtf8(allocator: arena), + secondsSinceEpoch, + ); + }); + + @override + Future zonedScheduleRawXml( + int id, + String xml, + TZDateTime scheduledDate, + WindowsNotificationDetails? details, + ) async => + using((Arena arena) { + if (!_isReady) { + throw StateError( + 'Flutter Local Notifications must be initialized before use', + ); + } + if (scheduledDate.isBefore(DateTime.now())) { + throw ArgumentError( + 'Flutter Local Notifications cannot' + ' schedule notifications in the past', + ); + } + final int secondsSinceEpoch = + scheduledDate.millisecondsSinceEpoch ~/ 1000; + _bindings.scheduleNotification( + _plugin, + id, + xml.toNativeUtf8(allocator: arena), + secondsSinceEpoch, + ); + }); + + @override + Future updateBindings({ + required int id, + required Map bindings, + }) async => + using((Arena arena) { + if (!_isReady) { + throw StateError( + 'Flutter Local Notifications must be initialized before use', + ); + } + final NativeUpdateResult result = _bindings.updateNotification( + _plugin, id, bindings.toNativeMap(arena)); + return getUpdateResult(result); + }); + + @override + @visibleForTesting + void enableMultithreading() => _bindings.enableMultithreading(); +} diff --git a/flutter_local_notifications_windows/lib/src/plugin/stub.dart b/flutter_local_notifications_windows/lib/src/plugin/stub.dart new file mode 100644 index 000000000..7aba9e34a --- /dev/null +++ b/flutter_local_notifications_windows/lib/src/plugin/stub.dart @@ -0,0 +1,99 @@ +import 'package:meta/meta.dart'; + +import '../details.dart'; +import 'base.dart'; + +/// The Windows implementation of `package:flutter_local_notifications`. +class FlutterLocalNotificationsWindows extends WindowsNotificationsBase { + @override + Future initialize( + WindowsInitializationSettings settings, { + DidReceiveNotificationResponseCallback? onNotificationReceived, + }) async { + throw UnsupportedError( + 'This platform does not support Windows notifications', + ); + } + + @override + void dispose() {} + + @override + Future cancel(int id) async {} + + @override + Future cancelAll() async {} + + @override + Future> getActiveNotifications() async => + []; + + @override + Future + getNotificationAppLaunchDetails() async => null; + + @override + Future> + pendingNotificationRequests() async => []; + + @override + Future periodicallyShow( + int id, + String? title, + String? body, + RepeatInterval repeatInterval, + ) async {} + + @override + Future periodicallyShowWithDuration( + int id, + String? title, + String? body, + Duration repeatDurationInterval, + ) async {} + + @override + Future show( + int id, + String? title, + String? body, { + String? payload, + WindowsNotificationDetails? details, + }) async {} + + @override + Future showRawXml({ + required int id, + required String xml, + Map bindings = const {}, + }) async {} + + @override + Future zonedSchedule( + int id, + String? title, + String? body, + TZDateTime scheduledDate, + WindowsNotificationDetails? details, { + String? payload, + }) async {} + + @override + Future zonedScheduleRawXml( + int id, + String xml, + TZDateTime scheduledDate, + WindowsNotificationDetails? details, + ) async {} + + @override + Future updateBindings({ + required int id, + required Map bindings, + }) async => + NotificationUpdateResult.success; + + @override + @visibleForTesting + void enableMultithreading() {} +} diff --git a/flutter_local_notifications_windows/pubspec.yaml b/flutter_local_notifications_windows/pubspec.yaml new file mode 100644 index 000000000..54580e8d3 --- /dev/null +++ b/flutter_local_notifications_windows/pubspec.yaml @@ -0,0 +1,27 @@ +name: flutter_local_notifications_windows +description: "A new Flutter FFI plugin project." +version: 1.0.0 +homepage: https://github.com/MaikuB/flutter_local_notifications/tree/master/flutter_local_notifications_windows + +environment: + sdk: ">=3.3.0 <4.0.0" + flutter: ">=3.19.0" + +dependencies: + ffi: ^2.1.2 + flutter_local_notifications_platform_interface: ^8.1.0 + meta: ^1.11.0 + timezone: ^0.9.4 + xml: ^6.5.0 + +dev_dependencies: + ffigen: ^13.0.0 + test: ^1.25.2 + +flutter: + plugin: + implements: flutter_local_notifications + platforms: + windows: + ffiPlugin: true + dartPluginClass: FlutterLocalNotificationsWindows diff --git a/flutter_local_notifications_windows/src/CMakeLists.txt b/flutter_local_notifications_windows/src/CMakeLists.txt new file mode 100644 index 000000000..5a95e858c --- /dev/null +++ b/flutter_local_notifications_windows/src/CMakeLists.txt @@ -0,0 +1,21 @@ +# The Flutter tooling requires that developers have CMake 3.10 or later +# installed. You should not increase this version, as doing so will cause +# the plugin to fail to compile for some customers of the plugin. +cmake_minimum_required(VERSION 3.10) + +project(flutter_local_notifications_windows_library VERSION 1.0.0 LANGUAGES CXX) + +add_library(flutter_local_notifications_windows SHARED + "ffi_api.cpp" + "plugin.cpp" + "utils.cpp" +) + +target_compile_features(flutter_local_notifications_windows PRIVATE cxx_std_17) + +set_target_properties(flutter_local_notifications_windows PROPERTIES + PUBLIC_HEADER ffi_api.h + OUTPUT_NAME "flutter_local_notifications_windows" +) + +target_compile_definitions(flutter_local_notifications_windows PUBLIC DART_SHARED_LIB) diff --git a/flutter_local_notifications_windows/src/ffi_api.cpp b/flutter_local_notifications_windows/src/ffi_api.cpp new file mode 100644 index 000000000..9d4f33dc2 --- /dev/null +++ b/flutter_local_notifications_windows/src/ffi_api.cpp @@ -0,0 +1,143 @@ +#include // <-- This must be the first Windows header +#include +#include + +#include "ffi_api.h" +#include "plugin.hpp" +#include "utils.hpp" + +using winrt::Windows::Data::Xml::Dom::XmlDocument; + +NativePlugin* createPlugin() { + return new NativePlugin(); +} + +void disposePlugin(NativePlugin* plugin) { + delete plugin; +} + +bool init(NativePlugin* plugin, char* appName, char* aumId, char* guid, char* iconPath, NativeNotificationCallback callback) { + string icon; + if (iconPath != nullptr) icon = string(iconPath); + const auto didRegister = plugin->registerApp(aumId, appName, guid, icon, callback); + if (!didRegister) return false; + const auto identity = plugin->checkIdentity(); + if (!identity.has_value()) return false; + plugin->hasIdentity = identity.value(); + plugin->aumid = winrt::to_hstring(aumId); + plugin->notifier = plugin->hasIdentity + ? ToastNotificationManager::CreateToastNotifier() + : ToastNotificationManager::CreateToastNotifier(plugin->aumid); + plugin->history = ToastNotificationManager::History(); + plugin->isReady = true; + return true; +} + +bool showNotification(NativePlugin* plugin, int id, char* xml, NativeStringMap bindings) { + if (!plugin->isReady) return false; + XmlDocument doc; + try { + doc.LoadXml(winrt::to_hstring(xml)); + } catch (winrt::hresult_error error) { + return false; + } + ToastNotification notification(doc); + const auto data = dataFromMap(bindings); + notification.Tag(winrt::to_hstring(id)); + notification.Data(data); + plugin->notifier.value().Show(notification); + return true; +} + +bool scheduleNotification(NativePlugin* plugin, int id, char* xml, int time) { + if (!plugin->isReady) return false; + XmlDocument doc; + try { doc.LoadXml(winrt::to_hstring(xml)); } + catch (winrt::hresult_error error) { return false; } + ScheduledToastNotification notification(doc, winrt::clock::from_time_t(time)); + notification.Tag(winrt::to_hstring(id)); + plugin->notifier.value().AddToSchedule(notification); + return true; +} + +NativeUpdateResult updateNotification(NativePlugin* plugin, int id, NativeStringMap bindings) { + if (!plugin->isReady) return NativeUpdateResult::failed; + const auto tag = winrt::to_hstring(id); + const auto data = dataFromMap(bindings); + const auto result = plugin->notifier.value().Update(data, tag); + return (NativeUpdateResult) result; +} + +void cancelAll(NativePlugin* plugin) { + if (!plugin->isReady) return; + if (plugin->hasIdentity) { + plugin->history.value().Clear(); + } else { + plugin->history.value().Clear(plugin->aumid); + } + for (const auto notification : plugin->notifier.value().GetScheduledToastNotifications()) { + plugin->notifier.value().RemoveFromSchedule(notification); + } +} + +void cancelNotification(NativePlugin* plugin, int id) { + if (!plugin->isReady) return; + const auto tag = winrt::to_hstring(id); + if (plugin->hasIdentity) plugin->history.value().Remove(tag); + for (const auto notification : plugin->notifier.value().GetScheduledToastNotifications()) { + if (notification.Tag() == tag) { + plugin->notifier.value().RemoveFromSchedule(notification); + return; + } + } +} + +NativeNotificationDetails* getActiveNotifications(NativePlugin* plugin, int* size) { + // TODO: Get more details here + if (!plugin->isReady || !plugin->hasIdentity) { *size = 0; return nullptr; } + const auto active = plugin->history.value().GetHistory(); + *size = active.Size(); + const auto result = new NativeNotificationDetails[*size]; + int index = 0; + for (const auto notification : active) { + const auto tag = notification.Tag(); + const auto tagStr = winrt::to_string(tag); + const auto tagInt = std::stoi(tagStr); + result[index++].id = tagInt; + } + return result; +} + +NativeNotificationDetails* getPendingNotifications(NativePlugin* plugin, int* size) { + // TODO: Get more details here + if (!plugin->isReady) { *size = 0; return nullptr; } + const auto pending = plugin->notifier.value().GetScheduledToastNotifications(); + *size = pending.Size(); + const auto result = new NativeNotificationDetails[*size]; + int index = 0; + for (const auto notification : pending) { + const auto tag = notification.Tag(); + const auto tagStr = winrt::to_string(tag); + const auto tagInt = std::stoi(tagStr); + result[index++].id = tagInt; + } + return result; +} + +void freeDetailsArray(NativeNotificationDetails* ptr) { + delete[] ptr; +} + +void freeLaunchDetails(NativeLaunchDetails details) { + if (details.payload != nullptr) delete[] details.payload; + for (int index = 0; index < details.data.size; index++) { + const auto pair = details.data.entries[index]; + delete pair.key; + delete pair.value; + } + if (details.data.entries != nullptr) delete[] details.data.entries; +} + +void enableMultithreading() { + CoInitializeEx(nullptr, COINIT_MULTITHREADED); +} diff --git a/flutter_local_notifications_windows/src/ffi_api.h b/flutter_local_notifications_windows/src/ffi_api.h new file mode 100644 index 000000000..2bb427d66 --- /dev/null +++ b/flutter_local_notifications_windows/src/ffi_api.h @@ -0,0 +1,128 @@ +#ifndef FFI_API_H_ +#define FFI_API_H_ + +#if _WIN32 +#include +#else +#include +#include +#endif + +#if _WIN32 +#define FFI_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FFI_PLUGIN_EXPORT +#endif + +// FFI needs to use a C-compatible API, even if the code is implemented in C++ or another language. +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/// A fake type to represent the C++ class that will own the Windows API handles. +typedef struct NativePlugin NativePlugin; + +/// A key-value pair in a map where both the keys and values are strings. +typedef struct StringMapEntry { + const char* key; + const char* value; +} StringMapEntry; + +/// A map where the keys and values are all strings. +typedef struct NativeStringMap { + const StringMapEntry* entries; + int size; +} NativeStringMap; + +/// Details about a notification. +typedef struct NativeNotificationDetails { + int id; +} NativeNotificationDetails; + +/// How the app was launched, either by pressing on the notification or an action within it. +typedef enum NativeLaunchType { + notification, + action, +} NativeLaunchType; + +/// Details about how the app was launched. +typedef struct NativeLaunchDetails { + /// Whether the app was launched by a notification + bool didLaunch; + /// What part of the notification launched the app. + NativeLaunchType launchType; + /// The payload sent to the app by the notification. Usually the action that was pressed. + const char* payload; + /// The IDs and values of any text inputs in the notification. + NativeStringMap data; +} NativeLaunchDetails; + +/// A callback that is run with [NativeLaunchDetails] when a notification is pressed. +/// +/// This may be called at app launch or even while the app is running. +typedef void (*NativeNotificationCallback)(NativeLaunchDetails details); + +// See: https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.notificationupdateresult +typedef enum NativeUpdateResult { + success = 0, + failed = 1, + notFound = 2, +} NativeUpdateResult; + +/// Allocates a new plugin that must be released with [disposePlugin]. +FFI_PLUGIN_EXPORT NativePlugin* createPlugin(); + +/// Releases the plugin and any resources it was holding onto. +FFI_PLUGIN_EXPORT void disposePlugin(NativePlugin* ptr); + +/// Initializes the plugin and registers the callback to be run when a notification is pressed. +FFI_PLUGIN_EXPORT bool init(NativePlugin* plugin, char* appName, char* aumId, char* guid, char* iconPath, NativeNotificationCallback callback); + +/// Shows the XML as a notification with the given ID. See [updateNotification] for details on bindings. +FFI_PLUGIN_EXPORT bool showNotification(NativePlugin* plugin, int id, char* xml, NativeStringMap bindings); + +/// Schedules the notification to be shown at the given time (as a [time_t]). +FFI_PLUGIN_EXPORT bool scheduleNotification(NativePlugin* plugin, int id, char* xml, int time); + +/// Updates a notification with the provided bindings after it's been shown. +/// +/// String values in the `` element of the XML can be placeholders instead of values, +/// for example, `{name}` and then call this function with a map with a `name` key, +/// and any string value, and the notification will be updated with that value where `name` was. +FFI_PLUGIN_EXPORT NativeUpdateResult updateNotification(NativePlugin* plugin, int id, NativeStringMap bindings); + +/// Cancels all notifications. +FFI_PLUGIN_EXPORT void cancelAll(NativePlugin* plugin); + +/// Cancels a notification with the given ID. +/// +/// Only applications with "package identity" (ie, installed with an MSIX installer), can use this. +FFI_PLUGIN_EXPORT void cancelNotification(NativePlugin* plugin, int id); + +/// Gets all notifications that have already been shown but are still in the Action center. +/// +/// Only applications with "package identity" (ie, installed with an MSIX installer), can use this. +/// When your app does not have identity, such as in debug mode, this will return an empty array. +FFI_PLUGIN_EXPORT NativeNotificationDetails* getActiveNotifications(NativePlugin* plugin, int* size); + +/// Gets all notifications that have been scheduled but not yet shown. +FFI_PLUGIN_EXPORT NativeNotificationDetails* getPendingNotifications(NativePlugin* plugin, int* size); + +/// Releases the memory associated with a [NativeNotificationDetails] array. +FFI_PLUGIN_EXPORT void freeDetailsArray(NativeNotificationDetails* ptr); + +/// Releases the memory associated with a [NativeLaunchDetails]. +FFI_PLUGIN_EXPORT void freeLaunchDetails(NativeLaunchDetails details); + +/// EXPERIMENTAL: Enables multithreading for this application. +/// +/// NOTE: This is only to make tests more stable and is not intended to be used in applications. +FFI_PLUGIN_EXPORT void enableMultithreading(); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/flutter_local_notifications_windows/src/plugin.cpp b/flutter_local_notifications_windows/src/plugin.cpp new file mode 100644 index 000000000..0dfd830b9 --- /dev/null +++ b/flutter_local_notifications_windows/src/plugin.cpp @@ -0,0 +1,225 @@ +#include + +#include // <-- This must be the first Windows header +#include +#include +#include +#include + +#include "plugin.hpp" +#include "utils.hpp" + +struct RegistryHandle { + using type = HKEY; + + static void close(type value) noexcept { + WINRT_VERIFY_(ERROR_SUCCESS, RegCloseKey(value)); + } + + static constexpr type invalid() noexcept { return nullptr; } +}; + +using RegistryKey = winrt::handle_type; + +/// This callback will be called when a notification sent by this plugin is clicked on. +struct NotificationActivationCallback : winrt::implements { + NativeNotificationCallback callback; + + HRESULT __stdcall Activate(LPCWSTR app, LPCWSTR args, NOTIFICATION_USER_INPUT_DATA const* data, ULONG count) noexcept final { + try { + // Fill the data map + vector entries; + for (ULONG i = 0; i < count; i++) { + auto item = data[i]; + const std::string key = CW2A(item.Key); + const std::string value = CW2A(item.Value); + const auto pair = StringMapEntry { toNativeString(key), toNativeString(value) }; + entries.push_back(pair); + } + + const auto openedWithAction = args != nullptr; + const auto payload = string(CW2A(args)); + const auto launchType = openedWithAction ? NativeLaunchType::action : NativeLaunchType::notification; + NativeLaunchDetails launchDetails; + launchDetails.didLaunch = true; + launchDetails.launchType = launchType; + launchDetails.payload = toNativeString(payload); + launchDetails.data = toNativeMap(entries); + callback(launchDetails); + return S_OK; + } catch (...) { + return winrt::to_hresult(); + } + } +}; + +/// A class factory that creates an instance of NotificationActivationCallback. +struct NotificationActivationCallbackFactory : winrt::implements { + NativeNotificationCallback callback; + + HRESULT __stdcall CreateInstance(IUnknown* outer, GUID const& iid, void** result) noexcept final { + *result = nullptr; + if (outer) return CLASS_E_NOAGGREGATION; + const auto cb = winrt::make_self(); + cb.get()->callback = callback; + return cb->QueryInterface(iid, result); + } + + HRESULT __stdcall LockServer(BOOL) noexcept final { return S_OK; } +}; + +/// Updates the Registry to enable notifications. +/// +/// Related resources: https://docs.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/send-local-toast-other-apps +void UpdateRegistry( + const std::string& aumid, + const std::string& appName, + const std::string& guid, + const std::optional& iconPath +) { + std::stringstream ss; + ss << "Software\\Microsoft\\Windows\\CurrentVersion\\PushNotifications\\Backup\\" << aumid; + const auto notifSettingsKeyPath = ss.str(); + RegistryKey key; + + // create registry key + // HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\PushNotifications\Backup + winrt::check_win32(RegCreateKeyExA( + HKEY_CURRENT_USER, + notifSettingsKeyPath.c_str(), + 0, + nullptr, + 0, + KEY_WRITE, + nullptr, + key.put(), + nullptr)); + + // put the following key values under the key + // HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\PushNotifications\Backup\ + // + // appType = app:desktop + // Setting = s:banner,s:toast,s:audio,c:toast,c:ringing + // wnsId = NonImmersivePackage + + const std::string appType = "app:desktop"; + const std::string setting = "s:banner,s:toast,s:audio,c:toast,c:ringing"; + const std::string wnsId = "NonImmersivePackage"; + winrt::check_win32(RegSetValueExA( + key.get(), + "appType", + 0, + REG_SZ, + reinterpret_cast(appType.c_str()), + static_cast(appType.size() + 1 * sizeof(char)))); + winrt::check_win32(RegSetValueExA( + key.get(), + "Setting", + 0, + REG_SZ, + reinterpret_cast(setting.c_str()), + static_cast(setting.size() + 1 * sizeof(char)))); + winrt::check_win32(RegSetValueExA( + key.get(), + "wnsId", + 0, + REG_SZ, + reinterpret_cast(wnsId.c_str()), + static_cast(wnsId.size() + 1 * sizeof(char)))); + + // now, we register app info to the Registry. + + ss.clear(); + ss.str(std::string()); + ss << "Software\\Classes\\AppUserModelId\\" << aumid; + const auto appInfoKeyPath = ss.str(); + RegistryKey appInfoKey; + + // create registry key + // HKEY_CURRENT_USER\Software\Classes\AppUserModelId\ + winrt::check_win32(RegCreateKeyExA( + HKEY_CURRENT_USER, + appInfoKeyPath.c_str(), + 0, + nullptr, + 0, + KEY_WRITE, + nullptr, + appInfoKey.put(), + nullptr)); + + winrt::check_win32(RegSetValueExA( + appInfoKey.get(), + "DisplayName", + 0, + REG_SZ, + reinterpret_cast(appName.c_str()), + static_cast(appName.size() + 1 * sizeof(char)))); + + if (iconPath.has_value()) { + const auto v = iconPath.value(); + winrt::check_win32(RegSetValueExA( + appInfoKey.get(), + "IconUri", + 0, + REG_SZ, + reinterpret_cast(v.c_str()), + static_cast(v.size() + 1 * sizeof(char)))); + } + + // combine guid to class id + ss.clear(); + ss.str(std::string()); + ss << '{' << guid << '}'; + const auto clsid = ss.str(); + + // register the guid of the notification activation callback + winrt::check_win32(RegSetValueExA( + appInfoKey.get(), + "CustomActivator", + 0, + REG_SZ, + reinterpret_cast(clsid.c_str()), + static_cast(clsid.size() + 1 * sizeof(char)))); +} + +/// Register the notification activation callback factory +/// and the guid of the callback. +bool RegisterCallback(const std::string& guid, NativeNotificationCallback callback) { + DWORD registration{}; + winrt::guid rclsid = parseGuid(guid); + const auto factory_ref = winrt::make_self(); + const auto factory = factory_ref.get(); + factory->callback = callback; + winrt::check_hresult(CoRegisterClassObject( + rclsid, + factory, + CLSCTX_LOCAL_SERVER, + REGCLS_MULTIPLEUSE, + ®istration + )); + return true; +} + +bool NativePlugin::registerApp( + const string& aumid, + const string& appName, + const string& guid, + const optional& iconPath, + NativeNotificationCallback callback +) { + UpdateRegistry(aumid, appName, guid, iconPath); + return RegisterCallback(guid, callback); +} + +std::optional NativePlugin::checkIdentity() { + if (!IsWindows8OrGreater()) return false; + uint32_t length = 0; + auto error = GetCurrentPackageFullName(&length, nullptr); + if (error == APPMODEL_ERROR_NO_PACKAGE) return false; + else if (error != ERROR_INSUFFICIENT_BUFFER) return std::nullopt; + std::vector fullName; + error = GetCurrentPackageFullName(&length, fullName.data()); + if (error != ERROR_SUCCESS) return std::nullopt; + return true; +} diff --git a/flutter_local_notifications_windows/src/plugin.hpp b/flutter_local_notifications_windows/src/plugin.hpp new file mode 100644 index 000000000..662bd59a0 --- /dev/null +++ b/flutter_local_notifications_windows/src/plugin.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include + +#include // <-- This must be the first Windows header +#include + +#include "ffi_api.h" + +using std::optional; +using std::string; +using namespace winrt::Windows::UI::Notifications; + +/// The C++ container object for WinRT handles. +/// +/// Note that this must be a struct as it was forward-declared as a struct in +/// `ffi_api.h`, which cannot use classes as it must be C-compatible. +struct NativePlugin { + /// Whether the plugin has been properly initialized. + bool isReady = false; + + /// Whether the current application has package identity (ie, was packaged with an MSIX). + /// + /// This impacts whether apps can query active notifications or cancel them. + /// For more details, see https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/package-identity-overview. + bool hasIdentity = false; + + /// The app user model ID. Used instead of package identity when [hasIdentity] is false. + /// + /// For more details, see https://learn.microsoft.com/en-us/windows/win32/shell/appids + winrt::hstring aumid; + + /// The API responsible for showing notifications. Null if [isReady] is false. + optional notifier; + + /// The API responsible for querying shown notifications. Null if [isReady] is false. + optional history; + + /// A callback to run when a notification is pressed, when the app is or is not running. + NativeNotificationCallback callback; + + NativePlugin() { } + ~NativePlugin() { } + + /// Checks whether the current application has package identity. See [hasIdentity] for details. + /// + /// Returns true or false if the package has identity, or null if an error occurred. + std::optional checkIdentity(); + + /// Registers the given [callback] to run when a notification is pressed. + bool registerApp( + const string& aumid, + const string& appName, + const string& guid, + const optional& iconPath, + NativeNotificationCallback callback + ); +}; diff --git a/flutter_local_notifications_windows/src/utils.cpp b/flutter_local_notifications_windows/src/utils.cpp new file mode 100644 index 000000000..a90bae3c9 --- /dev/null +++ b/flutter_local_notifications_windows/src/utils.cpp @@ -0,0 +1,85 @@ +#include + +#include "utils.hpp" +#include + +char* toNativeString(string str) { + const auto size = (int) str.size() + 1; // + 1 for null terminator + const auto result = new char[size]; + strcpy_s(result, size, str.c_str()); + return result; +} + +NativeStringMap toNativeMap(vector entries) { + const auto size = (int) entries.size(); + const auto array = new StringMapEntry[size]; + std::copy(entries.begin(), entries.end(), array); + return { array, size }; +} + +NotificationData dataFromMap(NativeStringMap map) { + NotificationData data; + for (int index = 0; index < map.size; index++) { + const auto key = winrt::to_hstring(map.entries[index].key); + const auto value = winrt::to_hstring(map.entries[index].value); + data.Values().Insert(key, value); + } + return data; +} + +constexpr uint8_t hex_to_uint(char const c) { + if (c >= '0' && c <= '9') { + return static_cast(c - '0'); + } else if (c >= 'A' && c <= 'F') { + return static_cast(10 + c - 'A'); + } else if (c >= 'a' && c <= 'f') { + return static_cast(10 + c - 'a'); + } else { + throw std::invalid_argument("Character is not a hexadecimal digit"); + } +} + +constexpr uint8_t hex_to_uint8(char const a, char const b) { + return (hex_to_uint(a) << 4) | hex_to_uint(b); +} + +constexpr uint16_t uint8_to_uint16(uint8_t a, uint8_t b) { + return (static_cast(a) << 8) | static_cast(b); +} + +constexpr uint32_t uint8_to_uint32(uint8_t a, uint8_t b, uint8_t c, uint8_t d) { + return (static_cast(uint8_to_uint16(a, b)) << 16) | + static_cast(uint8_to_uint16(c, d)); +} + +winrt::guid parseGuid(const std::string& guidString) { + if (guidString.size() != 36 || guidString[8] != '-' || guidString[13] != '-' || guidString[18] != '-' || guidString[23] != '-') { + throw std::invalid_argument("guidString is not a valid GUID string"); + } + return { + uint8_to_uint32( + hex_to_uint8(guidString[0], guidString[1]), + hex_to_uint8(guidString[2], guidString[3]), + hex_to_uint8(guidString[4], guidString[5]), + hex_to_uint8(guidString[6], guidString[7]) + ), + uint8_to_uint16( + hex_to_uint8(guidString[9], guidString[10]), + hex_to_uint8(guidString[11], guidString[12]) + ), + uint8_to_uint16( + hex_to_uint8(guidString[14], guidString[15]), + hex_to_uint8(guidString[16], guidString[17]) + ), + { + hex_to_uint8(guidString[19], guidString[20]), + hex_to_uint8(guidString[21], guidString[22]), + hex_to_uint8(guidString[24], guidString[25]), + hex_to_uint8(guidString[26], guidString[27]), + hex_to_uint8(guidString[28], guidString[29]), + hex_to_uint8(guidString[30], guidString[31]), + hex_to_uint8(guidString[32], guidString[33]), + hex_to_uint8(guidString[34], guidString[35]), + } + }; +} diff --git a/flutter_local_notifications_windows/src/utils.hpp b/flutter_local_notifications_windows/src/utils.hpp new file mode 100644 index 000000000..fa4ca5780 --- /dev/null +++ b/flutter_local_notifications_windows/src/utils.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +#include // <-- This must be the first Windows header +#include + +#include "ffi_api.h" + +using std::string; +using std::vector; +using namespace winrt::Windows::UI::Notifications; + +/// Allocates and returns a char array representing the original C++ string. +char* toNativeString(string str); + +/// Allocates and returns a [NativeStringMap] with the given key-value pairs. +NativeStringMap toNativeMap(vector entries); + +/// Parses a [NativeStringMap] into a WinRT [NotificationData]. +NotificationData dataFromMap(NativeStringMap map); + +winrt::guid parseGuid(const std::string& guidString); diff --git a/flutter_local_notifications_windows/test/bindings_test.dart b/flutter_local_notifications_windows/test/bindings_test.dart new file mode 100644 index 000000000..506ad202e --- /dev/null +++ b/flutter_local_notifications_windows/test/bindings_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; +import 'package:test/test.dart'; + +const WindowsInitializationSettings settings = WindowsInitializationSettings( + appName: 'Test app', + appUserModelId: 'com.test.test', + guid: 'a8c22b55-049e-422f-b30f-863694de08c8', +); + +const Map bindings = { + 'title': 'Bindings title', + 'body': 'Bindings body', +}; + +void main() => group('Bindings', () { + FlutterLocalNotificationsWindows().enableMultithreading(); + final FlutterLocalNotificationsWindows plugin = + FlutterLocalNotificationsWindows(); + setUpAll(() => plugin.initialize(settings)); + tearDownAll(() async { + await plugin.cancelAll(); + plugin.dispose(); + }); + + test('work in simple cases', () async { + await plugin.show(500, '{title}', '{body}'); + final NotificationUpdateResult result = + await plugin.updateBindings(id: 500, bindings: bindings); + expect(result, NotificationUpdateResult.success); + }); + + test('fail when ID is not found in simple cases', () async { + await plugin.show(501, '{title}', '{body}'); + final NotificationUpdateResult result = + await plugin.updateBindings(id: 599, bindings: bindings); + expect(result, NotificationUpdateResult.notFound); + }); + + test('are included in show()', () async { + await plugin.show( + 502, + '{title}', + '{body}', + details: const WindowsNotificationDetails(bindings: bindings), + ); + }); + + test('fail when notification has been cancelled', retry: 5, () async { + await Future.delayed(const Duration(milliseconds: 200)); + await plugin.show(503, '{title}', '{body}'); + final NotificationUpdateResult result = + await plugin.updateBindings(id: 503, bindings: bindings); + expect(result, NotificationUpdateResult.success); + await plugin.cancelAll(); + final NotificationUpdateResult result2 = + await plugin.updateBindings(id: 503, bindings: bindings); + expect(result2, NotificationUpdateResult.notFound); + }); + }); diff --git a/flutter_local_notifications_windows/test/details_test.dart b/flutter_local_notifications_windows/test/details_test.dart new file mode 100644 index 000000000..ebfc553d7 --- /dev/null +++ b/flutter_local_notifications_windows/test/details_test.dart @@ -0,0 +1,248 @@ +import 'dart:io'; + +import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; +import 'package:test/test.dart'; + +const WindowsInitializationSettings settings = WindowsInitializationSettings( + appName: 'Test app', + appUserModelId: 'com.test.test', + guid: 'a8c22b55-049e-422f-b30f-863694de08c8', +); + +extension PluginUtils on FlutterLocalNotificationsWindows { + static int id = 15; + + Future showDetails(WindowsNotificationDetails details) => + show(id++, 'Title', 'Body', details: details); + + void testDetails(WindowsNotificationDetails details) => + expect(showDetails(details), completes); +} + +void main() => group('Details:', () { + FlutterLocalNotificationsWindows().enableMultithreading(); + final FlutterLocalNotificationsWindows plugin = + FlutterLocalNotificationsWindows(); + setUpAll(() => plugin.initialize(settings)); + tearDownAll(() async { + await plugin.cancelAll(); + plugin.dispose(); + }); + + test('No details', () async { + expect(plugin.show(100, null, null), completes); + expect(plugin.show(101, 'Title', null), completes); + expect(plugin.show(102, null, 'Body'), completes); + expect(plugin.show(103, 'Title', 'Body'), completes); + expect(plugin.show(-1, 'Negative ID', 'Body'), completes); + }); + + test( + 'Simple details', + () async => plugin + ..testDetails(const WindowsNotificationDetails()) + ..testDetails( + const WindowsNotificationDetails(subtitle: 'Subtitle')) + ..testDetails(const WindowsNotificationDetails( + duration: WindowsNotificationDuration.long)) + ..testDetails(const WindowsNotificationDetails( + scenario: WindowsNotificationScenario.reminder)) + ..testDetails(WindowsNotificationDetails(timestamp: DateTime.now())) + ..testDetails(const WindowsNotificationDetails( + subtitle: '{message}', + bindings: {'message': 'Hello, Mr. Person'}))); + + test('Actions', () { + const WindowsAction simpleAction = + WindowsAction(content: 'Press me', arguments: '123'); + final WindowsAction complexAction = WindowsAction( + content: 'content', + arguments: 'args', + activationBehavior: WindowsNotificationBehavior.pendingUpdate, + buttonStyle: WindowsButtonStyle.success, + inputId: 'input-id', + tooltip: 'tooltip', + image: File('test/icon.png').absolute, + ); + plugin + ..testDetails(const WindowsNotificationDetails( + actions: [simpleAction])) + ..testDetails(WindowsNotificationDetails( + actions: [complexAction])) + ..testDetails(WindowsNotificationDetails( + actions: List.filled(5, simpleAction))); + expect( + plugin.showDetails( + WindowsNotificationDetails( + actions: List.filled(6, simpleAction), + ), + ), + throwsArgumentError, + ); + }); + + test( + 'Audio', + () => plugin + ..testDetails(WindowsNotificationDetails( + audio: WindowsNotificationAudio.silent())) + ..testDetails(WindowsNotificationDetails( + audio: WindowsNotificationAudio.preset( + sound: WindowsNotificationSound.call10)))); + + test('Rows', () { + const WindowsColumn emptyColumn = + WindowsColumn([]); + final WindowsImage image = WindowsImage.file( + File('test/icon.png').absolute, + altText: 'an icon'); + const WindowsNotificationText text = + WindowsNotificationText(text: 'Text'); + final WindowsColumn simpleColumn = + WindowsColumn([image, text]); + final WindowsRow bigRow = WindowsRow( + List.filled(5, simpleColumn), + ); + plugin + ..testDetails(const WindowsNotificationDetails()) + ..testDetails(const WindowsNotificationDetails( + rows: [WindowsRow([])])) + ..testDetails(const WindowsNotificationDetails(rows: [ + WindowsRow([emptyColumn]) + ])) + ..testDetails(WindowsNotificationDetails(rows: [ + WindowsRow([simpleColumn]) + ])) + ..testDetails(WindowsNotificationDetails(rows: [bigRow])) + ..testDetails(WindowsNotificationDetails( + rows: List.filled(5, bigRow))); + }); + + test('Header', () async { + const WindowsHeader header = WindowsHeader( + id: 'header1', + title: 'Header 1', + arguments: 'args1', + activation: WindowsHeaderActivation.foreground, + ); + plugin + ..testDetails(const WindowsNotificationDetails(header: header)) + ..testDetails(const WindowsNotificationDetails(header: header)); + }); + + test('Images', () async { + final WindowsImage simpleImage = WindowsImage.file( + File('test/icon.png').absolute, + altText: 'an icon', + ); + final WindowsImage complexImage = WindowsImage.file( + File('test/icon.png').absolute, + altText: 'an icon', + addQueryParams: true, + crop: WindowsImageCrop.circle, + placement: WindowsImagePlacement.appLogoOverride, + ); + plugin + ..testDetails( + WindowsNotificationDetails(images: [simpleImage])) + ..testDetails(WindowsNotificationDetails( + images: [simpleImage, complexImage])) + ..testDetails( + WindowsNotificationDetails( + images: List.filled(6, simpleImage), + ), + ); + }); + + test('Inputs', () async { + const WindowsTextInput textInput = WindowsTextInput( + id: 'input', + placeHolderContent: 'Text hint', + title: 'Text title', + ); + const WindowsSelectionInput selection = WindowsSelectionInput( + id: 'input', + items: [ + WindowsSelection(id: 'item1', content: 'Item 1'), + WindowsSelection(id: 'item2', content: 'Item 2'), + WindowsSelection(id: 'item3', content: 'Item 3'), + ], + ); + const WindowsAction action = WindowsAction( + content: 'Submit', + arguments: 'submit', + inputId: 'input', + ); + plugin + ..testDetails(const WindowsNotificationDetails( + inputs: [textInput])) + ..testDetails(const WindowsNotificationDetails( + inputs: [selection])) + ..testDetails( + WindowsNotificationDetails( + inputs: List.filled(5, textInput), + ), + ) + ..testDetails(const WindowsNotificationDetails( + inputs: [textInput], + actions: [action])) + ..testDetails(const WindowsNotificationDetails( + inputs: [selection, textInput], + actions: [action])); + expect( + plugin.showDetails( + WindowsNotificationDetails( + inputs: List.filled(6, textInput), + ), + ), + throwsArgumentError, + ); + }); + + test('Progress', retry: 5, () async { + final WindowsProgressBar simple = WindowsProgressBar( + id: 'simple', + status: 'Testing...', + value: 0.25, + ); + final WindowsProgressBar complex = WindowsProgressBar( + id: 'complex', + status: 'Testing...', + value: 0.75, + label: 'Progress label', + title: 'Progress title', + ); + final WindowsProgressBar dynamic = WindowsProgressBar( + id: 'dynamic', + status: 'Testing...', + value: 0, + ); + plugin + ..testDetails(WindowsNotificationDetails( + progressBars: [simple])) + ..testDetails(WindowsNotificationDetails( + progressBars: [complex])) + ..testDetails(WindowsNotificationDetails( + progressBars: [simple, complex])) + ..testDetails( + WindowsNotificationDetails( + progressBars: List.filled(6, simple), + ), + ); + await plugin.show( + 201, + null, + null, + details: WindowsNotificationDetails( + progressBars: [dynamic], + ), + ); + for (double i = 0; i <= 1.5; i += 0.05) { + dynamic.value = i; + final NotificationUpdateResult result = await plugin + .updateProgressBar(notificationId: 201, progressBar: dynamic); + expect(result, NotificationUpdateResult.success); + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + }); diff --git a/flutter_local_notifications_windows/test/icon.png b/flutter_local_notifications_windows/test/icon.png new file mode 100644 index 000000000..4d6372eeb Binary files /dev/null and b/flutter_local_notifications_windows/test/icon.png differ diff --git a/flutter_local_notifications_windows/test/plugin_test.dart b/flutter_local_notifications_windows/test/plugin_test.dart new file mode 100644 index 000000000..06de35d27 --- /dev/null +++ b/flutter_local_notifications_windows/test/plugin_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart'; +import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; +import 'package:test/test.dart'; +import 'package:timezone/data/latest_all.dart'; +import 'package:timezone/standalone.dart'; + +const WindowsInitializationSettings goodSettings = + WindowsInitializationSettings( + appName: 'test', + appUserModelId: 'com.test.test', + guid: 'a8c22b55-049e-422f-b30f-863694de08c8'); +const WindowsInitializationSettings badSettings = WindowsInitializationSettings( + appName: 'test', appUserModelId: 'com.test.test', guid: '123'); + +void main() => group('Plugin', () { + FlutterLocalNotificationsWindows().enableMultithreading(); + + setUpAll(initializeTimeZones); + + test('initializes safely', () async { + final FlutterLocalNotificationsWindows plugin = + FlutterLocalNotificationsWindows(); + final bool result = await plugin.initialize(goodSettings); + expect(result, isTrue); + plugin.dispose(); + }); + + test('catches bad GUIDs', () async { + final FlutterLocalNotificationsWindows plugin = + FlutterLocalNotificationsWindows(); + expect(plugin.initialize(badSettings), throwsArgumentError); + plugin.dispose(); + }); + + test('cannot be used before initializing', () async { + final FlutterLocalNotificationsWindows plugin = + FlutterLocalNotificationsWindows(); + final WindowsProgressBar progress = + WindowsProgressBar(id: 'progress', status: 'Testing', value: 0); + final TZDateTime now = TZDateTime.local(2024, 7, 18); + expect(plugin.cancel(0), throwsStateError); + expect(plugin.cancelAll(), throwsStateError); + expect(plugin.getActiveNotifications(), throwsStateError); + expect(plugin.getNotificationAppLaunchDetails(), throwsStateError); + expect(plugin.pendingNotificationRequests(), throwsStateError); + expect(plugin.show(0, 'Title', 'Body'), throwsStateError); + expect(plugin.showRawXml(id: 0, xml: ''), throwsStateError); + expect(plugin.updateBindings(id: 0, bindings: {}), + throwsStateError); + expect( + plugin.updateProgressBar(progressBar: progress, notificationId: 0), + throwsStateError); + expect( + plugin.zonedSchedule(0, null, null, now, null), throwsStateError); + plugin.dispose(); + }); + + test('cannot be used after disposed', () async { + final FlutterLocalNotificationsWindows plugin = + FlutterLocalNotificationsWindows(); + final WindowsProgressBar progress = + WindowsProgressBar(id: 'progress', status: 'Testing', value: 0); + final TZDateTime now = TZDateTime.local(2024, 7, 18); + await plugin.initialize(goodSettings); + plugin.dispose(); + expect(plugin.cancel(0), throwsStateError); + expect(plugin.cancelAll(), throwsStateError); + expect(plugin.getActiveNotifications(), throwsStateError); + expect(plugin.getNotificationAppLaunchDetails(), throwsStateError); + expect(plugin.pendingNotificationRequests(), throwsStateError); + expect(plugin.show(0, 'Title', 'Body'), throwsStateError); + expect(plugin.showRawXml(id: 0, xml: ''), throwsStateError); + expect(plugin.updateBindings(id: 0, bindings: {}), + throwsStateError); + expect( + plugin.updateProgressBar(progressBar: progress, notificationId: 0), + throwsStateError); + expect( + plugin.zonedSchedule(0, null, null, now, null), throwsStateError); + plugin.dispose(); + }); + + test('does not support repeating notifications', () async { + final FlutterLocalNotificationsWindows plugin = + FlutterLocalNotificationsWindows(); + await plugin.initialize(goodSettings); + expect( + plugin.periodicallyShow(0, null, null, RepeatInterval.everyMinute), + throwsUnsupportedError); + expect( + plugin.periodicallyShowWithDuration(0, null, null, Duration.zero), + throwsUnsupportedError); + plugin.dispose(); + }); + }); diff --git a/flutter_local_notifications_windows/test/scheduled_test.dart b/flutter_local_notifications_windows/test/scheduled_test.dart new file mode 100644 index 000000000..f810b8da3 --- /dev/null +++ b/flutter_local_notifications_windows/test/scheduled_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; +import 'package:test/test.dart'; +import 'package:timezone/data/latest_all.dart'; +import 'package:timezone/standalone.dart'; + +const WindowsInitializationSettings settings = WindowsInitializationSettings( + appName: 'Test app', + appUserModelId: 'com.test.test', + guid: 'a8c22b55-049e-422f-b30f-863694de08c8'); + +void main() => group('Schedules', () { + FlutterLocalNotificationsWindows().enableMultithreading(); + final FlutterLocalNotificationsWindows plugin = + FlutterLocalNotificationsWindows(); + setUpAll(initializeTimeZones); + setUpAll(() => plugin.initialize(settings)); + tearDownAll(() async { + await plugin.cancelAll(); + plugin.dispose(); + }); + + Future countPending() async => + (await plugin.pendingNotificationRequests()).length; + late final Location location = getLocation('US/Eastern'); + + test('work with basic times', () async { + await plugin.cancelAll(); + expect(await countPending(), 0); + final TZDateTime now = TZDateTime.now(location); + final TZDateTime later = now.add(const Duration(days: 1)); + expect(plugin.zonedSchedule(300, null, null, later, null), completes); + expect(await countPending(), 1); + expect(plugin.zonedSchedule(301, null, null, later, null), completes); + expect(await countPending(), 2); + expect(plugin.zonedSchedule(302, null, null, later, null), completes); + expect(await countPending(), 3); + }); + + test('do not work with earlier time', () async { + final TZDateTime now = TZDateTime.now(location); + final TZDateTime earlier = now.subtract(const Duration(days: 1)); + await plugin.cancelAll(); + expect(await countPending(), 0); + expect(plugin.zonedSchedule(302, null, null, now, null), + throwsArgumentError); + expect(plugin.zonedSchedule(302, null, null, earlier, null), + throwsArgumentError); + }); + }); diff --git a/flutter_local_notifications_windows/test/sound.mp3 b/flutter_local_notifications_windows/test/sound.mp3 new file mode 100644 index 000000000..60dbf9794 Binary files /dev/null and b/flutter_local_notifications_windows/test/sound.mp3 differ diff --git a/flutter_local_notifications_windows/test/xml_test.dart b/flutter_local_notifications_windows/test/xml_test.dart new file mode 100644 index 000000000..c9b100c58 --- /dev/null +++ b/flutter_local_notifications_windows/test/xml_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_local_notifications_windows/flutter_local_notifications_windows.dart'; +import 'package:test/test.dart'; + +const WindowsInitializationSettings settings = WindowsInitializationSettings( + appName: 'test', + appUserModelId: 'com.test.test', + guid: 'a8c22b55-049e-422f-b30f-863694de08c8'); +const String emptyXml = ''; +const String invalidXml = 'Blah blah blah'; +const String notWindowsXml = 'Hi'; +const String unmatchedXml = 'Hi'; +const String validXml = ''' + + + + + Hello World + This is a simple toast message + + + + +'''; + +const String complexXml = ''' + + + + + Surface Launch Party + Studio S / Ballroom + 4:00 PM, 10/26/2015 + + + + + + + + + + + + + + + + + + +'''; + +void main() => group('XML', () { + FlutterLocalNotificationsWindows().enableMultithreading(); + + final FlutterLocalNotificationsWindows plugin = + FlutterLocalNotificationsWindows(); + setUpAll(() => plugin.initialize(settings)); + tearDownAll(() async { + await plugin.cancelAll(); + plugin.dispose(); + }); + + test('catches invalid XML', () async { + expect(plugin.showRawXml(id: 0, xml: emptyXml), throwsArgumentError); + expect(plugin.showRawXml(id: 1, xml: invalidXml), throwsArgumentError); + expect( + plugin.showRawXml(id: 2, xml: notWindowsXml), throwsArgumentError); + expect( + plugin.showRawXml(id: 3, xml: unmatchedXml), throwsArgumentError); + expect(plugin.showRawXml(id: 4, xml: validXml), completes); + expect(plugin.showRawXml(id: 5, xml: complexXml), completes); + }); + }); diff --git a/flutter_local_notifications_windows/windows/.gitignore b/flutter_local_notifications_windows/windows/.gitignore new file mode 100644 index 000000000..b3eb2be16 --- /dev/null +++ b/flutter_local_notifications_windows/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/flutter_local_notifications_windows/windows/CMakeLists.txt b/flutter_local_notifications_windows/windows/CMakeLists.txt new file mode 100644 index 000000000..3be2b5cf4 --- /dev/null +++ b/flutter_local_notifications_windows/windows/CMakeLists.txt @@ -0,0 +1,23 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "flutter_local_notifications_windows") +project(${PROJECT_NAME} LANGUAGES CXX) + +# Invoke the build for native code shared with the other target platforms. +# This can be changed to accommodate different builds. +add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../src" "${CMAKE_CURRENT_BINARY_DIR}/shared") + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(flutter_local_notifications_windows_bundled_libraries + # Defined in ../src/CMakeLists.txt. + # This can be changed to accommodate different builds. + $ + PARENT_SCOPE +) diff --git a/images/windows_notification.png b/images/windows_notification.png new file mode 100644 index 000000000..44067cd5d Binary files /dev/null and b/images/windows_notification.png differ diff --git a/melos.yaml b/melos.yaml index df41b9c0d..c51fd59f0 100644 --- a/melos.yaml +++ b/melos.yaml @@ -3,6 +3,7 @@ repository: https://github.com/MaikuB/flutter_local_notifications packages: - flutter_local_notifications - flutter_local_notifications_linux + - flutter_local_notifications_windows - flutter_local_notifications_platform_interface - flutter_local_notifications/example/ @@ -21,6 +22,7 @@ scripts: description: Run unit tests in a specific package. run: melos exec -c 1 -- "flutter test" packageFilters: + ignore: '*_windows' dirExists: - test test:unit:android: @@ -28,10 +30,16 @@ scripts: run: melos exec -c 1 -- "flutter build apk --debug && cd android && ./gradlew flutter_local_notifications:testDebug" packageFilters: scope: "*example*" + test:unit:windows: + description: Runs Windows-specific unit tests + run: melos exec -c 1 -- "dart test" + packageFilters: + scope: '*_windows' test:integration: run: melos exec -c 1 -- "flutter test integration_test" description: Run integration tests packageFilters: + ignore: '*_windows' dirExists: - integration_test scope: "*example*" @@ -71,6 +79,15 @@ scripts: dirExists: - linux scope: "*example*" + build:example_windows: + run: | + melos exec -c 1 -- \ + "dart run msix:create" + description: Build a specific example app for Windows. + packageFilters: + dirExists: + - windows + scope: "*example*" clean: run: git clean -x -d -f -q