diff --git a/.gitignore b/.gitignore
index f624417f..d23754b4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,9 @@ dlcov.log
**/Package.resolved
**/.lock
**/workspace-state.json
+
+# Per-developer Firebase config for issue #1138 repro scaffolding
+# (only needed because firebase_messaging is wired into examples/demo_fm;
+# OneSignal itself does NOT need this file).
+**/google-services.json
+**/GoogleService-Info.plist
diff --git a/android/src/main/java/com/onesignal/flutter/FlutterMessengerResponder.java b/android/src/main/java/com/onesignal/flutter/FlutterMessengerResponder.java
index 8c739f6e..adf21ea1 100644
--- a/android/src/main/java/com/onesignal/flutter/FlutterMessengerResponder.java
+++ b/android/src/main/java/com/onesignal/flutter/FlutterMessengerResponder.java
@@ -5,6 +5,7 @@
import android.os.Looper;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.MethodChannel;
+import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import java.util.HashMap;
abstract class FlutterMessengerResponder {
@@ -12,6 +13,47 @@ abstract class FlutterMessengerResponder {
protected MethodChannel channel;
BinaryMessenger messenger;
+ /**
+ * #1138: bind the outgoing shared channel only on the first engine. These
+ * responders are process-global singletons but {@code registerWith} runs once
+ * per Flutter engine; FlutterFire's headless background engine would otherwise
+ * rebind the channel to an isolate with no listeners and drop native callbacks.
+ *
+ *
The incoming-call handler is still registered on every engine's messenger
+ * so Dart->Native calls work from any isolate (e.g. an FCM background handler),
+ * matching the pre-#1138 behavior; only the outgoing Native->Dart channel stays
+ * pinned to the first engine.
+ *
+ * @return true if this call performed the initial bind.
+ */
+ boolean bindChannelIfUnbound(BinaryMessenger messenger, String channelName, MethodCallHandler handler) {
+ MethodChannel channel = new MethodChannel(messenger, channelName);
+ channel.setMethodCallHandler(handler);
+ if (this.channel != null) {
+ return false;
+ }
+ this.messenger = messenger;
+ this.channel = channel;
+ return true;
+ }
+
+ /**
+ * #1138: reassert the channel binding to the engine that hosts the activity (the
+ * UI isolate), in case a background engine attached after us. No-op if the
+ * messenger is unchanged.
+ *
+ * @return true if the channel was rebound to a different engine.
+ */
+ boolean rebindChannelToEngine(BinaryMessenger activityMessenger, String channelName, MethodCallHandler handler) {
+ if (activityMessenger == null || activityMessenger == this.messenger) {
+ return false;
+ }
+ this.messenger = activityMessenger;
+ this.channel = new MethodChannel(activityMessenger, channelName);
+ this.channel.setMethodCallHandler(handler);
+ return true;
+ }
+
/**
* MethodChannel class is home to success() method used by Result class
* It has the @UiThread annotation and must be run on UI thread, otherwise a RuntimeException will be thrown
diff --git a/android/src/main/java/com/onesignal/flutter/OneSignalInAppMessages.java b/android/src/main/java/com/onesignal/flutter/OneSignalInAppMessages.java
index 6fe3ecb8..c23f5634 100644
--- a/android/src/main/java/com/onesignal/flutter/OneSignalInAppMessages.java
+++ b/android/src/main/java/com/onesignal/flutter/OneSignalInAppMessages.java
@@ -11,7 +11,6 @@
import com.onesignal.inAppMessages.IInAppMessageWillDisplayEvent;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.MethodCall;
-import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import java.util.Collection;
@@ -33,10 +32,11 @@ private OneSignalInAppMessages() {}
static void registerWith(BinaryMessenger messenger) {
OneSignalInAppMessages controller = getSharedInstance();
+ controller.bindChannelIfUnbound(messenger, "OneSignal#inappmessages", controller);
+ }
- controller.messenger = messenger;
- controller.channel = new MethodChannel(messenger, "OneSignal#inappmessages");
- controller.channel.setMethodCallHandler(controller);
+ void onAttachedToActivity(BinaryMessenger activityMessenger) {
+ rebindChannelToEngine(activityMessenger, "OneSignal#inappmessages", this);
}
@Override
diff --git a/android/src/main/java/com/onesignal/flutter/OneSignalNotifications.java b/android/src/main/java/com/onesignal/flutter/OneSignalNotifications.java
index d2ab0dd5..8913dc22 100644
--- a/android/src/main/java/com/onesignal/flutter/OneSignalNotifications.java
+++ b/android/src/main/java/com/onesignal/flutter/OneSignalNotifications.java
@@ -29,6 +29,10 @@ public class OneSignalNotifications extends FlutterMessengerResponder
private final HashMap notificationOnWillDisplayEventCache = new HashMap<>();
private final HashMap preventedDefaultCache = new HashMap<>();
+ // #1138: tracks if Dart requested clicks, so we can queue (not drop) them
+ // while the channel is detached across engine/activity lifecycles.
+ private boolean clickListenerRequested = false;
+
public static OneSignalNotifications getSharedInstance() {
if (sharedInstance == null) {
sharedInstance = new OneSignalNotifications();
@@ -73,9 +77,7 @@ public void resumeWith(@NonNull Object o) {
static void registerWith(BinaryMessenger messenger) {
OneSignalNotifications controller = getSharedInstance();
- controller.messenger = messenger;
- controller.channel = new MethodChannel(messenger, "OneSignal#notifications");
- controller.channel.setMethodCallHandler(controller);
+ controller.bindChannelIfUnbound(messenger, "OneSignal#notifications", controller);
}
@Override
@@ -227,18 +229,53 @@ public void onNotificationPermissionChange(boolean permission) {
invokeMethodOnUiThread("OneSignal#onNotificationPermissionDidChange", hash);
}
- void onDetachedFromEngine() {
- // The Flutter engine can be torn down before OneSignal.initialize() has been
- // called from Dart (cold start, fast finish, etc.). Calling getNotifications()
- // in that state throws IllegalStateException from the native SDK. See #1149.
+ void onDetachedFromEngine(BinaryMessenger detachingMessenger) {
+ // #1138: ignore a FlutterFire background engine detaching — removing the
+ // listener bound to the live UI engine would drop the next click (the UI
+ // engine fires no activity event, so nothing re-adds it).
+ if (detachingMessenger != null && detachingMessenger != this.messenger) {
+ return;
+ }
+ // #1149: engine can be torn down before Dart calls initialize().
if (!OneSignal.isInitialized()) {
return;
}
- // Unsubscribe so clicks while the engine is dead get queued by the native SDK
- // instead of dispatched on a detached channel.
+ // Unsubscribe so clicks get queued by the native SDK, not dropped.
OneSignal.getNotifications().removeClickListener(this);
}
+ /**
+ * Same as {@link #onDetachedFromEngine} but for when the engine survives and
+ * only the host activity is destroyed (e.g. back-pressed out of MainActivity).
+ */
+ void onDetachedFromActivity() {
+ if (!OneSignal.isInitialized()) {
+ return;
+ }
+ OneSignal.getNotifications().removeClickListener(this);
+ }
+
+ /**
+ * #1138: rebind the shared channel to the UI engine on (re)attach and drain
+ * any clicks the native SDK queued while detached.
+ */
+ void onAttachedToActivity(BinaryMessenger activityMessenger) {
+ // Rebind the shared channel so callbacks hit the now-foreground engine.
+ rebindChannelToEngine(activityMessenger, "OneSignal#notifications", this);
+ // Re-add the listener so the native SDK drains any clicks queued while
+ // detached. Works for fresh, FCM-background, and pre-warmed cached engines
+ // alike: a pre-warmed engine's Dart already ran main() and won't re-call
+ // OneSignal#addNativeClickListener, so the rebind alone wouldn't restore it.
+ // Draining before this engine's Dart listeners exist is safe — the Dart
+ // bridge buffers clicks that arrive with no listeners and flushes them once
+ // addClickListener runs.
+ if (!clickListenerRequested || !OneSignal.isInitialized()) {
+ return;
+ }
+ OneSignal.getNotifications().removeClickListener(this);
+ OneSignal.getNotifications().addClickListener(this);
+ }
+
private void lifecycleInit(Result result) {
OneSignal.getNotifications().removeForegroundLifecycleListener(this);
OneSignal.getNotifications().addForegroundLifecycleListener(this);
@@ -250,6 +287,7 @@ private void lifecycleInit(Result result) {
}
private void registerClickListener() {
+ clickListenerRequested = true;
OneSignal.getNotifications().removeClickListener(this);
OneSignal.getNotifications().addClickListener(this);
}
diff --git a/android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java b/android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java
index 2c2e8f21..c01c72d2 100644
--- a/android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java
+++ b/android/src/main/java/com/onesignal/flutter/OneSignalPlugin.java
@@ -45,26 +45,45 @@ public void onAttachedToEngine(@NonNull FlutterPlugin.FlutterPluginBinding flutt
@Override
public void onDetachedFromEngine(@NonNull FlutterPlugin.FlutterPluginBinding binding) {
- onDetachedFromEngine();
- }
-
- private void onDetachedFromEngine() {
- OneSignalNotifications.getSharedInstance().onDetachedFromEngine();
+ // #1138: pass the detaching engine's messenger so a background (FlutterFire)
+ // engine detaching doesn't tear down the listener bound to the UI engine.
+ OneSignalNotifications.getSharedInstance().onDetachedFromEngine(binding.getBinaryMessenger());
}
@Override
public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) {
this.context = binding.getActivity();
+ rebindChannelsToActivityEngine();
}
@Override
- public void onDetachedFromActivity() {}
+ public void onDetachedFromActivity() {
+ // #1138: unregister so the native SDK queues clicks until a new activity attaches.
+ OneSignalNotifications.getSharedInstance().onDetachedFromActivity();
+ }
@Override
- public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) {}
+ public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) {
+ this.context = binding.getActivity();
+ rebindChannelsToActivityEngine();
+ }
+
+ /**
+ * #1138: (re)bind the process-global singleton channels to the engine that
+ * hosts the activity (the UI isolate), so native callbacks aren't routed to a
+ * FlutterFire background engine that has no listeners.
+ */
+ private void rebindChannelsToActivityEngine() {
+ OneSignalNotifications.getSharedInstance().onAttachedToActivity(this.messenger);
+ OneSignalUser.getSharedInstance().onAttachedToActivity(this.messenger);
+ OneSignalPushSubscription.getSharedInstance().onAttachedToActivity(this.messenger);
+ OneSignalInAppMessages.getSharedInstance().onAttachedToActivity(this.messenger);
+ }
@Override
- public void onDetachedFromActivityForConfigChanges() {}
+ public void onDetachedFromActivityForConfigChanges() {
+ OneSignalNotifications.getSharedInstance().onDetachedFromActivity();
+ }
@Override
public void onMethodCall(MethodCall call, Result result) {
diff --git a/android/src/main/java/com/onesignal/flutter/OneSignalPushSubscription.java b/android/src/main/java/com/onesignal/flutter/OneSignalPushSubscription.java
index bb5bdc2e..498d0eae 100644
--- a/android/src/main/java/com/onesignal/flutter/OneSignalPushSubscription.java
+++ b/android/src/main/java/com/onesignal/flutter/OneSignalPushSubscription.java
@@ -6,7 +6,6 @@
import com.onesignal.user.subscriptions.PushSubscriptionChangedState;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.MethodCall;
-import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import org.json.JSONException;
@@ -26,9 +25,11 @@ private OneSignalPushSubscription() {}
static void registerWith(BinaryMessenger messenger) {
OneSignalPushSubscription controller = getSharedInstance();
- controller.messenger = messenger;
- controller.channel = new MethodChannel(messenger, "OneSignal#pushsubscription");
- controller.channel.setMethodCallHandler(controller);
+ controller.bindChannelIfUnbound(messenger, "OneSignal#pushsubscription", controller);
+ }
+
+ void onAttachedToActivity(BinaryMessenger activityMessenger) {
+ rebindChannelToEngine(activityMessenger, "OneSignal#pushsubscription", this);
}
@Override
diff --git a/android/src/main/java/com/onesignal/flutter/OneSignalUser.java b/android/src/main/java/com/onesignal/flutter/OneSignalUser.java
index 2a7c894b..5a4f7995 100644
--- a/android/src/main/java/com/onesignal/flutter/OneSignalUser.java
+++ b/android/src/main/java/com/onesignal/flutter/OneSignalUser.java
@@ -6,7 +6,6 @@
import com.onesignal.user.state.UserChangedState;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.MethodCall;
-import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import java.util.List;
@@ -27,9 +26,11 @@ private OneSignalUser() {}
static void registerWith(BinaryMessenger messenger) {
OneSignalUser controller = getSharedInstance();
- controller.messenger = messenger;
- controller.channel = new MethodChannel(messenger, "OneSignal#user");
- controller.channel.setMethodCallHandler(controller);
+ controller.bindChannelIfUnbound(messenger, "OneSignal#user", controller);
+ }
+
+ void onAttachedToActivity(BinaryMessenger activityMessenger) {
+ rebindChannelToEngine(activityMessenger, "OneSignal#user", this);
}
@Override
diff --git a/examples/demo/lib/main.dart b/examples/demo/lib/main.dart
index 898f1712..9075802c 100644
--- a/examples/demo/lib/main.dart
+++ b/examples/demo/lib/main.dart
@@ -59,7 +59,7 @@ Future main() async {
debugPrint('IAM did dismiss: ${event.message.messageId}');
});
OneSignal.InAppMessages.addClickListener((event) {
- debugPrint('IAM clicked: ${event.result.actionId}');
+ debugPrint('IAM clicked: ${event.message.messageId}');
});
// Register notification listeners
diff --git a/examples/demo/lib/viewmodels/app_viewmodel.dart b/examples/demo/lib/viewmodels/app_viewmodel.dart
index 18f7a086..afb89005 100644
--- a/examples/demo/lib/viewmodels/app_viewmodel.dart
+++ b/examples/demo/lib/viewmodels/app_viewmodel.dart
@@ -162,8 +162,16 @@ class AppViewModel extends ChangeNotifier {
OneSignal.User.pushSubscription.addObserver((state) {
_pushSubscriptionId = state.current.id;
_pushEnabled = state.current.optedIn;
+ String fmtToken(String? t) {
+ if (t == null || t.isEmpty) return 'null';
+ return t.length > 8 ? '${t.substring(0, 8)}…' : t;
+ }
+
debugPrint(
- 'Push subscription changed: id=${state.current.id}, optedIn=${state.current.optedIn}',
+ 'Push subscription changed: '
+ 'id=${state.previous.id ?? 'null'} → ${state.current.id ?? 'null'}, '
+ 'optedIn=${state.previous.optedIn} → ${state.current.optedIn}, '
+ 'token=${fmtToken(state.previous.token)} → ${fmtToken(state.current.token)}',
);
notifyListeners();
});
diff --git a/examples/demo_fm/.env.example b/examples/demo_fm/.env.example
new file mode 100644
index 00000000..d789fef7
--- /dev/null
+++ b/examples/demo_fm/.env.example
@@ -0,0 +1,6 @@
+ONESIGNAL_APP_ID=your-onesignal-app-id
+ONESIGNAL_API_KEY=your-onesignal-api-key
+
+# Optional: Android Notification Channel ID for the WITH SOUND test notification.
+# Create one in your OneSignal dashboard under Settings > Android Notification Categories.
+ONESIGNAL_ANDROID_CHANNEL_ID=
diff --git a/examples/demo_fm/.gitignore b/examples/demo_fm/.gitignore
new file mode 100644
index 00000000..b87603b4
--- /dev/null
+++ b/examples/demo_fm/.gitignore
@@ -0,0 +1,51 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+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
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
+
+# Environment
+.env
+
+# FCM service account key for tools/send_fcm.sh (never commit)
+tools/service-account.json
diff --git a/examples/demo_fm/.metadata b/examples/demo_fm/.metadata
new file mode 100644
index 00000000..83b34ebb
--- /dev/null
+++ b/examples/demo_fm/.metadata
@@ -0,0 +1,45 @@
+# 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: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ - platform: android
+ create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ - platform: ios
+ create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ - platform: linux
+ create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ - platform: macos
+ create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ - platform: web
+ create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ - platform: windows
+ create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+ base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
+
+ # 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/examples/demo_fm/README.md b/examples/demo_fm/README.md
new file mode 100644
index 00000000..a5405bfd
--- /dev/null
+++ b/examples/demo_fm/README.md
@@ -0,0 +1,208 @@
+# demo_fm — OneSignal + Firebase Messaging coexistence demo
+
+This is a variant of [`examples/demo`](../demo) that additionally wires in
+**Firebase Cloud Messaging** (`firebase_core` + `firebase_messaging`)
+alongside the OneSignal Flutter SDK.
+
+It exists to reproduce and validate fixes for
+[issue #1138](https://github.com/OneSignal/OneSignal-Flutter-SDK/issues/1138):
+on Android, `OneSignal.Notifications.addClickListener` could stop firing on
+background/killed notification taps when `firebase_messaging` is present in
+the same app. FCM in the app perturbs the Flutter engine / host activity
+lifecycle, which surfaced a channel-binding bug in the plugin.
+
+> Looking for the plain OneSignal sample? Use [`examples/demo`](../demo).
+> Use this project only when you need the Firebase coexistence scenario.
+
+## How `demo_fm` differs from `demo`
+
+| Area | `examples/demo` | `examples/demo_fm` |
+| --- | --- | --- |
+| Dependencies | `onesignal_flutter` (in-tree, `path: ../../`) | same **plus** `firebase_core` + `firebase_messaging` |
+| `lib/main.dart` | OneSignal init only | same OneSignal listeners **plus** `Firebase.initializeApp()`, a background FCM handler, `onMessage` / `onMessageOpenedApp` listeners, FCM token logging, and a `getInitialMessage()` check |
+| Android Gradle | no Firebase plugin | applies `com.google.gms.google-services` in `android/settings.gradle.kts` + `android/app/build.gradle.kts` |
+| Firebase config | not needed | **requires** `android/app/google-services.json` (per-developer, not committed — the build fails without it) |
+| Purpose | general SDK sample | repro harness for #1138 (OneSignal + FCM coexistence) |
+
+The actual SDK fix lives in the shared in-tree plugin
+(`android/src/main/java/com/onesignal/flutter/OneSignalNotifications.java` and
+`OneSignalPlugin.java`), so **both** demos consume it via the `path: ../../`
+dependency. `demo_fm` is what lets you exercise the Firebase-specific failure
+path that originally hid the bug.
+
+## Setup
+
+This mirrors `demo`'s setup, with two extra Firebase requirements.
+
+1. Add your own Firebase project's `google-services.json` to
+ `examples/demo_fm/android/app/google-services.json`. The build will fail
+ without it (the `com.google.gms.google-services` plugin is wired in). It is
+ not committed because it is per-developer.
+
+2. Make sure the Firebase project's package name matches the Android
+ `applicationId` (`com.onesignal.example` by default).
+
+3. Put your OneSignal credentials in `examples/demo_fm/.env`
+ (see `.env.example`):
+
+ ```
+ ONESIGNAL_APP_ID=
+ ONESIGNAL_API_KEY=
+ ```
+
+4. Run on a real Android device (or emulator with Google Play services):
+
+ ```
+ cd examples/demo_fm
+ flutter pub get
+ flutter run -d
+ ```
+
+## iOS setup & testing
+
+The original #1138 bug is Android-specific, but `demo_fm` also surfaced an
+**iOS** coexistence bug: with `firebase_messaging` present, the foreground
+`onWillDisplayNotification` could fire **twice** for a single OneSignal push,
+because Firebase's `UNUserNotificationCenter` delegate forwarding re-enters
+OneSignal's swizzled `willPresent`. The fix dedupes re-entrant dispatches in
+the in-tree plugin (`ios/.../OSFlutterNotifications.m`).
+
+To run the Firebase path on iOS:
+
+1. Add an **iOS app** to your Firebase project and drop its
+ `GoogleService-Info.plist` into `examples/demo_fm/ios/Runner/`, then add it to
+ the Runner target in Xcode (it's gitignored). Without it,
+ `Firebase.initializeApp()` throws `[core/not-initialized]`.
+
+2. Upload an **APNs Authentication Key** (`.p8`, with Key ID + Team ID) to
+ Firebase console → Project settings → Cloud Messaging → Apple app
+ configuration. Without it, direct FCM sends to iOS fail with
+ `THIRD_PARTY_AUTH_ERROR` (FCM can't relay to APNs).
+
+3. Prefer a **physical device** — FCM-relayed APNs delivery to the iOS
+ **simulator** is unreliable, and silent (`data`) pushes especially tend not
+ to arrive on the simulator.
+
+### Reading logs after the app is killed (iOS)
+
+`flutter run` streams logs over a debug connection to the app process, so
+killing the app prints `Lost connection to device` and cold-launch logs (the
+killed-state click / `getInitialMessage`) never reach that terminal. Read the
+system log instead:
+
+```bash
+xcrun simctl spawn booted log stream --level debug --style compact \
+ | grep -iE 'FCM|OneSignal|flutter'
+```
+
+Start it **before** tapping the notification so it captures the cold launch.
+On a physical device use `idevicesyslog` or Console.app (select the device).
+
+### Expected iOS log lines for a direct FCM push
+
+| App state | `notif` / `both` | `data` (silent) |
+| --- | --- | --- |
+| Foreground | `[FCM fg] received` | unreliable on iOS |
+| Background → tap | `[FCM open] tapped` | n/a (no UI) |
+| Killed → tap | `[FCM initial] launched from tap` | n/a (no UI) |
+
+Use `both` (not `notif`) when you want `data={...}` populated in these logs.
+
+## Reproducing / verifying issue #1138
+
+Send a push from the OneSignal dashboard and tap it in each app state
+(foreground / background / killed). The OneSignal `addClickListener` should
+fire every time — watch for `Notification clicked:` in the logs.
+
+Quick check — watch for the click callback (and the other listeners) after
+tapping a notification:
+
+```
+adb logcat -c && adb logcat | rg -i 'Notification (clicked|foreground)|IAM |\[FCM'
+```
+
+## Testing with non-OneSignal (direct FCM) notifications
+
+Pushes sent from the **OneSignal dashboard** are rendered and handled by the
+OneSignal SDK (you'll see `Notification clicked:` / `Notification foreground
+will display:`). They are delivered as FCM **data** messages, so FlutterFire's
+`onMessageOpenedApp` never fires for them.
+
+To exercise the **FlutterFire** path instead, send a message **directly through
+FCM**, bypassing OneSignal. This is the right way to verify the two pipelines
+coexist without interfering.
+
+### 1. Get the device FCM token
+
+`main.dart` logs it on startup:
+
+```
+adb logcat -c && adb logcat | rg '\[FCM token\]'
+```
+
+### 2a. Send via the helper script (easiest)
+
+[`tools/send_fcm.sh`](tools/send_fcm.sh) reads `project_id` from
+`google-services.json` and resolves an OAuth token from `$ACCESS_TOKEN`, a
+`tools/service-account.json` (gitignored, preferred — it mints a scoped token
+directly via openssl, no `gcloud` login needed), or `gcloud auth
+print-access-token`:
+
+```bash
+cd examples/demo_fm
+FCM_TOKEN= tools/send_fcm.sh notif # notification (alert) message
+FCM_TOKEN= tools/send_fcm.sh data # data-only (silent) message
+FCM_TOKEN= tools/send_fcm.sh both # notification + data payload
+```
+
+| Mode | iOS | Android | Use for |
+| --- | --- | --- | --- |
+| `notif` | reliable (alert; `data={}`) | reliable | foreground/tap delivery |
+| `data` | silent push — throttled/unreliable, esp. on simulator | reliable | the silent background path (`[FCM bg]`), test on Android |
+| `both` | reliable alert that also carries `data` | reliable | exercising the data payload on iOS (`[FCM fg]`/`[FCM open]`/`[FCM initial]` with `data` populated) |
+
+### 2b. Send directly via the FCM HTTP v1 API
+
+`` is in `android/app/google-services.json` (`project_info.project_id`).
+Get an access token from a service account JSON (Firebase console → Project
+settings → Service accounts):
+
+```bash
+ACCESS_TOKEN=$(gcloud auth application-default print-access-token)
+PROJECT_ID=
+TOKEN=
+```
+
+**Notification message** — shows a FlutterFire tray notification; tapping it
+from the background fires `onMessageOpenedApp`, from the killed state it
+arrives via `getInitialMessage()`:
+
+```bash
+curl -X POST "https://fcm.googleapis.com/v1/projects/$PROJECT_ID/messages:send" \
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
+ -d '{"message":{"token":"'"$TOKEN"'","notification":{"title":"FCM direct","body":"non-OneSignal push"}}}'
+```
+
+**Data-only message** — fires `onMessage` (foreground) / `onBackgroundMessage`
+(background/killed):
+
+```bash
+curl -X POST "https://fcm.googleapis.com/v1/projects/$PROJECT_ID/messages:send" \
+ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
+ -d '{"message":{"token":"'"$TOKEN"'","android":{"priority":"high"},"data":{"alert":"data only","source":"fcm-direct"}}}'
+```
+
+(Or use Firebase console → Messaging → "Send test message" and paste the token
+for a quick notification-message test.)
+
+### 3. Expected log lines for a direct FCM push
+
+| App state | Notification message | Data-only message |
+| --- | --- | --- |
+| Foreground | `[FCM fg] received` | `[FCM fg] received` |
+| Background | tray shown; tap → `[FCM open] tapped` | `[FCM bg] received` |
+| Killed | tray shown; tap → `[FCM initial] launched from tap` | `[FCM bg] received` |
+
+Note `onMessageOpenedApp` (`[FCM open]`) only fires for a **background** tap of
+a notification message; a **killed**-state tap surfaces via `getInitialMessage()`
+(`[FCM initial]`) instead.
diff --git a/examples/demo_fm/analysis_options.yaml b/examples/demo_fm/analysis_options.yaml
new file mode 100644
index 00000000..0d290213
--- /dev/null
+++ b/examples/demo_fm/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/examples/demo_fm/android/.gitignore b/examples/demo_fm/android/.gitignore
new file mode 100644
index 00000000..be3943c9
--- /dev/null
+++ b/examples/demo_fm/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/examples/demo_fm/android/app/build.gradle.kts b/examples/demo_fm/android/app/build.gradle.kts
new file mode 100644
index 00000000..ffefdd35
--- /dev/null
+++ b/examples/demo_fm/android/app/build.gradle.kts
@@ -0,0 +1,46 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+ // Issue #1138 reproduction: required by firebase_messaging.
+ id("com.google.gms.google-services")
+}
+
+android {
+ namespace = "com.onesignal.example"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.onesignal.example"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/examples/demo_fm/android/app/src/debug/AndroidManifest.xml b/examples/demo_fm/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 00000000..399f6981
--- /dev/null
+++ b/examples/demo_fm/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/examples/demo_fm/android/app/src/main/AndroidManifest.xml b/examples/demo_fm/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..dca75d49
--- /dev/null
+++ b/examples/demo_fm/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/demo_fm/android/app/src/main/kotlin/com/onesignal/example/MainActivity.kt b/examples/demo_fm/android/app/src/main/kotlin/com/onesignal/example/MainActivity.kt
new file mode 100644
index 00000000..df66b4f0
--- /dev/null
+++ b/examples/demo_fm/android/app/src/main/kotlin/com/onesignal/example/MainActivity.kt
@@ -0,0 +1,5 @@
+package com.onesignal.example
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/examples/demo_fm/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/examples/demo_fm/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..95de5cbf
Binary files /dev/null and b/examples/demo_fm/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ
diff --git a/examples/demo_fm/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/examples/demo_fm/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..639bc1f7
Binary files /dev/null and b/examples/demo_fm/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ
diff --git a/examples/demo_fm/android/app/src/main/res/drawable-v21/launch_background.xml b/examples/demo_fm/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 00000000..f74085f3
--- /dev/null
+++ b/examples/demo_fm/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/examples/demo_fm/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/examples/demo_fm/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..db57e205
Binary files /dev/null and b/examples/demo_fm/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ
diff --git a/examples/demo_fm/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/examples/demo_fm/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..0f9c2897
Binary files /dev/null and b/examples/demo_fm/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ
diff --git a/examples/demo_fm/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/examples/demo_fm/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 00000000..f278b388
Binary files /dev/null and b/examples/demo_fm/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/examples/demo_fm/android/app/src/main/res/drawable/launch_background.xml b/examples/demo_fm/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 00000000..304732f8
--- /dev/null
+++ b/examples/demo_fm/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/examples/demo_fm/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/examples/demo_fm/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..c79c58a3
--- /dev/null
+++ b/examples/demo_fm/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/examples/demo_fm/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/examples/demo_fm/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..88a040ba
Binary files /dev/null and b/examples/demo_fm/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/examples/demo_fm/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/examples/demo_fm/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..d2c144f1
Binary files /dev/null and b/examples/demo_fm/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/examples/demo_fm/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/examples/demo_fm/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..680b2f0b
Binary files /dev/null and b/examples/demo_fm/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/examples/demo_fm/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/examples/demo_fm/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..d241773d
Binary files /dev/null and b/examples/demo_fm/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/examples/demo_fm/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/examples/demo_fm/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..6062e282
Binary files /dev/null and b/examples/demo_fm/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/examples/demo_fm/android/app/src/main/res/values-night/styles.xml b/examples/demo_fm/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 00000000..06952be7
--- /dev/null
+++ b/examples/demo_fm/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/examples/demo_fm/android/app/src/main/res/values/colors.xml b/examples/demo_fm/android/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..f42ada65
--- /dev/null
+++ b/examples/demo_fm/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
diff --git a/examples/demo_fm/android/app/src/main/res/values/styles.xml b/examples/demo_fm/android/app/src/main/res/values/styles.xml
new file mode 100644
index 00000000..cb1ef880
--- /dev/null
+++ b/examples/demo_fm/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/examples/demo_fm/android/app/src/profile/AndroidManifest.xml b/examples/demo_fm/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 00000000..399f6981
--- /dev/null
+++ b/examples/demo_fm/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/examples/demo_fm/android/build.gradle.kts b/examples/demo_fm/android/build.gradle.kts
new file mode 100644
index 00000000..dbee657b
--- /dev/null
+++ b/examples/demo_fm/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/examples/demo_fm/android/gradle.properties b/examples/demo_fm/android/gradle.properties
new file mode 100644
index 00000000..fbee1d8c
--- /dev/null
+++ b/examples/demo_fm/android/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
diff --git a/examples/demo_fm/android/gradle/wrapper/gradle-wrapper.properties b/examples/demo_fm/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..e4ef43fb
--- /dev/null
+++ b/examples/demo_fm/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
diff --git a/examples/demo_fm/android/settings.gradle.kts b/examples/demo_fm/android/settings.gradle.kts
new file mode 100644
index 00000000..c6ae567e
--- /dev/null
+++ b/examples/demo_fm/android/settings.gradle.kts
@@ -0,0 +1,28 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.11.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+ // Issue #1138 reproduction: required by firebase_messaging.
+ id("com.google.gms.google-services") version "4.4.2" apply false
+}
+
+include(":app")
diff --git a/examples/demo_fm/assets/onesignal_logo.svg b/examples/demo_fm/assets/onesignal_logo.svg
new file mode 100644
index 00000000..8bb8d138
--- /dev/null
+++ b/examples/demo_fm/assets/onesignal_logo.svg
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/demo_fm/assets/onesignal_logo_icon_padded.png b/examples/demo_fm/assets/onesignal_logo_icon_padded.png
new file mode 100644
index 00000000..4ff809f4
Binary files /dev/null and b/examples/demo_fm/assets/onesignal_logo_icon_padded.png differ
diff --git a/examples/demo_fm/ios/.gitignore b/examples/demo_fm/ios/.gitignore
new file mode 100644
index 00000000..7a7f9873
--- /dev/null
+++ b/examples/demo_fm/ios/.gitignore
@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/examples/demo_fm/ios/ExportOptions.plist b/examples/demo_fm/ios/ExportOptions.plist
new file mode 100644
index 00000000..935873e2
--- /dev/null
+++ b/examples/demo_fm/ios/ExportOptions.plist
@@ -0,0 +1,23 @@
+
+
+
+
+ method
+ development
+ teamID
+ 99SW8E36CT
+ signingStyle
+ manual
+ stripSwiftSymbols
+
+ provisioningProfiles
+
+ com.onesignal.example
+ Appium Demo - Main
+ com.onesignal.example.NSE
+ Appium Demo - NSE
+ com.onesignal.example.LA
+ Appium Demo - Live Activity
+
+
+
diff --git a/examples/demo_fm/ios/Flutter/AppFrameworkInfo.plist b/examples/demo_fm/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 00000000..1dc6cf76
--- /dev/null
+++ b/examples/demo_fm/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+ MinimumOSVersion
+ 13.0
+
+
diff --git a/examples/demo_fm/ios/Flutter/Debug.xcconfig b/examples/demo_fm/ios/Flutter/Debug.xcconfig
new file mode 100644
index 00000000..592ceee8
--- /dev/null
+++ b/examples/demo_fm/ios/Flutter/Debug.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/examples/demo_fm/ios/Flutter/Release.xcconfig b/examples/demo_fm/ios/Flutter/Release.xcconfig
new file mode 100644
index 00000000..28f8fb7e
--- /dev/null
+++ b/examples/demo_fm/ios/Flutter/Release.xcconfig
@@ -0,0 +1,6 @@
+#include "Generated.xcconfig"
+
+// Skip dSYM emission in release builds. We don't ship to the App Store from
+// this app and don't need symbolicated crash reports, so DWARF-only saves
+// build time.
+DEBUG_INFORMATION_FORMAT = dwarf
diff --git a/examples/demo_fm/ios/OneSignalNotificationServiceExtension/Info.plist b/examples/demo_fm/ios/OneSignalNotificationServiceExtension/Info.plist
new file mode 100644
index 00000000..57421ebf
--- /dev/null
+++ b/examples/demo_fm/ios/OneSignalNotificationServiceExtension/Info.plist
@@ -0,0 +1,13 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.usernotifications.service
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).NotificationService
+
+
+
diff --git a/examples/demo_fm/ios/OneSignalNotificationServiceExtension/NotificationService.swift b/examples/demo_fm/ios/OneSignalNotificationServiceExtension/NotificationService.swift
new file mode 100644
index 00000000..ab391a33
--- /dev/null
+++ b/examples/demo_fm/ios/OneSignalNotificationServiceExtension/NotificationService.swift
@@ -0,0 +1,32 @@
+import UserNotifications
+import OneSignalExtension
+
+class NotificationService: UNNotificationServiceExtension {
+ var contentHandler: ((UNNotificationContent) -> Void)?
+ var receivedRequest: UNNotificationRequest!
+ var bestAttemptContent: UNMutableNotificationContent?
+
+ // Note this extension only runs when `mutable_content` is set
+ // Setting an attachment or action buttons automatically sets the property to true
+ override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
+ self.receivedRequest = request
+ self.contentHandler = contentHandler
+ self.bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
+
+ if let bestAttemptContent = bestAttemptContent {
+ // DEBUGGING: Uncomment the 2 lines below to check this extension is executing
+// print("Running NotificationServiceExtension")
+// bestAttemptContent.body = "[Modified] " + bestAttemptContent.body
+
+ OneSignalExtension.didReceiveNotificationExtensionRequest(self.receivedRequest, with: bestAttemptContent, withContentHandler: self.contentHandler)
+ }
+ }
+
+ override func serviceExtensionTimeWillExpire() {
+ // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
+ if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
+ OneSignalExtension.serviceExtensionTimeWillExpireRequest(self.receivedRequest, with: self.bestAttemptContent)
+ contentHandler(bestAttemptContent)
+ }
+ }
+}
diff --git a/examples/demo_fm/ios/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements b/examples/demo_fm/ios/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements
new file mode 100644
index 00000000..c70461e8
--- /dev/null
+++ b/examples/demo_fm/ios/OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.onesignal.example.onesignal
+
+
+
diff --git a/examples/demo_fm/ios/OneSignalWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/examples/demo_fm/ios/OneSignalWidget/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 00000000..eb878970
--- /dev/null
+++ b/examples/demo_fm/ios/OneSignalWidget/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/examples/demo_fm/ios/OneSignalWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/examples/demo_fm/ios/OneSignalWidget/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..23058801
--- /dev/null
+++ b/examples/demo_fm/ios/OneSignalWidget/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,35 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/examples/demo_fm/ios/OneSignalWidget/Assets.xcassets/Contents.json b/examples/demo_fm/ios/OneSignalWidget/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/examples/demo_fm/ios/OneSignalWidget/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/examples/demo_fm/ios/OneSignalWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/examples/demo_fm/ios/OneSignalWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json
new file mode 100644
index 00000000..eb878970
--- /dev/null
+++ b/examples/demo_fm/ios/OneSignalWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/examples/demo_fm/ios/OneSignalWidget/Info.plist b/examples/demo_fm/ios/OneSignalWidget/Info.plist
new file mode 100644
index 00000000..0f118fb7
--- /dev/null
+++ b/examples/demo_fm/ios/OneSignalWidget/Info.plist
@@ -0,0 +1,11 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/examples/demo_fm/ios/OneSignalWidget/OneSignalWidgetBundle.swift b/examples/demo_fm/ios/OneSignalWidget/OneSignalWidgetBundle.swift
new file mode 100644
index 00000000..1940bb22
--- /dev/null
+++ b/examples/demo_fm/ios/OneSignalWidget/OneSignalWidgetBundle.swift
@@ -0,0 +1,16 @@
+//
+// OneSignalWidgetBundle.swift
+// OneSignalWidgetExtension
+//
+// Created by Fadi George on 4/1/26.
+//
+
+import WidgetKit
+import SwiftUI
+
+@main
+struct OneSignalWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ OneSignalWidgetLiveActivity()
+ }
+}
diff --git a/examples/demo_fm/ios/OneSignalWidget/OneSignalWidgetLiveActivity.swift b/examples/demo_fm/ios/OneSignalWidget/OneSignalWidgetLiveActivity.swift
new file mode 100644
index 00000000..f4a95f75
--- /dev/null
+++ b/examples/demo_fm/ios/OneSignalWidget/OneSignalWidgetLiveActivity.swift
@@ -0,0 +1,142 @@
+import ActivityKit
+import WidgetKit
+import SwiftUI
+import OneSignalLiveActivities
+
+@available(iOS 16.2, *)
+struct OneSignalWidgetLiveActivity: Widget {
+
+ private func statusIcon(for status: String) -> String {
+ switch status {
+ case "on_the_way": return "box.truck.fill"
+ case "delivered": return "checkmark.circle.fill"
+ default: return "bag.fill"
+ }
+ }
+
+ private func statusColor(for status: String) -> Color {
+ switch status {
+ case "on_the_way": return .blue
+ case "delivered": return .green
+ default: return .orange
+ }
+ }
+
+ private func statusLabel(for status: String) -> String {
+ switch status {
+ case "on_the_way": return "On the Way"
+ case "delivered": return "Delivered"
+ default: return "Preparing"
+ }
+ }
+
+ var body: some WidgetConfiguration {
+ ActivityConfiguration(for: DefaultLiveActivityAttributes.self) { context in
+ let orderNumber = context.attributes.data["orderNumber"]?.asString() ?? "Order"
+ let status = context.state.data["status"]?.asString() ?? "preparing"
+ let message = context.state.data["message"]?.asString() ?? "Your order is being prepared"
+ let eta = context.state.data["estimatedTime"]?.asString() ?? ""
+
+ VStack(spacing: 10) {
+ HStack {
+ Text(orderNumber)
+ .font(.caption)
+ .foregroundColor(.gray)
+ Spacer()
+ if !eta.isEmpty {
+ Text(eta)
+ .font(.caption)
+ .foregroundColor(.white.opacity(0.7))
+ }
+ }
+
+ HStack(spacing: 12) {
+ Image(systemName: statusIcon(for: status))
+ .font(.title2)
+ .foregroundColor(statusColor(for: status))
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(statusLabel(for: status))
+ .font(.headline)
+ .foregroundColor(.white)
+ Text(message)
+ .font(.subheadline)
+ .foregroundColor(.white.opacity(0.8))
+ .lineLimit(1)
+ }
+ Spacer()
+ }
+
+ DeliveryProgressBar(status: status)
+ }
+ .padding()
+ .activityBackgroundTint(Color(red: 0.11, green: 0.13, blue: 0.19))
+ .activitySystemActionForegroundColor(.white)
+
+ } dynamicIsland: { context in
+ let status = context.state.data["status"]?.asString() ?? "preparing"
+ let message = context.state.data["message"]?.asString() ?? "Preparing"
+ let eta = context.state.data["estimatedTime"]?.asString() ?? ""
+
+ return DynamicIsland {
+ DynamicIslandExpandedRegion(.leading) {
+ Image(systemName: statusIcon(for: status))
+ .font(.title2)
+ .foregroundColor(statusColor(for: status))
+ }
+ DynamicIslandExpandedRegion(.center) {
+ Text(statusLabel(for: status))
+ .font(.headline)
+ }
+ DynamicIslandExpandedRegion(.trailing) {
+ if !eta.isEmpty {
+ Text(eta)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ DynamicIslandExpandedRegion(.bottom) {
+ Text(message)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ } compactLeading: {
+ Image(systemName: statusIcon(for: status))
+ .foregroundColor(statusColor(for: status))
+ } compactTrailing: {
+ Text(statusLabel(for: status))
+ .font(.caption)
+ } minimal: {
+ Image(systemName: statusIcon(for: status))
+ .foregroundColor(statusColor(for: status))
+ }
+ }
+ }
+}
+
+@available(iOS 16.2, *)
+struct DeliveryProgressBar: View {
+ let status: String
+
+ private var progress: CGFloat {
+ switch status {
+ case "on_the_way": return 0.6
+ case "delivered": return 1.0
+ default: return 0.25
+ }
+ }
+
+ var body: some View {
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 3)
+ .fill(Color.white.opacity(0.2))
+ .frame(height: 6)
+ RoundedRectangle(cornerRadius: 3)
+ .fill(progress >= 1.0 ? Color.green : Color.blue)
+ .frame(width: geo.size.width * progress, height: 6)
+ }
+ }
+ .frame(height: 6)
+ }
+}
diff --git a/examples/demo_fm/ios/Runner.xcodeproj/project.pbxproj b/examples/demo_fm/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..188c304c
--- /dev/null
+++ b/examples/demo_fm/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,1116 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ FAD0113800000000CAFEBABE /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = FAD0113800000000DEADBEEF /* GoogleService-Info.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+ E8C8D1902F7B0DB7006581CB /* OneSignalNotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E8C8D1892F7B0DB7006581CB /* OneSignalNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ E8C8D1B32F7D9550006581CB /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E8C8D1B22F7D9550006581CB /* WidgetKit.framework */; };
+ E8C8D1B52F7D9550006581CB /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E8C8D1B42F7D9550006581CB /* SwiftUI.framework */; };
+ E8C8D1C22F7D9551006581CB /* OneSignalWidgetExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E8C8D1B02F7D9550006581CB /* OneSignalWidgetExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+ E8C8D18E2F7B0DB7006581CB /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = E8C8D1882F7B0DB7006581CB;
+ remoteInfo = OneSignalNotificationServiceExtension;
+ };
+ E8C8D1C02F7D9551006581CB /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = E8C8D1AF2F7D9550006581CB;
+ remoteInfo = OneSignalWidgetExtensionExtension;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E8C8D1962F7B0DB7006581CB /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ E8C8D1902F7B0DB7006581CB /* OneSignalNotificationServiceExtension.appex in Embed Foundation Extensions */,
+ E8C8D1C22F7D9551006581CB /* OneSignalWidgetExtensionExtension.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ FAD0113800000000DEADBEEF /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ E8C8D1892F7B0DB7006581CB /* OneSignalNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OneSignalNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ E8C8D1B02F7D9550006581CB /* OneSignalWidgetExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OneSignalWidgetExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ E8C8D1B22F7D9550006581CB /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
+ E8C8D1B42F7D9550006581CB /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ E8C8D1912F7B0DB7006581CB /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = E8C8D1882F7B0DB7006581CB /* OneSignalNotificationServiceExtension */;
+ };
+ E8C8D1C62F7D9551006581CB /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = E8C8D1AF2F7D9550006581CB /* OneSignalWidgetExtensionExtension */;
+ };
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ E8C8D18A2F7B0DB7006581CB /* OneSignalNotificationServiceExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E8C8D1912F7B0DB7006581CB /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = OneSignalNotificationServiceExtension; sourceTree = ""; };
+ E8C8D1B62F7D9550006581CB /* OneSignalWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E8C8D1C62F7D9551006581CB /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = OneSignalWidget; sourceTree = ""; };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E8C8D1862F7B0DB7006581CB /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E8C8D1AD2F7D9550006581CB /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ E8C8D1B52F7D9550006581CB /* SwiftUI.framework in Frameworks */,
+ E8C8D1B32F7D9550006581CB /* WidgetKit.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ E8C8D18A2F7B0DB7006581CB /* OneSignalNotificationServiceExtension */,
+ E8C8D1B62F7D9550006581CB /* OneSignalWidget */,
+ E8C8D1B12F7D9550006581CB /* Frameworks */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ E8C8D1892F7B0DB7006581CB /* OneSignalNotificationServiceExtension.appex */,
+ E8C8D1B02F7D9550006581CB /* OneSignalWidgetExtensionExtension.appex */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ FAD0113800000000DEADBEEF /* GoogleService-Info.plist */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+ E8C8D1B12F7D9550006581CB /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ E8C8D1B22F7D9550006581CB /* WidgetKit.framework */,
+ E8C8D1B42F7D9550006581CB /* SwiftUI.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ E8C8D1962F7B0DB7006581CB /* Embed Foundation Extensions */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ E8C8D18F2F7B0DB7006581CB /* PBXTargetDependency */,
+ E8C8D1C12F7D9551006581CB /* PBXTargetDependency */,
+ );
+ name = Runner;
+ packageProductDependencies = (
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
+ );
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+ E8C8D1882F7B0DB7006581CB /* OneSignalNotificationServiceExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = E8C8D1922F7B0DB7006581CB /* Build configuration list for PBXNativeTarget "OneSignalNotificationServiceExtension" */;
+ buildPhases = (
+ E8C8D1852F7B0DB7006581CB /* Sources */,
+ E8C8D1862F7B0DB7006581CB /* Frameworks */,
+ E8C8D1872F7B0DB7006581CB /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ E8C8D18A2F7B0DB7006581CB /* OneSignalNotificationServiceExtension */,
+ );
+ name = OneSignalNotificationServiceExtension;
+ packageProductDependencies = (
+ );
+ productName = OneSignalNotificationServiceExtension;
+ productReference = E8C8D1892F7B0DB7006581CB /* OneSignalNotificationServiceExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+ E8C8D1AF2F7D9550006581CB /* OneSignalWidgetExtensionExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = E8C8D1C72F7D9551006581CB /* Build configuration list for PBXNativeTarget "OneSignalWidgetExtensionExtension" */;
+ buildPhases = (
+ E8C8D1AC2F7D9550006581CB /* Sources */,
+ E8C8D1AD2F7D9550006581CB /* Frameworks */,
+ E8C8D1AE2F7D9550006581CB /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ E8C8D1B62F7D9550006581CB /* OneSignalWidget */,
+ );
+ name = OneSignalWidgetExtensionExtension;
+ packageProductDependencies = (
+ );
+ productName = OneSignalWidgetExtensionExtension;
+ productReference = E8C8D1B02F7D9550006581CB /* OneSignalWidgetExtensionExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastSwiftUpdateCheck = 2620;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C8080294A63A400263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ E8C8D1882F7B0DB7006581CB = {
+ CreatedOnToolsVersion = 26.2;
+ };
+ E8C8D1AF2F7D9550006581CB = {
+ CreatedOnToolsVersion = 26.2;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ packageReferences = (
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
+ );
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C8080294A63A400263BE5 /* RunnerTests */,
+ E8C8D1882F7B0DB7006581CB /* OneSignalNotificationServiceExtension */,
+ E8C8D1AF2F7D9550006581CB /* OneSignalWidgetExtensionExtension */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C807F294A63A400263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ FAD0113800000000CAFEBABE /* GoogleService-Info.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E8C8D1872F7B0DB7006581CB /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E8C8D1AE2F7D9550006581CB /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C807D294A63A400263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E8C8D1852F7B0DB7006581CB /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ E8C8D1AC2F7D9550006581CB /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+ };
+ E8C8D18F2F7B0DB7006581CB /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = E8C8D1882F7B0DB7006581CB /* OneSignalNotificationServiceExtension */;
+ targetProxy = E8C8D18E2F7B0DB7006581CB /* PBXContainerItemProxy */;
+ };
+ E8C8D1C12F7D9551006581CB /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = E8C8D1AF2F7D9550006581CB /* OneSignalWidgetExtensionExtension */;
+ targetProxy = E8C8D1C02F7D9551006581CB /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = 99SW8E36CT;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.onesignal.example;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.onesignal.example.demo.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C8089294A63A400263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.onesignal.example.demo.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C808A294A63A400263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.onesignal.example.demo.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = 99SW8E36CT;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.onesignal.example;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = 99SW8E36CT;
+ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 99SW8E36CT;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.onesignal.example;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "Appium Demo - Main";
+ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Appium Demo - Main";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+ E8C8D1932F7B0DB7006581CB /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 99SW8E36CT;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = OneSignalNotificationServiceExtension/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = OneSignalNotificationServiceExtension;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 1.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ PRODUCT_BUNDLE_IDENTIFIER = com.onesignal.example.NSE;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ E8C8D1942F7B0DB7006581CB /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 99SW8E36CT;
+ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 99SW8E36CT;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = OneSignalNotificationServiceExtension/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = OneSignalNotificationServiceExtension;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 1.0;
+ MTL_FAST_MATH = YES;
+ PRODUCT_BUNDLE_IDENTIFIER = com.onesignal.example.NSE;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "Appium Demo - NSE";
+ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Appium Demo - NSE";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ E8C8D1952F7B0DB7006581CB /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 99SW8E36CT;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = OneSignalNotificationServiceExtension/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = OneSignalNotificationServiceExtension;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 1.0;
+ MTL_FAST_MATH = YES;
+ PRODUCT_BUNDLE_IDENTIFIER = com.onesignal.example.NSE;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Profile;
+ };
+ E8C8D1C32F7D9551006581CB /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 99SW8E36CT;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = OneSignalWidget/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = OneSignalWidget;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 1.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ PRODUCT_BUNDLE_IDENTIFIER = com.onesignal.example.LA;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ E8C8D1C42F7D9551006581CB /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 99SW8E36CT;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = OneSignalWidget/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = OneSignalWidget;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 1.0;
+ MTL_FAST_MATH = YES;
+ PRODUCT_BUNDLE_IDENTIFIER = com.onesignal.example.LA;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ PROVISIONING_PROFILE_SPECIFIER = "Appium Demo - Live Activity";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ E8C8D1C52F7D9551006581CB /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 99SW8E36CT;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = OneSignalWidget/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = OneSignalWidget;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MARKETING_VERSION = 1.0;
+ MTL_FAST_MATH = YES;
+ PRODUCT_BUNDLE_IDENTIFIER = com.onesignal.example.LA;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Profile;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C8088294A63A400263BE5 /* Debug */,
+ 331C8089294A63A400263BE5 /* Release */,
+ 331C808A294A63A400263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ E8C8D1922F7B0DB7006581CB /* Build configuration list for PBXNativeTarget "OneSignalNotificationServiceExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ E8C8D1932F7B0DB7006581CB /* Debug */,
+ E8C8D1942F7B0DB7006581CB /* Release */,
+ E8C8D1952F7B0DB7006581CB /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ E8C8D1C72F7D9551006581CB /* Build configuration list for PBXNativeTarget "OneSignalWidgetExtensionExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ E8C8D1C32F7D9551006581CB /* Debug */,
+ E8C8D1C42F7D9551006581CB /* Release */,
+ E8C8D1C52F7D9551006581CB /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/examples/demo_fm/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/examples/demo_fm/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..919434a6
--- /dev/null
+++ b/examples/demo_fm/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/examples/demo_fm/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/examples/demo_fm/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 00000000..18d98100
--- /dev/null
+++ b/examples/demo_fm/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/examples/demo_fm/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/examples/demo_fm/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 00000000..f9b0d7c5
--- /dev/null
+++ b/examples/demo_fm/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/examples/demo_fm/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/examples/demo_fm/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 00000000..c3fedb29
--- /dev/null
+++ b/examples/demo_fm/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/demo_fm/ios/Runner.xcworkspace/contents.xcworkspacedata b/examples/demo_fm/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..1d526a16
--- /dev/null
+++ b/examples/demo_fm/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/examples/demo_fm/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/examples/demo_fm/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 00000000..18d98100
--- /dev/null
+++ b/examples/demo_fm/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/examples/demo_fm/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/examples/demo_fm/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 00000000..f9b0d7c5
--- /dev/null
+++ b/examples/demo_fm/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/examples/demo_fm/ios/Runner/AppDelegate.swift b/examples/demo_fm/ios/Runner/AppDelegate.swift
new file mode 100644
index 00000000..62666446
--- /dev/null
+++ b/examples/demo_fm/ios/Runner/AppDelegate.swift
@@ -0,0 +1,13 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ GeneratedPluginRegistrant.register(with: self)
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+}
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..d0d98aa1
--- /dev/null
+++ b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1 @@
+{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
\ No newline at end of file
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 00000000..d4295e38
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 00000000..97658bfe
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 00000000..b1bf42f9
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 00000000..76b737e0
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 00000000..b8ac6697
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 00000000..d3de94d2
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 00000000..f0559d2b
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 00000000..b1bf42f9
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 00000000..59124a76
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 00000000..dd146ada
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
new file mode 100644
index 00000000..388750c5
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
new file mode 100644
index 00000000..5ede07ab
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
new file mode 100644
index 00000000..9edc8622
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
new file mode 100644
index 00000000..7a6e7e45
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 00000000..dd146ada
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 00000000..8e943138
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
new file mode 100644
index 00000000..7da0bc3d
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
new file mode 100644
index 00000000..be7bd5a1
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 00000000..96ba43c2
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 00000000..9dd61861
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 00000000..2947572c
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 00000000..0bedcf2f
--- /dev/null
+++ b/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 00000000..9da19eac
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 00000000..9da19eac
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 00000000..9da19eac
Binary files /dev/null and b/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 00000000..89c2725b
--- /dev/null
+++ b/examples/demo_fm/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/examples/demo_fm/ios/Runner/Base.lproj/LaunchScreen.storyboard b/examples/demo_fm/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 00000000..f2e259c7
--- /dev/null
+++ b/examples/demo_fm/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/demo_fm/ios/Runner/Base.lproj/Main.storyboard b/examples/demo_fm/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 00000000..f3c28516
--- /dev/null
+++ b/examples/demo_fm/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/demo_fm/ios/Runner/Info.plist b/examples/demo_fm/ios/Runner/Info.plist
new file mode 100644
index 00000000..fd8aee7d
--- /dev/null
+++ b/examples/demo_fm/ios/Runner/Info.plist
@@ -0,0 +1,59 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ OneSignal Demo
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ demo
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ NSSupportsLiveActivities
+
+ NSLocationWhenInUseUsageDescription
+ This app uses your location to personalize notifications and content.
+ NSLocationAlwaysAndWhenInUseUsageDescription
+ This app uses your location to personalize notifications and content, even in the background.
+ UIApplicationSupportsIndirectInputEvents
+
+ UIBackgroundModes
+
+ remote-notification
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/examples/demo_fm/ios/Runner/Runner-Bridging-Header.h b/examples/demo_fm/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 00000000..308a2a56
--- /dev/null
+++ b/examples/demo_fm/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/examples/demo_fm/ios/Runner/Runner.entitlements b/examples/demo_fm/ios/Runner/Runner.entitlements
new file mode 100644
index 00000000..34463649
--- /dev/null
+++ b/examples/demo_fm/ios/Runner/Runner.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ aps-environment
+ development
+ com.apple.security.application-groups
+
+ group.com.onesignal.example.onesignal
+
+
+
diff --git a/examples/demo_fm/ios/RunnerTests/RunnerTests.swift b/examples/demo_fm/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 00000000..86a7c3b1
--- /dev/null
+++ b/examples/demo_fm/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/examples/demo_fm/lib/main.dart b/examples/demo_fm/lib/main.dart
new file mode 100644
index 00000000..6e08b534
--- /dev/null
+++ b/examples/demo_fm/lib/main.dart
@@ -0,0 +1,164 @@
+import 'package:firebase_core/firebase_core.dart';
+import 'package:firebase_messaging/firebase_messaging.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_dotenv/flutter_dotenv.dart';
+import 'package:onesignal_flutter/onesignal_flutter.dart';
+import 'package:provider/provider.dart';
+
+import 'screens/home_screen.dart';
+import 'services/onesignal_api_service.dart';
+import 'services/preferences_service.dart';
+import 'services/tooltip_helper.dart';
+import 'theme.dart';
+import 'viewmodels/app_viewmodel.dart';
+
+const String _defaultAppId = '77e32082-ea27-42e3-a898-c72e141824ef';
+
+// Issue #1138 reproduction: top-level background handler required by FCM.
+// On Android, registering ANY FirebaseMessagingService is enough to cause
+// FCM (and FlutterFire) to intercept incoming push messages, which is what
+// the affected users in #1138 reported alongside the OneSignal click
+// listener failure.
+@pragma('vm:entry-point')
+Future _firebaseBackgroundHandler(RemoteMessage message) async {
+ await Firebase.initializeApp();
+ debugPrint('[FCM bg] received: ${message.messageId} data=${message.data}');
+}
+
+Future main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+
+ try {
+ await dotenv.load(fileName: '.env');
+ } catch (_) {
+ debugPrint('.env file not found, using defaults');
+ }
+
+ // Initialize Firebase + register FCM listeners BEFORE OneSignal so the
+ // FirebaseMessagingService is registered in the manifest and starts
+ // intercepting push payloads (matches the affected users' setup).
+ try {
+ await Firebase.initializeApp();
+ FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
+ FirebaseMessaging.onMessage.listen((RemoteMessage message) {
+ debugPrint('[FCM fg] received: ${message.messageId} data=${message.data}');
+ });
+ FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
+ debugPrint('[FCM open] tapped: ${message.messageId} data=${message.data}');
+ });
+
+ // Log the device's FCM registration token so we can target it when
+ // sending non-OneSignal pushes directly through the FCM HTTP v1 API /
+ // Firebase console (see examples/demo_fm/README.md).
+ final fcmToken = await FirebaseMessaging.instance.getToken();
+ debugPrint('[FCM token] $fcmToken');
+
+ // A direct FCM "notification" message tapped from the KILLED state does
+ // NOT fire onMessageOpenedApp — it's delivered here as the initial
+ // message instead. Without this, the cold-start direct-FCM tap is
+ // invisible in the logs.
+ final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
+ if (initialMessage != null) {
+ debugPrint(
+ '[FCM initial] launched from tap: ${initialMessage.messageId} '
+ 'data=${initialMessage.data}',
+ );
+ }
+ debugPrint('Firebase initialized (issue #1138 repro)');
+ } catch (e) {
+ debugPrint('Firebase init failed (drop google-services.json into android/app): $e');
+ }
+
+ final prefs = PreferencesService();
+ await prefs.init();
+
+ final envAppId = dotenv.env['ONESIGNAL_APP_ID'];
+ final appId = (envAppId != null && envAppId.isNotEmpty) ? envAppId : _defaultAppId;
+
+ // Initialize OneSignal SDK
+ OneSignal.Debug.setLogLevel(OSLogLevel.verbose);
+ OneSignal.consentRequired(prefs.consentRequired);
+ OneSignal.consentGiven(prefs.privacyConsent);
+ await OneSignal.initialize(appId);
+
+ OneSignal.LiveActivities.setupDefault(
+ options: LiveActivitySetupOptions(
+ enablePushToStart: true,
+ enablePushToUpdate: true,
+ ),
+ );
+
+ // Restore cached SDK states after init fully completes
+ OneSignal.InAppMessages.paused(prefs.iamPaused);
+ OneSignal.Location.setShared(prefs.locationShared);
+
+ // Register IAM listeners
+ OneSignal.InAppMessages.addWillDisplayListener((event) {
+ debugPrint('IAM will display: ${event.message.messageId}');
+ });
+ OneSignal.InAppMessages.addDidDisplayListener((event) {
+ debugPrint('IAM did display: ${event.message.messageId}');
+ });
+ OneSignal.InAppMessages.addWillDismissListener((event) {
+ debugPrint('IAM will dismiss: ${event.message.messageId}');
+ });
+ OneSignal.InAppMessages.addDidDismissListener((event) {
+ debugPrint('IAM did dismiss: ${event.message.messageId}');
+ });
+ OneSignal.InAppMessages.addClickListener((event) {
+ debugPrint('IAM clicked: ${event.message.messageId}');
+ });
+
+ // Register notification listeners
+ OneSignal.Notifications.addClickListener((event) {
+ debugPrint('Notification clicked: ${event.notification.title}');
+ });
+ OneSignal.Notifications.addForegroundWillDisplayListener((event) {
+ debugPrint(
+ 'Notification foreground will display: ${event.notification.title}',
+ );
+ event.notification.display();
+ });
+
+ // Set up API service
+ String apiKey = '';
+ try {
+ apiKey = dotenv.env['ONESIGNAL_API_KEY'] ?? '';
+ } catch (_) {
+ debugPrint('API key not found, continuing without it');
+ }
+ final apiService = OneSignalApiService()
+ ..setAppId(appId)
+ ..setApiKey(apiKey);
+
+ // Fetch tooltips in background
+ TooltipHelper().init();
+
+ debugPrint('OneSignal initialized with app ID: $appId');
+
+ runApp(
+ ChangeNotifierProvider(
+ create: (_) {
+ final vm = AppViewModel(apiService, prefs);
+ vm.setupObservers();
+ vm.loadInitialState(appId);
+ return vm;
+ },
+ child: const OneSignalDemoApp(),
+ ),
+ );
+}
+
+class OneSignalDemoApp extends StatelessWidget {
+ const OneSignalDemoApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'OneSignal Demo',
+ theme: AppTheme.light,
+ debugShowCheckedModeBanner: false,
+ home: const HomeScreen(),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/models/in_app_message_type.dart b/examples/demo_fm/lib/models/in_app_message_type.dart
new file mode 100644
index 00000000..983acddb
--- /dev/null
+++ b/examples/demo_fm/lib/models/in_app_message_type.dart
@@ -0,0 +1,11 @@
+enum InAppMessageType {
+ topBanner('Top Banner', 'top_banner'),
+ bottomBanner('Bottom Banner', 'bottom_banner'),
+ centerModal('Center Modal', 'center_modal'),
+ fullScreen('Full Screen', 'full_screen');
+
+ final String label;
+ final String triggerValue;
+
+ const InAppMessageType(this.label, this.triggerValue);
+}
diff --git a/examples/demo_fm/lib/models/notification_type.dart b/examples/demo_fm/lib/models/notification_type.dart
new file mode 100644
index 00000000..ddaf3ac3
--- /dev/null
+++ b/examples/demo_fm/lib/models/notification_type.dart
@@ -0,0 +1,38 @@
+enum NotificationType {
+ simple(
+ title: 'Simple Notification',
+ body: 'This is a simple push notification',
+ ),
+ withImage(
+ title: 'Image Notification',
+ body: 'This notification includes an image',
+ bigPicture:
+ 'https://media.onesignal.com/automated_push_templates/ratings_template.png',
+ iosAttachments: {
+ 'image':
+ 'https://media.onesignal.com/automated_push_templates/ratings_template.png'
+ },
+ ),
+ withSound(
+ title: 'Sound Notification',
+ body: 'This notification plays a custom sound',
+ iosSound: 'vine_boom.wav',
+ useAndroidChannel: true,
+ );
+
+ final String title;
+ final String body;
+ final String? bigPicture;
+ final Map? iosAttachments;
+ final String? iosSound;
+ final bool useAndroidChannel;
+
+ const NotificationType({
+ required this.title,
+ required this.body,
+ this.bigPicture,
+ this.iosAttachments,
+ this.iosSound,
+ this.useAndroidChannel = false,
+ });
+}
diff --git a/examples/demo_fm/lib/models/user_data.dart b/examples/demo_fm/lib/models/user_data.dart
new file mode 100644
index 00000000..b03b46d2
--- /dev/null
+++ b/examples/demo_fm/lib/models/user_data.dart
@@ -0,0 +1,56 @@
+class UserData {
+ final Map aliases;
+ final Map tags;
+ final List emails;
+ final List smsNumbers;
+ final String? externalId;
+
+ const UserData({
+ required this.aliases,
+ required this.tags,
+ required this.emails,
+ required this.smsNumbers,
+ this.externalId,
+ });
+
+ factory UserData.fromJson(Map json) {
+ final identity = json['identity'] as Map? ?? {};
+ final properties = json['properties'] as Map? ?? {};
+ final subscriptions = json['subscriptions'] as List? ?? [];
+ final tagsRaw = properties['tags'] as Map? ?? {};
+
+ final aliases = {};
+ for (final entry in identity.entries) {
+ if (entry.key != 'external_id' && entry.key != 'onesignal_id') {
+ aliases[entry.key] = entry.value.toString();
+ }
+ }
+
+ final tags = {};
+ for (final entry in tagsRaw.entries) {
+ tags[entry.key] = entry.value.toString();
+ }
+
+ final emails = [];
+ final smsNumbers = [];
+ for (final sub in subscriptions) {
+ if (sub is Map) {
+ final type = sub['type'] as String?;
+ final token = sub['token'] as String?;
+ if (type == 'Email' && token != null) {
+ emails.add(token);
+ } else if (type == 'SMS' && token != null) {
+ smsNumbers.add(token);
+ }
+ }
+ }
+
+ return UserData(
+ aliases: aliases,
+ tags: tags,
+ emails: emails,
+ smsNumbers: smsNumbers,
+ externalId: identity['external_id']?.toString(),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/screens/home_screen.dart b/examples/demo_fm/lib/screens/home_screen.dart
new file mode 100644
index 00000000..20e8894d
--- /dev/null
+++ b/examples/demo_fm/lib/screens/home_screen.dart
@@ -0,0 +1,145 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:provider/provider.dart';
+
+import '../theme.dart';
+import '../services/tooltip_helper.dart';
+import '../viewmodels/app_viewmodel.dart';
+import '../widgets/dialogs.dart';
+import '../widgets/sections/aliases_section.dart';
+import '../widgets/sections/app_section.dart';
+import '../widgets/sections/user_section.dart';
+import '../widgets/sections/emails_section.dart';
+import '../widgets/sections/in_app_section.dart';
+import '../widgets/sections/live_activities_section.dart';
+import '../widgets/sections/location_section.dart';
+import '../widgets/sections/outcomes_section.dart';
+import '../widgets/sections/push_section.dart';
+import '../widgets/sections/send_iam_section.dart';
+import '../widgets/sections/send_push_section.dart';
+import '../widgets/sections/sms_section.dart';
+import '../widgets/sections/tags_section.dart';
+import '../widgets/sections/custom_events_section.dart';
+import '../widgets/sections/triggers_section.dart';
+import 'secondary_screen.dart';
+
+class HomeScreen extends StatefulWidget {
+ const HomeScreen({super.key});
+
+ @override
+ State createState() => _HomeScreenState();
+}
+
+class _HomeScreenState extends State {
+ @override
+ void initState() {
+ super.initState();
+ // Auto-prompt push permission after frame renders
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ context.read().promptPush();
+ });
+ }
+
+ void _showTooltipDialog(BuildContext context, String key) {
+ final tooltip = TooltipHelper().getTooltip(key);
+ if (tooltip != null) {
+ showDialog(
+ context: context,
+ builder: (_) => TooltipDialog(tooltip: tooltip),
+ );
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ centerTitle: true,
+ title: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SvgPicture.asset(
+ 'assets/onesignal_logo.svg',
+ height: 22,
+ colorFilter: const ColorFilter.mode(
+ Colors.white,
+ BlendMode.srcIn,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ 'Flutter',
+ style: Theme.of(
+ context,
+ ).textTheme.bodyMedium?.copyWith(color: Colors.white),
+ ),
+ ],
+ ),
+ ),
+ body: Semantics(
+ identifier: 'main_scroll_view',
+ child: ListView(
+ padding: const EdgeInsets.only(bottom: 24),
+ children: [
+ const AppSection(),
+ const UserSection(),
+ PushSection(onInfoTap: () => _showTooltipDialog(context, 'push')),
+ SendPushSection(
+ onInfoTap:
+ () => _showTooltipDialog(context, 'sendPushNotification'),
+ ),
+ InAppSection(
+ onInfoTap: () => _showTooltipDialog(context, 'inAppMessaging'),
+ ),
+ SendIamSection(
+ onInfoTap: () => _showTooltipDialog(context, 'sendInAppMessage'),
+ ),
+ AliasesSection(
+ onInfoTap: () => _showTooltipDialog(context, 'aliases'),
+ ),
+ EmailsSection(
+ onInfoTap: () => _showTooltipDialog(context, 'emails'),
+ ),
+ SmsSection(onInfoTap: () => _showTooltipDialog(context, 'sms')),
+ TagsSection(onInfoTap: () => _showTooltipDialog(context, 'tags')),
+ OutcomesSection(
+ onInfoTap: () => _showTooltipDialog(context, 'outcomes'),
+ ),
+ TriggersSection(
+ onInfoTap: () => _showTooltipDialog(context, 'triggers'),
+ ),
+ CustomEventsSection(
+ onInfoTap: () => _showTooltipDialog(context, 'customEvents'),
+ ),
+ LocationSection(
+ onInfoTap: () => _showTooltipDialog(context, 'location'),
+ ),
+ if (defaultTargetPlatform == TargetPlatform.iOS)
+ LiveActivitiesSection(
+ onInfoTap: () => _showTooltipDialog(context, 'liveActivities'),
+ ),
+ const SizedBox(height: 8),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: ElevatedButton(
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(builder: (_) => const SecondaryScreen()),
+ );
+ },
+ style: ElevatedButton.styleFrom(
+ backgroundColor: AppColors.osPrimary,
+ foregroundColor: Colors.white,
+ ),
+ child: const Text('NEXT SCREEN'),
+ ),
+ ),
+ const SizedBox(height: 16),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/screens/secondary_screen.dart b/examples/demo_fm/lib/screens/secondary_screen.dart
new file mode 100644
index 00000000..d6232e1f
--- /dev/null
+++ b/examples/demo_fm/lib/screens/secondary_screen.dart
@@ -0,0 +1,21 @@
+import 'package:flutter/material.dart';
+
+class SecondaryScreen extends StatelessWidget {
+ const SecondaryScreen({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Secondary Screen'),
+ centerTitle: true,
+ ),
+ body: Center(
+ child: Text(
+ 'Secondary Screen',
+ style: Theme.of(context).textTheme.headlineMedium,
+ ),
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/services/onesignal_api_service.dart b/examples/demo_fm/lib/services/onesignal_api_service.dart
new file mode 100644
index 00000000..7fe8ea46
--- /dev/null
+++ b/examples/demo_fm/lib/services/onesignal_api_service.dart
@@ -0,0 +1,218 @@
+import 'dart:convert';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter_dotenv/flutter_dotenv.dart';
+import 'package:http/http.dart' as http;
+
+import '../models/notification_type.dart';
+import '../models/user_data.dart';
+
+const String _defaultAndroidChannelId = 'b3b015d9-c050-4042-8548-dcc34aa44aa4';
+
+String _resolveAndroidChannelId() {
+ String? envValue;
+ try {
+ envValue = dotenv.env['ONESIGNAL_ANDROID_CHANNEL_ID']?.trim();
+ } catch (_) {
+ envValue = null;
+ }
+ return (envValue != null && envValue.isNotEmpty)
+ ? envValue
+ : _defaultAndroidChannelId;
+}
+
+class OneSignalApiService {
+ String _appId = '';
+ String _apiKey = '';
+
+ void setAppId(String appId) => _appId = appId;
+ String get appId => _appId;
+
+ void setApiKey(String apiKey) => _apiKey = apiKey;
+ bool hasApiKey() => _apiKey.isNotEmpty && _apiKey != 'your_rest_api_key';
+
+ Future sendNotification(
+ NotificationType type,
+ String subscriptionId,
+ ) async {
+ final body = {
+ 'app_id': _appId,
+ 'include_subscription_ids': [subscriptionId],
+ 'headings': {'en': type.title},
+ 'contents': {'en': type.body},
+ };
+ if (type.bigPicture != null) {
+ body['big_picture'] = type.bigPicture;
+ }
+ if (type.iosAttachments != null) {
+ body['ios_attachments'] = type.iosAttachments;
+ }
+ if (type.iosSound != null) {
+ body['ios_sound'] = type.iosSound;
+ }
+ if (type.useAndroidChannel) {
+ body['android_channel_id'] = _resolveAndroidChannelId();
+ }
+
+ return _postNotification(body);
+ }
+
+ Future sendCustomNotification(
+ String title,
+ String body,
+ String subscriptionId,
+ ) async {
+ final payload = {
+ 'app_id': _appId,
+ 'include_subscription_ids': [subscriptionId],
+ 'headings': {'en': title},
+ 'contents': {'en': body},
+ };
+
+ return _postNotification(payload);
+ }
+
+ Future _postNotification(Map payload) async {
+ const maxAttempts = 5;
+ int backoffMs(int n) => 2000 * (1 << (n - 1));
+
+ // Retry while the OneSignal backend hasn't yet indexed the freshly
+ // created subscription. The /notifications endpoint reports this race in a
+ // few different shapes, all of which return HTTP 200:
+ // - {"id":"...","recipients":0} (user just switched, push token not yet attached)
+ // - {"id":"...","errors":{"invalid_player_ids":[...]}}
+ // - {"id":"","errors":["All included players are not subscribed"]}
+ // - {"id":"","errors":[...]}
+ // Treat any 200 response with no real id, populated errors, or recipients=0 as transient.
+ for (var attempt = 1; attempt <= maxAttempts; attempt++) {
+ try {
+ final response = await http.post(
+ Uri.parse('https://onesignal.com/api/v1/notifications'),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/vnd.onesignal.v1+json',
+ },
+ body: jsonEncode(payload),
+ );
+
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ debugPrint('Send notification failed: ${response.body}');
+ return false;
+ }
+
+ final decoded = jsonDecode(response.body);
+ if (_isTransientSendFailure(decoded)) {
+ if (attempt < maxAttempts) {
+ await Future.delayed(Duration(milliseconds: backoffMs(attempt)));
+ continue;
+ }
+ debugPrint('Send notification failed: ${response.body}');
+ return false;
+ }
+
+ return true;
+ } catch (e) {
+ debugPrint('Send notification error: $e');
+ return false;
+ }
+ }
+
+ return false;
+ }
+
+ bool _isTransientSendFailure(dynamic decoded) {
+ if (decoded is! Map) return false;
+ final id = decoded['id'];
+ final errors = decoded['errors'];
+ final recipients = decoded['recipients'];
+ final hasErrors =
+ (errors is List && errors.isNotEmpty) ||
+ (errors is Map && errors.isNotEmpty);
+ final missingId = id is! String || id.isEmpty;
+ final zeroRecipients = recipients is num && recipients == 0;
+ return hasErrors || missingId || zeroRecipients;
+ }
+
+ Future updateLiveActivity(
+ String activityId,
+ Map eventUpdates,
+ ) async {
+ try {
+ final response = await http.post(
+ Uri.parse(
+ 'https://api.onesignal.com/apps/$_appId/live_activities/$activityId/notifications',
+ ),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Key $_apiKey',
+ },
+ body: jsonEncode({
+ 'event': 'update',
+ 'event_updates': eventUpdates,
+ 'name': 'live_activity_update',
+ 'priority': 10,
+ }),
+ );
+
+ debugPrint(
+ 'Update live activity response: ${response.statusCode} ${response.body}',
+ );
+ return response.statusCode >= 200 && response.statusCode < 300;
+ } catch (e) {
+ debugPrint('Update live activity error: $e');
+ return false;
+ }
+ }
+
+ Future endLiveActivity(String activityId) async {
+ try {
+ final dismissalDate =
+ DateTime.now().add(const Duration(seconds: 5)).millisecondsSinceEpoch ~/
+ 1000;
+ final response = await http.post(
+ Uri.parse(
+ 'https://api.onesignal.com/apps/$_appId/live_activities/$activityId/notifications',
+ ),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Key $_apiKey',
+ },
+ body: jsonEncode({
+ 'event': 'end',
+ 'event_updates': {'message': 'Ended'},
+ 'dismissal_date': dismissalDate,
+ 'name': 'live_activity_end',
+ }),
+ );
+
+ debugPrint(
+ 'End live activity response: ${response.statusCode} ${response.body}',
+ );
+ return response.statusCode >= 200 && response.statusCode < 300;
+ } catch (e) {
+ debugPrint('End live activity error: $e');
+ return false;
+ }
+ }
+
+ Future fetchUser(String onesignalId) async {
+ try {
+ final url =
+ 'https://api.onesignal.com/apps/$_appId/users/by/onesignal_id/$onesignalId';
+ final response = await http.get(
+ Uri.parse(url),
+ headers: {'Accept': 'application/json'},
+ );
+
+ if (response.statusCode == 200) {
+ final json = jsonDecode(response.body) as Map;
+ return UserData.fromJson(json);
+ }
+ debugPrint('Fetch user returned ${response.statusCode}');
+ return null;
+ } catch (e) {
+ debugPrint('Fetch user error: $e');
+ return null;
+ }
+ }
+}
diff --git a/examples/demo_fm/lib/services/preferences_service.dart b/examples/demo_fm/lib/services/preferences_service.dart
new file mode 100644
index 00000000..7fe89283
--- /dev/null
+++ b/examples/demo_fm/lib/services/preferences_service.dart
@@ -0,0 +1,42 @@
+import 'package:shared_preferences/shared_preferences.dart';
+
+class PreferencesService {
+ static const _keyConsentRequired = 'consent_required';
+ static const _keyPrivacyConsent = 'privacy_consent';
+ static const _keyExternalUserId = 'external_user_id';
+ static const _keyLocationShared = 'location_shared';
+ static const _keyIamPaused = 'iam_paused';
+
+ late final SharedPreferences _prefs;
+
+ Future init() async {
+ _prefs = await SharedPreferences.getInstance();
+ }
+
+ // Consent required
+ bool get consentRequired => _prefs.getBool(_keyConsentRequired) ?? false;
+ Future setConsentRequired(bool value) =>
+ _prefs.setBool(_keyConsentRequired, value);
+
+ // Privacy consent
+ bool get privacyConsent => _prefs.getBool(_keyPrivacyConsent) ?? false;
+ Future setPrivacyConsent(bool value) =>
+ _prefs.setBool(_keyPrivacyConsent, value);
+
+ // External user ID
+ String? get externalUserId => _prefs.getString(_keyExternalUserId);
+ Future setExternalUserId(String? value) {
+ if (value == null) return _prefs.remove(_keyExternalUserId);
+ return _prefs.setString(_keyExternalUserId, value);
+ }
+
+ // Location shared
+ bool get locationShared => _prefs.getBool(_keyLocationShared) ?? false;
+ Future setLocationShared(bool value) =>
+ _prefs.setBool(_keyLocationShared, value);
+
+ // In-app messaging paused
+ bool get iamPaused => _prefs.getBool(_keyIamPaused) ?? false;
+ Future setIamPaused(bool value) =>
+ _prefs.setBool(_keyIamPaused, value);
+}
diff --git a/examples/demo_fm/lib/services/tooltip_helper.dart b/examples/demo_fm/lib/services/tooltip_helper.dart
new file mode 100644
index 00000000..664f8121
--- /dev/null
+++ b/examples/demo_fm/lib/services/tooltip_helper.dart
@@ -0,0 +1,74 @@
+import 'dart:convert';
+
+import 'package:flutter/foundation.dart';
+import 'package:http/http.dart' as http;
+
+class TooltipData {
+ final String title;
+ final String description;
+ final List? options;
+
+ const TooltipData({
+ required this.title,
+ required this.description,
+ this.options,
+ });
+}
+
+class TooltipOption {
+ final String name;
+ final String description;
+
+ const TooltipOption({required this.name, required this.description});
+}
+
+class TooltipHelper {
+ static final TooltipHelper _instance = TooltipHelper._internal();
+ factory TooltipHelper() => _instance;
+ TooltipHelper._internal();
+
+ Map _tooltips = {};
+ bool _initialized = false;
+
+ static const _tooltipUrl =
+ 'https://raw.githubusercontent.com/OneSignal/sdk-shared/main/demo/tooltip_content.json';
+
+ Future init() async {
+ if (_initialized) return;
+
+ try {
+ final response = await http.get(Uri.parse(_tooltipUrl));
+ if (response.statusCode == 200) {
+ final json = jsonDecode(response.body) as Map;
+ _tooltips = json.map((key, value) {
+ final data = value as Map;
+ List? options;
+ if (data['options'] != null) {
+ options = (data['options'] as List).map((o) {
+ final opt = o as Map;
+ return TooltipOption(
+ name: opt['name'] as String? ?? '',
+ description: opt['description'] as String? ?? '',
+ );
+ }).toList();
+ }
+ return MapEntry(
+ key,
+ TooltipData(
+ title: data['title'] as String? ?? key,
+ description: data['description'] as String? ?? '',
+ options: options,
+ ),
+ );
+ });
+ debugPrint('Loaded ${_tooltips.length} tooltips');
+ }
+ } catch (e) {
+ debugPrint('Failed to load tooltips: $e');
+ }
+
+ _initialized = true;
+ }
+
+ TooltipData? getTooltip(String key) => _tooltips[key];
+}
diff --git a/examples/demo_fm/lib/theme.dart b/examples/demo_fm/lib/theme.dart
new file mode 100644
index 00000000..60d10b72
--- /dev/null
+++ b/examples/demo_fm/lib/theme.dart
@@ -0,0 +1,125 @@
+import 'package:flutter/material.dart';
+
+class AppSpacing {
+ static const double gap = 8;
+ static const gapBox = SizedBox(height: gap);
+ static const cardPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 12);
+
+ AppSpacing._();
+}
+
+class AppColors {
+ static const osPrimary = Color(0xFFE54B4D);
+ static const osSuccess = Color(0xFF34A853);
+ static const osGrey700 = Color(0xFF616161);
+ static const osGrey600 = Color(0xFF757575);
+ static const osGrey500 = Color(0xFF9E9E9E);
+ static const osLightBackground = Color(0xFFF8F9FA);
+ static const osCardBackground = Colors.white;
+ static const osCardBorder = Color(0x1A000000); // rgba(0, 0, 0, 0.1)
+ static const osDivider = Color(0xFFE8EAED);
+ static const osWarningBackground = Color(0xFFFFF8E1);
+ static const osBackdrop = Color(0x8A000000);
+ static const osLogBackground = Color(0xFF1A1B1E);
+ static const osLogDebug = Color(0xFF82AAFF);
+ static const osLogInfo = Color(0xFFC3E88D);
+ static const osLogWarn = Color(0xFFFFCB6B);
+ static const osLogError = Color(0xFFFF5370);
+ static const osLogTimestamp = Color(0xFF676E7B);
+ AppColors._();
+}
+
+class AppTheme {
+ static ThemeData get light {
+ return ThemeData(
+ useMaterial3: true,
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: AppColors.osPrimary,
+ ).copyWith(primary: AppColors.osPrimary),
+ scaffoldBackgroundColor: AppColors.osLightBackground,
+ appBarTheme: const AppBarTheme(
+ backgroundColor: AppColors.osPrimary,
+ foregroundColor: Colors.white,
+ elevation: 2,
+ scrolledUnderElevation: 2,
+ shadowColor: Colors.black,
+ ),
+ cardTheme: CardThemeData(
+ color: AppColors.osCardBackground,
+ elevation: 0,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ side: const BorderSide(
+ color: AppColors.osCardBorder,
+ width: 2,
+ ),
+ ),
+ margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
+ ),
+ elevatedButtonTheme: ElevatedButtonThemeData(
+ style: ElevatedButton.styleFrom(
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ minimumSize: const Size(double.infinity, 48),
+ textStyle: const TextStyle(fontWeight: FontWeight.w600),
+ ),
+ ),
+ outlinedButtonTheme: OutlinedButtonThemeData(
+ style: OutlinedButton.styleFrom(
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ minimumSize: const Size(double.infinity, 48),
+ textStyle: const TextStyle(fontWeight: FontWeight.w600),
+ ),
+ ),
+ textButtonTheme: TextButtonThemeData(
+ style: TextButton.styleFrom(
+ disabledForegroundColor: AppColors.osGrey500,
+ ),
+ ),
+ dialogTheme: const DialogThemeData(
+ backgroundColor: Colors.white,
+ ),
+ dividerColor: AppColors.osDivider,
+ inputDecorationTheme: InputDecorationTheme(
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8),
+ borderSide: const BorderSide(color: AppColors.osGrey700),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8),
+ borderSide: const BorderSide(color: AppColors.osGrey700),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8),
+ borderSide: const BorderSide(color: AppColors.osPrimary, width: 2),
+ ),
+ hintStyle: const TextStyle(color: AppColors.osGrey600),
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 14,
+ ),
+ ),
+ );
+ }
+
+ AppTheme._();
+}
+
+const Duration _toastDuration = Duration(seconds: 3);
+
+extension AppSnackBar on BuildContext {
+ void showSnackBar(String message) {
+ ScaffoldMessenger.of(this)
+ ..hideCurrentSnackBar()
+ ..showSnackBar(
+ SnackBar(
+ content: Text(message),
+ duration: _toastDuration,
+ dismissDirection: DismissDirection.horizontal,
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/viewmodels/app_viewmodel.dart b/examples/demo_fm/lib/viewmodels/app_viewmodel.dart
new file mode 100644
index 00000000..afb89005
--- /dev/null
+++ b/examples/demo_fm/lib/viewmodels/app_viewmodel.dart
@@ -0,0 +1,572 @@
+import 'package:flutter/foundation.dart';
+import 'package:onesignal_flutter/onesignal_flutter.dart';
+
+import '../models/in_app_message_type.dart';
+import '../models/notification_type.dart';
+import '../services/onesignal_api_service.dart';
+import '../services/preferences_service.dart';
+
+List> _mergePairs(
+ List> prev,
+ Map next,
+) {
+ final merged = {
+ for (final e in prev) e.key: e.value,
+ ...next,
+ };
+ return merged.entries.toList();
+}
+
+List _mergeUnique(List prev, List next) =>
+ {...prev, ...next}.toList();
+
+class AppViewModel extends ChangeNotifier {
+ final OneSignalApiService _apiService;
+ final PreferencesService _prefs;
+
+ AppViewModel(this._apiService, this._prefs);
+
+ static const _orderStatuses = [
+ {
+ 'status': 'preparing',
+ 'message': 'Your order is being prepared',
+ 'estimatedTime': '15 min',
+ },
+ {
+ 'status': 'on_the_way',
+ 'message': 'Driver is heading your way',
+ 'estimatedTime': '10 min',
+ },
+ {'status': 'delivered', 'message': 'Order delivered!', 'estimatedTime': ''},
+ ];
+
+ // Loading state
+ bool _isLoading = false;
+ bool get isLoading => _isLoading;
+
+ // Increments per fetchUserDataFromApi() call so stale results are dropped
+ // when a newer fetch is already in flight (e.g. cold-start fetch racing the
+ // user-change observer's fetch right after login).
+ int _fetchSequence = 0;
+
+ // App state
+ String _appId = '';
+ String get appId => _appId;
+
+ bool _consentRequired = false;
+ bool get consentRequired => _consentRequired;
+
+ bool _privacyConsentGiven = false;
+ bool get privacyConsentGiven => _privacyConsentGiven;
+
+ String? _externalUserId;
+ String? get externalUserId => _externalUserId;
+
+ bool get isLoggedIn => _externalUserId != null;
+
+ String? _oneSignalId;
+ String? get oneSignalId => _oneSignalId;
+
+ // Push state
+ String? _pushSubscriptionId;
+ // The native bridge can hand back an empty string before the subscription
+ // id is provisioned. Treat that as "no id yet" so the UI's `?? '—'`
+ // fallback renders the placeholder instead of an empty cell.
+ String? get pushSubscriptionId =>
+ (_pushSubscriptionId?.isEmpty ?? true) ? null : _pushSubscriptionId;
+
+ bool _pushEnabled = false;
+ bool get pushEnabled => _pushEnabled;
+
+ bool _hasNotificationPermission = false;
+ bool get hasNotificationPermission => _hasNotificationPermission;
+
+ // IAM state
+ bool _iamPaused = false;
+ bool get iamPaused => _iamPaused;
+
+ // Location state
+ bool _locationShared = false;
+ bool get locationShared => _locationShared;
+
+ // Live Activity state
+ String _activityId = 'order-1';
+ String get activityId => _activityId;
+
+ String _orderNumber = 'ORD-1234';
+ String get orderNumber => _orderNumber;
+
+ int _statusIndex = 0;
+
+ bool _isLaUpdating = false;
+ bool get isLaUpdating => _isLaUpdating;
+
+ bool get hasApiKey => _apiService.hasApiKey();
+
+ String get nextStatusLabel {
+ final nextIndex = (_statusIndex + 1) % _orderStatuses.length;
+ final status = _orderStatuses[nextIndex]['status']!;
+ return status.toUpperCase().replaceAll('_', ' ');
+ }
+
+ // Data lists
+ List> _aliasesList = [];
+ List> get aliasesList =>
+ List.unmodifiable(_aliasesList);
+
+ List _emailsList = [];
+ List get emailsList => List.unmodifiable(_emailsList);
+
+ List _smsNumbersList = [];
+ List get smsNumbersList => List.unmodifiable(_smsNumbersList);
+
+ List> _tagsList = [];
+ List> get tagsList => List.unmodifiable(_tagsList);
+
+ List> _triggersList = [];
+ List> get triggersList =>
+ List.unmodifiable(_triggersList);
+
+ // Initialize
+ Future loadInitialState(String appId) async {
+ _appId = appId;
+
+ _consentRequired = _prefs.consentRequired;
+ _privacyConsentGiven = _prefs.privacyConsent;
+ _externalUserId = _prefs.externalUserId;
+
+ _iamPaused = _prefs.iamPaused;
+ _locationShared = _prefs.locationShared;
+
+ if (_externalUserId != null) {
+ OneSignal.login(_externalUserId!);
+ }
+
+ _pushSubscriptionId = OneSignal.User.pushSubscription.id;
+ _pushEnabled = OneSignal.User.pushSubscription.optedIn ?? false;
+ _hasNotificationPermission = OneSignal.Notifications.permission;
+
+ final onesignalId = await OneSignal.User.getOnesignalId();
+ _oneSignalId = onesignalId;
+
+ notifyListeners();
+
+ if (onesignalId == null) return;
+
+ // fetchUserDataFromApi owns _isLoading + notifyListeners.
+ await fetchUserDataFromApi();
+ }
+
+ // Observers
+ void setupObservers() {
+ OneSignal.User.pushSubscription.addObserver((state) {
+ _pushSubscriptionId = state.current.id;
+ _pushEnabled = state.current.optedIn;
+ String fmtToken(String? t) {
+ if (t == null || t.isEmpty) return 'null';
+ return t.length > 8 ? '${t.substring(0, 8)}…' : t;
+ }
+
+ debugPrint(
+ 'Push subscription changed: '
+ 'id=${state.previous.id ?? 'null'} → ${state.current.id ?? 'null'}, '
+ 'optedIn=${state.previous.optedIn} → ${state.current.optedIn}, '
+ 'token=${fmtToken(state.previous.token)} → ${fmtToken(state.current.token)}',
+ );
+ notifyListeners();
+ });
+
+ OneSignal.Notifications.addPermissionObserver((permission) {
+ _hasNotificationPermission = permission;
+ debugPrint('Permission changed: $permission');
+ notifyListeners();
+ });
+
+ OneSignal.User.addObserver((state) {
+ final nextOnesignalId = state.current.onesignalId;
+ debugPrint(
+ 'User changed: onesignalId=${nextOnesignalId ?? 'null'}, externalId=${state.current.externalId ?? 'null'}',
+ );
+
+ _oneSignalId = nextOnesignalId;
+ notifyListeners();
+
+ // Drive the post-login fetch from the observer so it runs only once the
+ // SDK has actually assigned a new onesignalId. Logout clears it to null;
+ // skip the fetch in that case (logoutUser already clears local lists).
+ if (nextOnesignalId == null) return;
+ fetchUserDataFromApi();
+ });
+ }
+
+ // Fetch user data from API. Owns the isLoading toggle and uses a
+ // request-sequence guard so stale results are dropped if a newer fetch
+ // started before this one finishes.
+ Future fetchUserDataFromApi() async {
+ final requestId = ++_fetchSequence;
+ _isLoading = true;
+ notifyListeners();
+
+ try {
+ final onesignalId = await OneSignal.User.getOnesignalId();
+ if (onesignalId == null) return;
+
+ final userData = await _apiService.fetchUser(onesignalId);
+ if (userData == null) return;
+
+ if (_fetchSequence != requestId) return;
+
+ _aliasesList = _mergePairs(_aliasesList, userData.aliases);
+ _tagsList = _mergePairs(_tagsList, userData.tags);
+ _emailsList = _mergeUnique(_emailsList, userData.emails);
+ _smsNumbersList = _mergeUnique(_smsNumbersList, userData.smsNumbers);
+
+ if (userData.externalId != null) {
+ _externalUserId = userData.externalId;
+ await _prefs.setExternalUserId(userData.externalId);
+ }
+ } catch (e) {
+ debugPrint('fetchUserDataFromApi error: $e');
+ } finally {
+ if (_fetchSequence == requestId) {
+ _isLoading = false;
+ }
+ notifyListeners();
+ }
+ }
+
+ // Login / Logout
+ Future loginUser(String externalUserId) async {
+ _aliasesList = [];
+ _emailsList = [];
+ _smsNumbersList = [];
+ _tagsList = [];
+ _triggersList = [];
+ _externalUserId = externalUserId;
+ _isLoading = true;
+ notifyListeners();
+
+ try {
+ await OneSignal.login(externalUserId);
+ await _prefs.setExternalUserId(externalUserId);
+ debugPrint('Logged in as: $externalUserId');
+ // The User observer runs fetchUserDataFromApi once the new onesignalId
+ // is assigned; that call clears _isLoading in its finally.
+ } catch (e) {
+ debugPrint('Login error: $e');
+ _isLoading = false;
+ notifyListeners();
+ }
+ }
+
+ Future logoutUser() async {
+ _externalUserId = null;
+ _aliasesList = [];
+ _emailsList = [];
+ _smsNumbersList = [];
+ _tagsList = [];
+ _triggersList = [];
+ notifyListeners();
+
+ try {
+ await OneSignal.logout();
+ await _prefs.setExternalUserId(null);
+ debugPrint('Logged out');
+ } catch (e) {
+ debugPrint('Logout error: $e');
+ }
+ }
+
+ // Consent
+ Future setConsentRequired(bool value) async {
+ _consentRequired = value;
+ OneSignal.consentRequired(value);
+ await _prefs.setConsentRequired(value);
+ if (!value) {
+ _privacyConsentGiven = false;
+ await _prefs.setPrivacyConsent(false);
+ }
+ notifyListeners();
+ }
+
+ Future setPrivacyConsent(bool value) async {
+ _privacyConsentGiven = value;
+ OneSignal.consentGiven(value);
+ await _prefs.setPrivacyConsent(value);
+ notifyListeners();
+ }
+
+ // Push
+ void togglePush(bool enabled) {
+ if (enabled) {
+ OneSignal.User.pushSubscription.optIn();
+ } else {
+ OneSignal.User.pushSubscription.optOut();
+ }
+ _pushEnabled = enabled;
+ notifyListeners();
+ debugPrint('Push ${enabled ? "enabled" : "disabled"}');
+ }
+
+ Future promptPush() => OneSignal.Notifications.requestPermission(true);
+
+ // Notifications
+ Future sendNotification(NotificationType type) async {
+ final subscriptionId = OneSignal.User.pushSubscription.id;
+ if (subscriptionId == null) {
+ debugPrint('No subscription ID for notification');
+ return;
+ }
+ final success = await _apiService.sendNotification(type, subscriptionId);
+ if (success) {
+ debugPrint('Notification sent: ${type.name}');
+ } else {
+ debugPrint('Failed to send notification');
+ }
+ }
+
+ Future sendCustomNotification(String title, String body) async {
+ final subscriptionId = OneSignal.User.pushSubscription.id;
+ if (subscriptionId == null) {
+ debugPrint('No subscription ID for custom notification');
+ return;
+ }
+ final success = await _apiService.sendCustomNotification(
+ title,
+ body,
+ subscriptionId,
+ );
+ if (success) {
+ debugPrint('Custom notification sent');
+ } else {
+ debugPrint('Failed to send notification');
+ }
+ }
+
+ void clearAllNotifications() {
+ OneSignal.Notifications.clearAll();
+ debugPrint('All notifications cleared');
+ }
+
+ // IAM
+ Future setIamPaused(bool paused) async {
+ _iamPaused = paused;
+ OneSignal.InAppMessages.paused(paused);
+ await _prefs.setIamPaused(paused);
+ notifyListeners();
+ }
+
+ void sendInAppMessage(InAppMessageType type) {
+ OneSignal.InAppMessages.addTrigger('iam_type', type.triggerValue);
+ _triggersList = _mergePairs(_triggersList, {'iam_type': type.triggerValue});
+ notifyListeners();
+ debugPrint('Sent In-App Message: ${type.label}');
+ }
+
+ // Aliases
+ void addAlias(String label, String id) {
+ OneSignal.User.addAlias(label, id);
+ _aliasesList = _mergePairs(_aliasesList, {label: id});
+ notifyListeners();
+ debugPrint('Alias added: $label');
+ }
+
+ void addAliases(Map aliases) {
+ OneSignal.User.addAliases(aliases);
+ _aliasesList = _mergePairs(_aliasesList, aliases);
+ notifyListeners();
+ debugPrint('${aliases.length} alias(es) added');
+ }
+
+ // Emails
+ void addEmail(String email) {
+ OneSignal.User.addEmail(email);
+ _emailsList = _mergeUnique(_emailsList, [email]);
+ notifyListeners();
+ debugPrint('Email added: $email');
+ }
+
+ void removeEmail(String email) {
+ OneSignal.User.removeEmail(email);
+ _emailsList = List.from(_emailsList)..remove(email);
+ notifyListeners();
+ debugPrint('Email removed: $email');
+ }
+
+ // SMS
+ void addSms(String smsNumber) {
+ OneSignal.User.addSms(smsNumber);
+ _smsNumbersList = _mergeUnique(_smsNumbersList, [smsNumber]);
+ notifyListeners();
+ debugPrint('SMS added: $smsNumber');
+ }
+
+ void removeSms(String smsNumber) {
+ OneSignal.User.removeSms(smsNumber);
+ _smsNumbersList = List.from(_smsNumbersList)..remove(smsNumber);
+ notifyListeners();
+ debugPrint('SMS removed: $smsNumber');
+ }
+
+ // Tags
+ void addTag(String key, String value) {
+ OneSignal.User.addTagWithKey(key, value);
+ _tagsList = _mergePairs(_tagsList, {key: value});
+ notifyListeners();
+ debugPrint('Tag added: $key');
+ }
+
+ void addTags(Map tags) {
+ OneSignal.User.addTags(tags);
+ _tagsList = _mergePairs(_tagsList, tags);
+ notifyListeners();
+ debugPrint('${tags.length} tag(s) added');
+ }
+
+ void removeTag(String key) {
+ OneSignal.User.removeTag(key);
+ _tagsList = List.from(_tagsList)..removeWhere((e) => e.key == key);
+ notifyListeners();
+ debugPrint('Tag removed: $key');
+ }
+
+ void removeSelectedTags(List keys) {
+ OneSignal.User.removeTags(keys);
+ _tagsList = List.from(_tagsList)..removeWhere((e) => keys.contains(e.key));
+ notifyListeners();
+ debugPrint('${keys.length} tag(s) removed');
+ }
+
+ // Triggers (in-memory only)
+ void addTrigger(String key, String value) {
+ OneSignal.InAppMessages.addTrigger(key, value);
+ _triggersList = _mergePairs(_triggersList, {key: value});
+ notifyListeners();
+ debugPrint('Trigger added: $key');
+ }
+
+ void addTriggers(Map triggers) {
+ OneSignal.InAppMessages.addTriggers(triggers);
+ _triggersList = _mergePairs(_triggersList, triggers);
+ notifyListeners();
+ debugPrint('${triggers.length} trigger(s) added');
+ }
+
+ void removeTrigger(String key) {
+ OneSignal.InAppMessages.removeTrigger(key);
+ _triggersList = List.from(_triggersList)..removeWhere((e) => e.key == key);
+ notifyListeners();
+ debugPrint('Trigger removed: $key');
+ }
+
+ void removeSelectedTriggers(List keys) {
+ OneSignal.InAppMessages.removeTriggers(keys);
+ _triggersList = List.from(_triggersList)
+ ..removeWhere((e) => keys.contains(e.key));
+ notifyListeners();
+ debugPrint('${keys.length} trigger(s) removed');
+ }
+
+ void clearAllTriggers() {
+ OneSignal.InAppMessages.clearTriggers();
+ _triggersList = [];
+ notifyListeners();
+ debugPrint('All triggers cleared');
+ }
+
+ // Outcomes
+ void sendOutcome(String name) {
+ OneSignal.Session.addOutcome(name);
+ debugPrint('Outcome sent: $name');
+ }
+
+ void sendUniqueOutcome(String name) {
+ OneSignal.Session.addUniqueOutcome(name);
+ debugPrint('Unique outcome sent: $name');
+ }
+
+ void sendOutcomeWithValue(String name, double value) {
+ OneSignal.Session.addOutcomeWithValue(name, value);
+ debugPrint('Outcome sent: $name = $value');
+ }
+
+ // Custom Events
+ void trackEvent(String name, Map? properties) {
+ OneSignal.User.trackEvent(name, properties);
+ debugPrint('Event tracked: $name');
+ }
+
+ // Live Activities
+ void setActivityId(String id) {
+ _activityId = id;
+ notifyListeners();
+ }
+
+ void setOrderNumber(String number) {
+ _orderNumber = number;
+ notifyListeners();
+ }
+
+ Future startLiveActivity() async {
+ final attributes = {'orderNumber': _orderNumber};
+ final content = Map.from(_orderStatuses[0]);
+ _statusIndex = 0;
+ await OneSignal.LiveActivities.startDefault(
+ _activityId,
+ attributes,
+ content,
+ );
+ notifyListeners();
+ debugPrint('Started Live Activity: $_activityId');
+ }
+
+ Future updateLiveActivity() async {
+ _isLaUpdating = true;
+ notifyListeners();
+
+ final nextIndex = (_statusIndex + 1) % _orderStatuses.length;
+ final content = Map.from(_orderStatuses[nextIndex]);
+ final eventUpdates = {'data': content};
+ final success = await _apiService.updateLiveActivity(
+ _activityId,
+ eventUpdates,
+ );
+
+ _isLaUpdating = false;
+ if (success) {
+ _statusIndex = nextIndex;
+ debugPrint('Updated Live Activity: $_activityId');
+ } else {
+ debugPrint('Failed to update Live Activity');
+ }
+ notifyListeners();
+ }
+
+ Future endLiveActivity() async {
+ final success = await _apiService.endLiveActivity(_activityId);
+ if (success) {
+ _statusIndex = 0;
+ debugPrint('Ended Live Activity: $_activityId');
+ } else {
+ debugPrint('Failed to end Live Activity');
+ }
+ notifyListeners();
+ }
+
+ // Location
+ Future setLocationShared(bool shared) async {
+ _locationShared = shared;
+ OneSignal.Location.setShared(shared);
+ await _prefs.setLocationShared(shared);
+ notifyListeners();
+ debugPrint('Location sharing ${shared ? "enabled" : "disabled"}');
+ }
+
+ void promptLocation() {
+ OneSignal.Location.requestPermission();
+ }
+
+ Future checkLocationShared() async {
+ return await OneSignal.Location.isShared();
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/action_button.dart b/examples/demo_fm/lib/widgets/action_button.dart
new file mode 100644
index 00000000..9222f81c
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/action_button.dart
@@ -0,0 +1,78 @@
+import 'package:flutter/material.dart';
+
+import '../theme.dart';
+
+class PrimaryButton extends StatelessWidget {
+ final String label;
+ final VoidCallback? onPressed;
+ final IconData? icon;
+ final String? semanticsLabel;
+
+ const PrimaryButton({
+ super.key,
+ required this.label,
+ this.onPressed,
+ this.icon,
+ this.semanticsLabel,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ Widget button = SizedBox(
+ width: double.infinity,
+ child: ElevatedButton(
+ onPressed: onPressed,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: AppColors.osPrimary,
+ foregroundColor: Colors.white,
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ if (icon != null) ...[
+ Icon(icon, size: 18),
+ const SizedBox(width: 8),
+ ],
+ Text(label),
+ ],
+ ),
+ ),
+ );
+ if (semanticsLabel != null) {
+ button = Semantics(identifier: semanticsLabel, container: true, child: button);
+ }
+ return button;
+ }
+}
+
+class DestructiveButton extends StatelessWidget {
+ final String label;
+ final VoidCallback? onPressed;
+ final String? semanticsLabel;
+
+ const DestructiveButton({
+ super.key,
+ required this.label,
+ this.onPressed,
+ this.semanticsLabel,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ Widget button = SizedBox(
+ width: double.infinity,
+ child: OutlinedButton(
+ onPressed: onPressed,
+ style: OutlinedButton.styleFrom(
+ foregroundColor: AppColors.osPrimary,
+ side: const BorderSide(color: AppColors.osPrimary),
+ ),
+ child: Text(label),
+ ),
+ );
+ if (semanticsLabel != null) {
+ button = Semantics(identifier: semanticsLabel, container: true, child: button);
+ }
+ return button;
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/app_text_field.dart b/examples/demo_fm/lib/widgets/app_text_field.dart
new file mode 100644
index 00000000..1e541f52
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/app_text_field.dart
@@ -0,0 +1,18 @@
+import 'package:flutter/material.dart';
+
+class AppTextField extends TextField {
+ const AppTextField({
+ super.key,
+ super.controller,
+ super.decoration,
+ super.keyboardType,
+ super.onChanged,
+ super.textAlign,
+ super.style,
+ super.maxLines,
+ super.autocorrect = false,
+ super.enableSuggestions = false,
+ super.smartQuotesType = SmartQuotesType.disabled,
+ super.smartDashesType = SmartDashesType.disabled,
+ });
+}
diff --git a/examples/demo_fm/lib/widgets/dialogs.dart b/examples/demo_fm/lib/widgets/dialogs.dart
new file mode 100644
index 00000000..10ad3233
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/dialogs.dart
@@ -0,0 +1,782 @@
+import 'dart:convert';
+
+import 'package:flutter/material.dart';
+
+import '../services/tooltip_helper.dart';
+import '../theme.dart';
+import 'app_text_field.dart';
+
+// Single input dialog (login, email, sms)
+class SingleInputDialog extends StatefulWidget {
+ final String title;
+ final String fieldLabel;
+ final String confirmLabel;
+ final TextInputType keyboardType;
+ final String? semanticsLabel;
+
+ const SingleInputDialog({
+ super.key,
+ required this.title,
+ required this.fieldLabel,
+ this.confirmLabel = 'Add',
+ this.keyboardType = TextInputType.text,
+ this.semanticsLabel,
+ });
+
+ @override
+ State createState() => _SingleInputDialogState();
+}
+
+class _SingleInputDialogState extends State {
+ final _controller = TextEditingController();
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ insetPadding: const EdgeInsets.symmetric(horizontal: 16),
+ title: Text(widget.title),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: Semantics(
+ identifier: widget.semanticsLabel ?? '${widget.fieldLabel}_input',
+ container: true,
+ child: AppTextField(
+ controller: _controller,
+ decoration: InputDecoration(labelText: widget.fieldLabel),
+ keyboardType: widget.keyboardType,
+ onChanged: (_) => setState(() {}),
+ ),
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ Semantics(
+ identifier: 'singleinput_confirm_button',
+ container: true,
+ button: true,
+ child: TextButton(
+ onPressed: _controller.text.isEmpty
+ ? null
+ : () => Navigator.pop(context, _controller.text),
+ child: Text(widget.confirmLabel),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+// Key-value pair input dialog (single pair)
+class PairInputDialog extends StatefulWidget {
+ final String title;
+ final String keyLabel;
+ final String valueLabel;
+ final String? keySemanticsLabel;
+ final String? valueSemanticsLabel;
+
+ const PairInputDialog({
+ super.key,
+ required this.title,
+ this.keyLabel = 'Key',
+ this.valueLabel = 'Value',
+ this.keySemanticsLabel,
+ this.valueSemanticsLabel,
+ });
+
+ @override
+ State createState() => _PairInputDialogState();
+}
+
+class _PairInputDialogState extends State {
+ final _keyController = TextEditingController();
+ final _valueController = TextEditingController();
+
+ @override
+ void dispose() {
+ _keyController.dispose();
+ _valueController.dispose();
+ super.dispose();
+ }
+
+ bool get _isValid =>
+ _keyController.text.isNotEmpty && _valueController.text.isNotEmpty;
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ insetPadding: const EdgeInsets.symmetric(horizontal: 16),
+ title: Text(widget.title),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: Row(
+ children: [
+ Expanded(
+ child: Semantics(
+ identifier: widget.keySemanticsLabel ?? '${widget.keyLabel}_input',
+ container: true,
+ child: AppTextField(
+ controller: _keyController,
+ decoration: InputDecoration(labelText: widget.keyLabel),
+ onChanged: (_) => setState(() {}),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Semantics(
+ identifier: widget.valueSemanticsLabel ?? '${widget.valueLabel}_input',
+ container: true,
+ child: AppTextField(
+ controller: _valueController,
+ decoration: InputDecoration(labelText: widget.valueLabel),
+ onChanged: (_) => setState(() {}),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ Semantics(
+ identifier: 'singlepair_confirm_button',
+ container: true,
+ button: true,
+ child: TextButton(
+ onPressed: _isValid
+ ? () => Navigator.pop(
+ context,
+ MapEntry(_keyController.text, _valueController.text),
+ )
+ : null,
+ child: const Text('Add'),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+// Multi-pair input dialog (dynamic rows)
+class MultiPairInputDialog extends StatefulWidget {
+ final String title;
+ final String keyLabel;
+ final String valueLabel;
+
+ const MultiPairInputDialog({
+ super.key,
+ required this.title,
+ this.keyLabel = 'Key',
+ this.valueLabel = 'Value',
+ });
+
+ @override
+ State createState() => _MultiPairInputDialogState();
+}
+
+class _MultiPairInputDialogState extends State {
+ final List _keyControllers = [];
+ final List _valueControllers = [];
+
+ @override
+ void initState() {
+ super.initState();
+ _addRow();
+ }
+
+ @override
+ void dispose() {
+ for (final c in _keyControllers) {
+ c.dispose();
+ }
+ for (final c in _valueControllers) {
+ c.dispose();
+ }
+ super.dispose();
+ }
+
+ void _addRow() {
+ final keyC = TextEditingController();
+ final valC = TextEditingController();
+ keyC.addListener(() => setState(() {}));
+ valC.addListener(() => setState(() {}));
+ _keyControllers.add(keyC);
+ _valueControllers.add(valC);
+ setState(() {});
+ }
+
+ void _removeRow(int index) {
+ _keyControllers[index].dispose();
+ _valueControllers[index].dispose();
+ _keyControllers.removeAt(index);
+ _valueControllers.removeAt(index);
+ setState(() {});
+ }
+
+ bool get _allValid {
+ for (var i = 0; i < _keyControllers.length; i++) {
+ if (_keyControllers[i].text.isEmpty ||
+ _valueControllers[i].text.isEmpty) {
+ return false;
+ }
+ }
+ return _keyControllers.isNotEmpty;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ insetPadding: const EdgeInsets.symmetric(horizontal: 16),
+ title: Text(widget.title),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ for (var i = 0; i < _keyControllers.length; i++) ...[
+ if (i > 0) const Divider(),
+ Row(
+ children: [
+ Expanded(
+ child: Semantics(
+ identifier: 'multipair_key_$i',
+ container: true,
+ child: AppTextField(
+ controller: _keyControllers[i],
+ decoration: InputDecoration(
+ labelText: widget.keyLabel,
+ isDense: true,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Semantics(
+ identifier: 'multipair_value_$i',
+ container: true,
+ child: AppTextField(
+ controller: _valueControllers[i],
+ decoration: InputDecoration(
+ labelText: widget.valueLabel,
+ isDense: true,
+ ),
+ ),
+ ),
+ ),
+ if (_keyControllers.length > 1)
+ IconButton(
+ icon: const Icon(Icons.close, size: 20),
+ onPressed: () => _removeRow(i),
+ ),
+ ],
+ ),
+ ],
+ Semantics(
+ identifier: 'multipair_add_row_button',
+ container: true,
+ button: true,
+ child: TextButton.icon(
+ onPressed: _addRow,
+ icon: const Icon(Icons.add, size: 18),
+ label: const Text('Add Row'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ Semantics(
+ identifier: 'multipair_confirm_button',
+ container: true,
+ button: true,
+ child: TextButton(
+ onPressed: _allValid
+ ? () {
+ final pairs = {};
+ for (var i = 0; i < _keyControllers.length; i++) {
+ pairs[_keyControllers[i].text] =
+ _valueControllers[i].text;
+ }
+ Navigator.pop(context, pairs);
+ }
+ : null,
+ child: const Text('Add All'),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+// Multi-select remove dialog
+class MultiSelectRemoveDialog extends StatefulWidget {
+ final String title;
+ final List> items;
+
+ const MultiSelectRemoveDialog({
+ super.key,
+ required this.title,
+ required this.items,
+ });
+
+ @override
+ State createState() =>
+ _MultiSelectRemoveDialogState();
+}
+
+class _MultiSelectRemoveDialogState extends State {
+ final Set _selected = {};
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ insetPadding: const EdgeInsets.symmetric(horizontal: 16),
+ title: Text(widget.title),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: widget.items.map((item) {
+ return Semantics(
+ identifier: 'remove_checkbox_${item.key}',
+ container: true,
+ child: CheckboxListTile(
+ title: Text(item.key),
+ value: _selected.contains(item.key),
+ controlAffinity: ListTileControlAffinity.leading,
+ onChanged: (checked) {
+ setState(() {
+ if (checked == true) {
+ _selected.add(item.key);
+ } else {
+ _selected.remove(item.key);
+ }
+ });
+ },
+ contentPadding: EdgeInsets.zero,
+ ),
+ );
+ }).toList(),
+ ),
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ Semantics(
+ identifier: 'multiselect_confirm_button',
+ container: true,
+ button: true,
+ child: TextButton(
+ onPressed: _selected.isEmpty
+ ? null
+ : () => Navigator.pop(context, _selected.toList()),
+ child: Text('Remove (${_selected.length})'),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+// Outcome dialog
+class OutcomeDialog extends StatefulWidget {
+ const OutcomeDialog({super.key});
+
+ @override
+ State createState() => _OutcomeDialogState();
+}
+
+enum OutcomeType { normal, unique, withValue }
+
+class _OutcomeDialogState extends State {
+ final _nameController = TextEditingController();
+ final _valueController = TextEditingController();
+ OutcomeType _type = OutcomeType.normal;
+
+ @override
+ void dispose() {
+ _nameController.dispose();
+ _valueController.dispose();
+ super.dispose();
+ }
+
+ bool get _isValid {
+ if (_nameController.text.isEmpty) return false;
+ if (_type == OutcomeType.withValue) {
+ return double.tryParse(_valueController.text) != null;
+ }
+ return true;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ insetPadding: const EdgeInsets.symmetric(horizontal: 16),
+ title: const Text('Send Outcome'),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ RadioGroup(
+ groupValue: _type,
+ onChanged: (v) => setState(() { if (v != null) _type = v; }),
+ child: Column(
+ children: [
+ Semantics(
+ identifier: 'outcome_type_normal_radio',
+ container: true,
+ button: true,
+ child: RadioListTile(
+ title: const Text('Normal Outcome'),
+ value: OutcomeType.normal,
+ contentPadding: EdgeInsets.zero,
+ ),
+ ),
+ Semantics(
+ identifier: 'outcome_type_unique_radio',
+ container: true,
+ button: true,
+ child: RadioListTile(
+ title: const Text('Unique Outcome'),
+ value: OutcomeType.unique,
+ contentPadding: EdgeInsets.zero,
+ ),
+ ),
+ Semantics(
+ identifier: 'outcome_type_value_radio',
+ container: true,
+ button: true,
+ child: RadioListTile(
+ title: const Text('Outcome with Value'),
+ value: OutcomeType.withValue,
+ contentPadding: EdgeInsets.zero,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 8),
+ Semantics(
+ identifier: 'outcome_name_input',
+ container: true,
+ child: AppTextField(
+ controller: _nameController,
+ decoration: const InputDecoration(labelText: 'Outcome Name'),
+ onChanged: (_) => setState(() {}),
+ ),
+ ),
+ if (_type == OutcomeType.withValue) ...[
+ const SizedBox(height: 12),
+ Semantics(
+ identifier: 'outcome_value_input',
+ container: true,
+ child: AppTextField(
+ controller: _valueController,
+ decoration: const InputDecoration(labelText: 'Value'),
+ keyboardType:
+ const TextInputType.numberWithOptions(decimal: true),
+ onChanged: (_) => setState(() {}),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ Semantics(
+ identifier: 'outcome_send_button',
+ container: true,
+ child: TextButton(
+ onPressed: _isValid
+ ? () {
+ Navigator.pop(context, {
+ 'type': _type,
+ 'name': _nameController.text,
+ 'value': _type == OutcomeType.withValue
+ ? double.parse(_valueController.text)
+ : null,
+ });
+ }
+ : null,
+ child: const Text('Send'),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+// Track event dialog
+class TrackEventDialog extends StatefulWidget {
+ const TrackEventDialog({super.key});
+
+ @override
+ State createState() => _TrackEventDialogState();
+}
+
+class _TrackEventDialogState extends State {
+ final _nameController = TextEditingController();
+ final _propsController = TextEditingController();
+ String? _jsonError;
+
+ @override
+ void dispose() {
+ _nameController.dispose();
+ _propsController.dispose();
+ super.dispose();
+ }
+
+ bool get _isValid {
+ if (_nameController.text.isEmpty) return false;
+ if (_propsController.text.isNotEmpty && _jsonError != null) return false;
+ return true;
+ }
+
+ void _validateJson(String text) {
+ setState(() {
+ if (text.isEmpty) {
+ _jsonError = null;
+ } else {
+ try {
+ jsonDecode(text);
+ _jsonError = null;
+ } catch (_) {
+ _jsonError = 'Invalid JSON format';
+ }
+ }
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ insetPadding: const EdgeInsets.symmetric(horizontal: 16),
+ title: const Text('Custom Event'),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Semantics(
+ identifier: 'event_name_input',
+ container: true,
+ child: AppTextField(
+ controller: _nameController,
+ decoration: const InputDecoration(labelText: 'Event Name'),
+ onChanged: (_) => setState(() {}),
+ ),
+ ),
+ const SizedBox(height: 12),
+ Semantics(
+ identifier: 'event_properties_input',
+ container: true,
+ child: AppTextField(
+ controller: _propsController,
+ decoration: InputDecoration(
+ labelText: 'Properties (optional, JSON)',
+ hintText: '{"key": "value"}',
+ errorText: _jsonError,
+ ),
+ maxLines: 3,
+ onChanged: _validateJson,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ Semantics(
+ identifier: 'event_track_button',
+ container: true,
+ child: TextButton(
+ onPressed: _isValid
+ ? () {
+ Map? props;
+ if (_propsController.text.isNotEmpty) {
+ props = jsonDecode(_propsController.text)
+ as Map;
+ }
+ Navigator.pop(context, {
+ 'name': _nameController.text,
+ 'properties': props,
+ });
+ }
+ : null,
+ child: const Text('Track'),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+// Custom notification dialog
+class CustomNotificationDialog extends StatefulWidget {
+ const CustomNotificationDialog({super.key});
+
+ @override
+ State createState() =>
+ _CustomNotificationDialogState();
+}
+
+class _CustomNotificationDialogState extends State {
+ final _titleController = TextEditingController();
+ final _bodyController = TextEditingController();
+
+ @override
+ void dispose() {
+ _titleController.dispose();
+ _bodyController.dispose();
+ super.dispose();
+ }
+
+ bool get _isValid =>
+ _titleController.text.isNotEmpty && _bodyController.text.isNotEmpty;
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ insetPadding: const EdgeInsets.symmetric(horizontal: 16),
+ title: const Text('Custom Notification'),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ AppTextField(
+ controller: _titleController,
+ decoration: const InputDecoration(labelText: 'Title'),
+ onChanged: (_) => setState(() {}),
+ ),
+ const SizedBox(height: 12),
+ AppTextField(
+ controller: _bodyController,
+ decoration: const InputDecoration(labelText: 'Body'),
+ onChanged: (_) => setState(() {}),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: _isValid
+ ? () => Navigator.pop(context, {
+ 'title': _titleController.text,
+ 'body': _bodyController.text,
+ })
+ : null,
+ child: const Text('Send'),
+ ),
+ ],
+ );
+ }
+}
+
+// Tooltip dialog
+class TooltipDialog extends StatelessWidget {
+ final TooltipData tooltip;
+
+ const TooltipDialog({super.key, required this.tooltip});
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ insetPadding: const EdgeInsets.symmetric(horizontal: 16),
+ title: Semantics(
+ identifier: 'tooltip_title',
+ container: true,
+ child: Text(tooltip.title),
+ ),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Semantics(
+ identifier: 'tooltip_description',
+ container: true,
+ child: Text(tooltip.description),
+ ),
+ if (tooltip.options != null && tooltip.options!.isNotEmpty) ...[
+ const SizedBox(height: 16),
+ ...tooltip.options!.map((option) => Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ option.name,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ Text(
+ option.description,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: AppColors.osGrey600,
+ ),
+ ),
+ ],
+ ),
+ )),
+ ],
+ ],
+ ),
+ ),
+ ),
+ actions: [
+ Semantics(
+ identifier: 'tooltip_ok_button',
+ container: true,
+ button: true,
+ child: TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('OK'),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/list_widgets.dart b/examples/demo_fm/lib/widgets/list_widgets.dart
new file mode 100644
index 00000000..aa8686b5
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/list_widgets.dart
@@ -0,0 +1,272 @@
+import 'package:flutter/material.dart';
+
+import '../theme.dart';
+
+class PairItem extends StatelessWidget {
+ final String sectionKey;
+ final String keyText;
+ final String valueText;
+ final VoidCallback? onDelete;
+
+ const PairItem({
+ super.key,
+ required this.sectionKey,
+ required this.keyText,
+ required this.valueText,
+ this.onDelete,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Semantics(
+ identifier: '${sectionKey}_pair_key_$keyText',
+ container: true,
+ child: Text(
+ keyText,
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ ),
+ Semantics(
+ identifier: '${sectionKey}_pair_value_$keyText',
+ container: true,
+ child: Text(
+ valueText,
+ style: Theme.of(
+ context,
+ ).textTheme.bodySmall?.copyWith(color: AppColors.osGrey600),
+ ),
+ ),
+ ],
+ ),
+ ),
+ if (onDelete != null)
+ Semantics(
+ identifier: '${sectionKey}_remove_$keyText',
+ container: true,
+ child: GestureDetector(
+ onTap: onDelete,
+ child: Icon(Icons.close, size: 18, color: AppColors.osPrimary),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class SingleItem extends StatelessWidget {
+ final String sectionKey;
+ final String text;
+ final VoidCallback? onDelete;
+
+ const SingleItem({
+ super.key,
+ required this.sectionKey,
+ required this.text,
+ this.onDelete,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
+ child: Row(
+ children: [
+ Expanded(
+ child: Semantics(
+ identifier: '${sectionKey}_value_$text',
+ container: true,
+ child: Text(text, style: Theme.of(context).textTheme.bodyMedium),
+ ),
+ ),
+ if (onDelete != null)
+ Semantics(
+ identifier: '${sectionKey}_remove_$text',
+ container: true,
+ child: GestureDetector(
+ onTap: onDelete,
+ child: Icon(Icons.close, size: 18, color: AppColors.osPrimary),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class EmptyState extends StatelessWidget {
+ final String text;
+ final String? sectionKey;
+
+ const EmptyState({super.key, required this.text, this.sectionKey});
+
+ @override
+ Widget build(BuildContext context) {
+ final label = Text(
+ text,
+ style: Theme.of(
+ context,
+ ).textTheme.bodyMedium?.copyWith(color: AppColors.osGrey600),
+ );
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ child: Center(
+ child: sectionKey == null
+ ? label
+ : Semantics(
+ identifier: '${sectionKey}_empty',
+ container: true,
+ child: label,
+ ),
+ ),
+ );
+ }
+}
+
+class LoadingState extends StatelessWidget {
+ final String? sectionKey;
+
+ const LoadingState({super.key, this.sectionKey});
+
+ @override
+ Widget build(BuildContext context) {
+ final indicator = SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: AppColors.osPrimary,
+ ),
+ );
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ child: Center(
+ child:
+ sectionKey == null
+ ? indicator
+ : Semantics(
+ identifier: '${sectionKey}_loading',
+ container: true,
+ child: indicator,
+ ),
+ ),
+ );
+ }
+}
+
+class PairList extends StatelessWidget {
+ final String sectionKey;
+ final List> items;
+ final String emptyText;
+ final bool loading;
+ final void Function(String key)? onDelete;
+
+ const PairList({
+ super.key,
+ required this.sectionKey,
+ required this.items,
+ required this.emptyText,
+ this.loading = false,
+ this.onDelete,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ if (items.isEmpty) {
+ return loading
+ ? LoadingState(sectionKey: sectionKey)
+ : EmptyState(text: emptyText, sectionKey: sectionKey);
+ }
+
+ return Column(
+ children: [
+ for (var i = 0; i < items.length; i++) ...[
+ PairItem(
+ key: ValueKey('${items[i].key}_${items[i].value}'),
+ sectionKey: sectionKey,
+ keyText: items[i].key,
+ valueText: items[i].value,
+ onDelete: onDelete != null ? () => onDelete!(items[i].key) : null,
+ ),
+ if (i < items.length - 1) const Divider(height: 1),
+ ],
+ ],
+ );
+ }
+}
+
+class CollapsibleList extends StatefulWidget {
+ final String sectionKey;
+ final List items;
+ final String emptyText;
+ final void Function(String item) onDelete;
+ final int maxVisible;
+ final bool loading;
+
+ const CollapsibleList({
+ super.key,
+ required this.sectionKey,
+ required this.items,
+ required this.emptyText,
+ required this.onDelete,
+ this.maxVisible = 5,
+ this.loading = false,
+ });
+
+ @override
+ State createState() => _CollapsibleListState();
+}
+
+class _CollapsibleListState extends State {
+ bool _expanded = false;
+
+ @override
+ Widget build(BuildContext context) {
+ if (widget.items.isEmpty) {
+ return widget.loading
+ ? LoadingState(sectionKey: widget.sectionKey)
+ : EmptyState(text: widget.emptyText, sectionKey: widget.sectionKey);
+ }
+
+ final showAll = _expanded || widget.items.length <= widget.maxVisible;
+ final visibleItems =
+ showAll ? widget.items : widget.items.take(widget.maxVisible).toList();
+ final remaining = widget.items.length - widget.maxVisible;
+
+ return Column(
+ children: [
+ for (var i = 0; i < visibleItems.length; i++) ...[
+ SingleItem(
+ key: ValueKey(visibleItems[i]),
+ sectionKey: widget.sectionKey,
+ text: visibleItems[i],
+ onDelete: () => widget.onDelete(visibleItems[i]),
+ ),
+ if (i < visibleItems.length - 1) const Divider(height: 1),
+ ],
+ if (!showAll && remaining > 0)
+ GestureDetector(
+ onTap: () => setState(() => _expanded = true),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4),
+ child: Text(
+ '$remaining more',
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.primary,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/section_card.dart b/examples/demo_fm/lib/widgets/section_card.dart
new file mode 100644
index 00000000..5e261f87
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/section_card.dart
@@ -0,0 +1,77 @@
+import 'package:flutter/material.dart';
+
+import '../theme.dart';
+
+class SectionCard extends StatelessWidget {
+ final String title;
+ final VoidCallback? onInfoTap;
+ final String? sectionKey;
+ final Widget child;
+
+ const SectionCard({
+ super.key,
+ required this.title,
+ this.onInfoTap,
+ this.sectionKey,
+ required this.child,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Semantics(
+ identifier: sectionKey != null ? '${sectionKey}_section' : null,
+ container: true,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Section header (outside card, ALL CAPS like reference)
+ Padding(
+ padding: EdgeInsets.only(bottom: onInfoTap != null ? 0 : AppSpacing.gap),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ title.toUpperCase(),
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: AppColors.osGrey700,
+ letterSpacing: 0.5,
+ ),
+ ),
+ ),
+ if (onInfoTap != null)
+ Transform.translate(
+ offset: const Offset(16, 0),
+ child: Semantics(
+ identifier: sectionKey != null
+ ? '${sectionKey}_info_icon'
+ : null,
+ container: true,
+ child: IconButton(
+ onPressed: onInfoTap,
+ icon: Icon(
+ Icons.info_outline,
+ size: 18,
+ color: AppColors.osGrey500,
+ ),
+ padding: EdgeInsets.zero,
+ constraints: const BoxConstraints(
+ minWidth: 32,
+ minHeight: 32,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ // Card content
+ child,
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/aliases_section.dart b/examples/demo_fm/lib/widgets/sections/aliases_section.dart
new file mode 100644
index 00000000..9f0eb2ba
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/aliases_section.dart
@@ -0,0 +1,82 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../action_button.dart';
+import '../dialogs.dart';
+import '../list_widgets.dart';
+import '../section_card.dart';
+
+class AliasesSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const AliasesSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.watch();
+
+ return SectionCard(
+ title: 'Aliases',
+ sectionKey: 'aliases',
+ onInfoTap: onInfoTap,
+ child: Column(
+ children: [
+ Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: AppSpacing.cardPadding,
+ child: PairList(
+ sectionKey: 'aliases',
+ items: vm.aliasesList,
+ emptyText: 'No aliases added',
+ loading: vm.isLoading,
+ ),
+ ),
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'ADD ALIAS',
+ semanticsLabel: 'add_alias_button',
+ onPressed: () async {
+ final result = await showDialog>(
+ context: context,
+ builder:
+ (_) => const PairInputDialog(
+ title: 'Add Alias',
+ keyLabel: 'Label',
+ valueLabel: 'ID',
+ keySemanticsLabel: 'alias_label_input',
+ valueSemanticsLabel: 'alias_id_input',
+ ),
+ );
+ if (result != null) {
+ vm.addAlias(result.key, result.value);
+ }
+ },
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'ADD MULTIPLE ALIASES',
+ semanticsLabel: 'add_multiple_aliases_button',
+ onPressed: () async {
+ final result = await showDialog>(
+ context: context,
+ builder:
+ (_) => const MultiPairInputDialog(
+ title: 'Add Multiple Aliases',
+ keyLabel: 'Label',
+ valueLabel: 'ID',
+ ),
+ );
+ if (result != null) {
+ vm.addAliases(result);
+ }
+ },
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/app_section.dart b/examples/demo_fm/lib/widgets/sections/app_section.dart
new file mode 100644
index 00000000..64f86205
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/app_section.dart
@@ -0,0 +1,119 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../section_card.dart';
+import '../toggle_row.dart';
+
+class AppSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const AppSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.watch();
+
+ return SectionCard(
+ title: 'App',
+ sectionKey: 'app',
+ onInfoTap: onInfoTap,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // App ID card (single row like reference)
+ Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: AppSpacing.cardPadding,
+ child: Row(
+ children: [
+ Text('App ID', style: Theme.of(context).textTheme.bodyMedium),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Semantics(
+ identifier: 'app_id_value',
+ container: true,
+ child: SelectableText(
+ vm.appId,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ fontFamily: 'monospace',
+ ),
+ textAlign: TextAlign.end,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ AppSpacing.gapBox,
+
+ // Guidance banner
+ SizedBox(
+ width: double.infinity,
+ child: Card(
+ color: AppColors.osWarningBackground,
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Add your own App ID, then rebuild to fully test all functionality.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ GestureDetector(
+ onTap: () => launchUrl(
+ Uri.parse('https://onesignal.com'),
+ mode: LaunchMode.externalApplication,
+ ),
+ child: Text(
+ 'Get your keys at onesignal.com',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: AppColors.osPrimary,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ AppSpacing.gapBox,
+
+ // Consent card
+ Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: AppSpacing.cardPadding,
+ child: Column(
+ children: [
+ ToggleRow(
+ label: 'Consent Required',
+ description: 'Require consent before SDK processes data',
+ value: vm.consentRequired,
+ onChanged: vm.setConsentRequired,
+ ),
+ if (vm.consentRequired) ...[
+ const Divider(),
+ ToggleRow(
+ label: 'Privacy Consent',
+ description: 'Consent given for data collection',
+ value: vm.privacyConsentGiven,
+ onChanged: vm.setPrivacyConsent,
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/custom_events_section.dart b/examples/demo_fm/lib/widgets/sections/custom_events_section.dart
new file mode 100644
index 00000000..d09bfc62
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/custom_events_section.dart
@@ -0,0 +1,45 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../action_button.dart';
+import '../dialogs.dart';
+import '../section_card.dart';
+
+class CustomEventsSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const CustomEventsSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.read();
+
+ return SectionCard(
+ title: 'Custom Events',
+ sectionKey: 'custom_events',
+ onInfoTap: onInfoTap,
+ child: PrimaryButton(
+ label: 'TRACK EVENT',
+ semanticsLabel: 'track_event_button',
+ onPressed: () async {
+ final result = await showDialog>(
+ context: context,
+ builder: (_) => const TrackEventDialog(),
+ );
+ if (result != null) {
+ final name = result['name'] as String;
+ vm.trackEvent(
+ name,
+ result['properties'] as Map?,
+ );
+ if (context.mounted) {
+ context.showSnackBar('Event tracked: $name');
+ }
+ }
+ },
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/emails_section.dart b/examples/demo_fm/lib/widgets/sections/emails_section.dart
new file mode 100644
index 00000000..6b9f4174
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/emails_section.dart
@@ -0,0 +1,63 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../action_button.dart';
+import '../dialogs.dart';
+import '../list_widgets.dart';
+import '../section_card.dart';
+
+class EmailsSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const EmailsSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.watch();
+
+ return SectionCard(
+ title: 'Emails',
+ sectionKey: 'emails',
+ onInfoTap: onInfoTap,
+ child: Column(
+ children: [
+ Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: AppSpacing.cardPadding,
+ child: CollapsibleList(
+ sectionKey: 'emails',
+ items: vm.emailsList,
+ emptyText: 'No emails added',
+ loading: vm.isLoading,
+ onDelete: vm.removeEmail,
+ ),
+ ),
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'ADD EMAIL',
+ semanticsLabel: 'add_email_button',
+ onPressed: () async {
+ final result = await showDialog(
+ context: context,
+ builder:
+ (_) => const SingleInputDialog(
+ title: 'Add Email',
+ fieldLabel: 'Email',
+ keyboardType: TextInputType.emailAddress,
+ semanticsLabel: 'email_input',
+ ),
+ );
+ if (result != null) {
+ vm.addEmail(result);
+ }
+ },
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/in_app_section.dart b/examples/demo_fm/lib/widgets/sections/in_app_section.dart
new file mode 100644
index 00000000..c8abfed3
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/in_app_section.dart
@@ -0,0 +1,37 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../section_card.dart';
+import '../toggle_row.dart';
+
+class InAppSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const InAppSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.watch();
+
+ return SectionCard(
+ title: 'In-App Messaging',
+ sectionKey: 'iam',
+ onInfoTap: onInfoTap,
+ child: Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: AppSpacing.cardPadding,
+ child: ToggleRow(
+ label: 'Pause In-App Messages',
+ description: 'Toggle in-app message display',
+ semanticsLabel: 'pause_iam_toggle',
+ value: vm.iamPaused,
+ onChanged: vm.setIamPaused,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/live_activities_section.dart b/examples/demo_fm/lib/widgets/sections/live_activities_section.dart
new file mode 100644
index 00000000..2a267b80
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/live_activities_section.dart
@@ -0,0 +1,144 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../app_text_field.dart';
+import '../action_button.dart';
+import '../section_card.dart';
+
+class LiveActivitiesSection extends StatefulWidget {
+ final VoidCallback? onInfoTap;
+
+ const LiveActivitiesSection({super.key, this.onInfoTap});
+
+ @override
+ State createState() => _LiveActivitiesSectionState();
+}
+
+class _LiveActivitiesSectionState extends State {
+ late TextEditingController _activityIdController;
+ late TextEditingController _orderNumberController;
+
+ @override
+ void initState() {
+ super.initState();
+ final vm = context.read();
+ _activityIdController = TextEditingController(text: vm.activityId);
+ _orderNumberController = TextEditingController(text: vm.orderNumber);
+ }
+
+ @override
+ void dispose() {
+ _activityIdController.dispose();
+ _orderNumberController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.watch();
+ final activityEmpty = _activityIdController.text.isEmpty;
+
+ return SectionCard(
+ title: 'Live Activities',
+ sectionKey: 'live_activities',
+ onInfoTap: widget.onInfoTap,
+ child: Column(
+ children: [
+ Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: AppSpacing.cardPadding,
+ child: Column(
+ children: [
+ _InputRow(
+ label: 'Activity ID',
+ controller: _activityIdController,
+ onChanged: (value) {
+ vm.setActivityId(value);
+ setState(() {});
+ },
+ ),
+ const SizedBox(height: 4),
+ _InputRow(
+ label: 'Order #',
+ controller: _orderNumberController,
+ onChanged: (value) => vm.setOrderNumber(value),
+ ),
+ ],
+ ),
+ ),
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'START LIVE ACTIVITY',
+ semanticsLabel: 'start_live_activity_button',
+ onPressed: activityEmpty ? null : () => vm.startLiveActivity(),
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'UPDATE → ${vm.nextStatusLabel}',
+ semanticsLabel: 'update_live_activity_button',
+ onPressed: activityEmpty || vm.isLaUpdating || !vm.hasApiKey
+ ? null
+ : () => vm.updateLiveActivity(),
+ ),
+ AppSpacing.gapBox,
+ DestructiveButton(
+ label: 'END LIVE ACTIVITY',
+ semanticsLabel: 'end_live_activity_button',
+ onPressed: activityEmpty || !vm.hasApiKey
+ ? null
+ : () => vm.endLiveActivity(),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _InputRow extends StatelessWidget {
+ final String label;
+ final TextEditingController controller;
+ final ValueChanged onChanged;
+
+ const _InputRow({
+ required this.label,
+ required this.controller,
+ required this.onChanged,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ children: [
+ SizedBox(
+ width: 80,
+ child: Text(
+ label,
+ style: const TextStyle(
+ fontSize: 14,
+ color: AppColors.osGrey600,
+ ),
+ ),
+ ),
+ Expanded(
+ child: AppTextField(
+ controller: controller,
+ textAlign: TextAlign.right,
+ style: const TextStyle(fontSize: 14, color: Color(0xFF212121)),
+ decoration: const InputDecoration(
+ border: InputBorder.none,
+ enabledBorder: InputBorder.none,
+ focusedBorder: InputBorder.none,
+ contentPadding: EdgeInsets.symmetric(vertical: 4),
+ isDense: true,
+ ),
+ onChanged: onChanged,
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/location_section.dart b/examples/demo_fm/lib/widgets/sections/location_section.dart
new file mode 100644
index 00000000..82607c4e
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/location_section.dart
@@ -0,0 +1,59 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../action_button.dart';
+import '../section_card.dart';
+import '../toggle_row.dart';
+
+class LocationSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const LocationSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.watch();
+
+ return SectionCard(
+ title: 'Location',
+ sectionKey: 'location',
+ onInfoTap: onInfoTap,
+ child: Column(
+ children: [
+ Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: AppSpacing.cardPadding,
+ child: ToggleRow(
+ label: 'Location Shared',
+ description: 'Share device location with OneSignal',
+ semanticsLabel: 'location_shared_toggle',
+ value: vm.locationShared,
+ onChanged: vm.setLocationShared,
+ ),
+ ),
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'PROMPT LOCATION',
+ semanticsLabel: 'prompt_location_button',
+ onPressed: vm.promptLocation,
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'CHECK LOCATION SHARED',
+ semanticsLabel: 'check_location_button',
+ onPressed: () async {
+ final shared = await vm.checkLocationShared();
+ if (context.mounted) {
+ context.showSnackBar('Location shared: $shared');
+ }
+ },
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/outcomes_section.dart b/examples/demo_fm/lib/widgets/sections/outcomes_section.dart
new file mode 100644
index 00000000..c1fb50a9
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/outcomes_section.dart
@@ -0,0 +1,55 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../action_button.dart';
+import '../dialogs.dart';
+import '../section_card.dart';
+
+class OutcomesSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const OutcomesSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.read();
+
+ return SectionCard(
+ title: 'Outcomes',
+ sectionKey: 'outcomes',
+ onInfoTap: onInfoTap,
+ child: PrimaryButton(
+ label: 'SEND OUTCOME',
+ semanticsLabel: 'send_outcome_button',
+ onPressed: () async {
+ final result = await showDialog>(
+ context: context,
+ builder: (_) => const OutcomeDialog(),
+ );
+ if (result != null) {
+ final type = result['type'] as OutcomeType;
+ final name = result['name'] as String;
+ String snackbarMessage;
+ switch (type) {
+ case OutcomeType.normal:
+ vm.sendOutcome(name);
+ snackbarMessage = 'Outcome sent: $name';
+ case OutcomeType.unique:
+ vm.sendUniqueOutcome(name);
+ snackbarMessage = 'Unique outcome sent: $name';
+ case OutcomeType.withValue:
+ final value = result['value'] as double;
+ vm.sendOutcomeWithValue(name, value);
+ snackbarMessage = 'Outcome sent: $name = $value';
+ }
+ if (context.mounted) {
+ context.showSnackBar(snackbarMessage);
+ }
+ }
+ },
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/push_section.dart b/examples/demo_fm/lib/widgets/sections/push_section.dart
new file mode 100644
index 00000000..ee9b3aee
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/push_section.dart
@@ -0,0 +1,77 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../action_button.dart';
+import '../section_card.dart';
+import '../toggle_row.dart';
+
+class PushSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const PushSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.watch();
+
+ return SectionCard(
+ title: 'Push',
+ sectionKey: 'push',
+ onInfoTap: onInfoTap,
+ child: Column(
+ children: [
+ Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: AppSpacing.cardPadding,
+ child: Column(
+ children: [
+ Row(
+ children: [
+ Text(
+ 'Push ID',
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Semantics(
+ identifier: 'push_id_value',
+ container: true,
+ child: SelectableText(
+ vm.pushSubscriptionId ?? '—',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ fontFamily: 'monospace',
+ ),
+ textAlign: TextAlign.end,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const Divider(),
+ ToggleRow(
+ label: 'Enabled',
+ value: vm.pushEnabled,
+ semanticsLabel: 'push_enabled_toggle',
+ onChanged: vm.hasNotificationPermission
+ ? vm.togglePush
+ : null,
+ ),
+ ],
+ ),
+ ),
+ ),
+ if (!vm.hasNotificationPermission) ...[
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'PROMPT PUSH',
+ onPressed: vm.promptPush,
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/send_iam_section.dart b/examples/demo_fm/lib/widgets/sections/send_iam_section.dart
new file mode 100644
index 00000000..7830040e
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/send_iam_section.dart
@@ -0,0 +1,35 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../models/in_app_message_type.dart';
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../action_button.dart';
+import '../section_card.dart';
+
+class SendIamSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const SendIamSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.read();
+
+ return SectionCard(
+ title: 'Send In-App Message',
+ sectionKey: 'send_iam',
+ onInfoTap: onInfoTap,
+ child: Column(
+ spacing: AppSpacing.gap,
+ children: InAppMessageType.values.map((type) {
+ return PrimaryButton(
+ label: type.label.toUpperCase(),
+ onPressed: () => vm.sendInAppMessage(type),
+ semanticsLabel: 'send_iam_${type.triggerValue}_button',
+ );
+ }).toList(),
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/send_push_section.dart b/examples/demo_fm/lib/widgets/sections/send_push_section.dart
new file mode 100644
index 00000000..3ea7a965
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/send_push_section.dart
@@ -0,0 +1,67 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../models/notification_type.dart';
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../action_button.dart';
+import '../dialogs.dart';
+import '../section_card.dart';
+
+class SendPushSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const SendPushSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.read();
+
+ return SectionCard(
+ title: 'Send Push Notification',
+ sectionKey: 'send_push',
+ onInfoTap: onInfoTap,
+ child: Column(
+ children: [
+ PrimaryButton(
+ label: 'SIMPLE',
+ semanticsLabel: 'send_simple_button',
+ onPressed: () => vm.sendNotification(NotificationType.simple),
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'WITH IMAGE',
+ semanticsLabel: 'send_image_button',
+ onPressed: () => vm.sendNotification(NotificationType.withImage),
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'WITH SOUND',
+ semanticsLabel: 'send_sound_button',
+ onPressed: () => vm.sendNotification(NotificationType.withSound),
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'CUSTOM',
+ semanticsLabel: 'send_custom_button',
+ onPressed: () async {
+ final result = await showDialog>(
+ context: context,
+ builder: (_) => const CustomNotificationDialog(),
+ );
+ if (result != null) {
+ vm.sendCustomNotification(result['title']!, result['body']!);
+ }
+ },
+ ),
+ AppSpacing.gapBox,
+ DestructiveButton(
+ label: 'CLEAR ALL',
+ semanticsLabel: 'clear_all_button',
+ onPressed: vm.clearAllNotifications,
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/sms_section.dart b/examples/demo_fm/lib/widgets/sections/sms_section.dart
new file mode 100644
index 00000000..b7426d6f
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/sms_section.dart
@@ -0,0 +1,63 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../action_button.dart';
+import '../dialogs.dart';
+import '../list_widgets.dart';
+import '../section_card.dart';
+
+class SmsSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const SmsSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.watch();
+
+ return SectionCard(
+ title: 'SMS',
+ sectionKey: 'sms',
+ onInfoTap: onInfoTap,
+ child: Column(
+ children: [
+ Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: AppSpacing.cardPadding,
+ child: CollapsibleList(
+ sectionKey: 'sms',
+ items: vm.smsNumbersList,
+ emptyText: 'No SMS added',
+ loading: vm.isLoading,
+ onDelete: vm.removeSms,
+ ),
+ ),
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'ADD SMS',
+ semanticsLabel: 'add_sms_button',
+ onPressed: () async {
+ final result = await showDialog(
+ context: context,
+ builder:
+ (_) => const SingleInputDialog(
+ title: 'Add SMS',
+ fieldLabel: 'SMS Number',
+ keyboardType: TextInputType.phone,
+ semanticsLabel: 'sms_input',
+ ),
+ );
+ if (result != null) {
+ vm.addSms(result);
+ }
+ },
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/tags_section.dart b/examples/demo_fm/lib/widgets/sections/tags_section.dart
new file mode 100644
index 00000000..041aa3d6
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/tags_section.dart
@@ -0,0 +1,98 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../action_button.dart';
+import '../dialogs.dart';
+import '../list_widgets.dart';
+import '../section_card.dart';
+
+class TagsSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const TagsSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.watch();
+
+ return SectionCard(
+ title: 'Tags',
+ sectionKey: 'tags',
+ onInfoTap: onInfoTap,
+ child: Column(
+ children: [
+ Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: AppSpacing.cardPadding,
+ child: PairList(
+ sectionKey: 'tags',
+ items: vm.tagsList,
+ emptyText: 'No tags added',
+ loading: vm.isLoading,
+ onDelete: vm.removeTag,
+ ),
+ ),
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'ADD TAG',
+ semanticsLabel: 'add_tag_button',
+ onPressed: () async {
+ final result = await showDialog>(
+ context: context,
+ builder:
+ (_) => const PairInputDialog(
+ title: 'Add Tag',
+ keySemanticsLabel: 'tag_key_input',
+ valueSemanticsLabel: 'tag_value_input',
+ ),
+ );
+ if (result != null) {
+ vm.addTag(result.key, result.value);
+ }
+ },
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'ADD MULTIPLE TAGS',
+ semanticsLabel: 'add_multiple_tags_button',
+ onPressed: () async {
+ final result = await showDialog>(
+ context: context,
+ builder:
+ (_) =>
+ const MultiPairInputDialog(title: 'Add Multiple Tags'),
+ );
+ if (result != null) {
+ vm.addTags(result);
+ }
+ },
+ ),
+ if (vm.tagsList.isNotEmpty) ...[
+ AppSpacing.gapBox,
+ DestructiveButton(
+ label: 'REMOVE TAGS',
+ semanticsLabel: 'remove_tags_button',
+ onPressed: () async {
+ final result = await showDialog>(
+ context: context,
+ builder:
+ (_) => MultiSelectRemoveDialog(
+ title: 'Remove Tags',
+ items: vm.tagsList,
+ ),
+ );
+ if (result != null) {
+ vm.removeSelectedTags(result);
+ }
+ },
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/triggers_section.dart b/examples/demo_fm/lib/widgets/sections/triggers_section.dart
new file mode 100644
index 00000000..7f650ae8
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/triggers_section.dart
@@ -0,0 +1,101 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../action_button.dart';
+import '../dialogs.dart';
+import '../list_widgets.dart';
+import '../section_card.dart';
+
+class TriggersSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const TriggersSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.watch();
+
+ return SectionCard(
+ title: 'Triggers',
+ sectionKey: 'triggers',
+ onInfoTap: onInfoTap,
+ child: Column(
+ children: [
+ Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: AppSpacing.cardPadding,
+ child: PairList(
+ sectionKey: 'triggers',
+ items: vm.triggersList,
+ emptyText: 'No triggers added',
+ onDelete: vm.removeTrigger,
+ ),
+ ),
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'ADD TRIGGER',
+ semanticsLabel: 'add_trigger_button',
+ onPressed: () async {
+ final result = await showDialog>(
+ context: context,
+ builder: (_) => const PairInputDialog(
+ title: 'Add Trigger',
+ keySemanticsLabel: 'trigger_key_input',
+ valueSemanticsLabel: 'trigger_value_input',
+ ),
+ );
+ if (result != null) {
+ vm.addTrigger(result.key, result.value);
+ }
+ },
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: 'ADD MULTIPLE TRIGGERS',
+ semanticsLabel: 'add_multiple_triggers_button',
+ onPressed: () async {
+ final result = await showDialog>(
+ context: context,
+ builder: (_) => const MultiPairInputDialog(
+ title: 'Add Multiple Triggers',
+ ),
+ );
+ if (result != null) {
+ vm.addTriggers(result);
+ }
+ },
+ ),
+ if (vm.triggersList.isNotEmpty) ...[
+ AppSpacing.gapBox,
+ DestructiveButton(
+ label: 'REMOVE TRIGGERS',
+ semanticsLabel: 'remove_triggers_button',
+ onPressed: () async {
+ final result = await showDialog>(
+ context: context,
+ builder: (_) => MultiSelectRemoveDialog(
+ title: 'Remove Triggers',
+ items: vm.triggersList,
+ ),
+ );
+ if (result != null) {
+ vm.removeSelectedTriggers(result);
+ }
+ },
+ ),
+ AppSpacing.gapBox,
+ DestructiveButton(
+ label: 'CLEAR ALL TRIGGERS',
+ semanticsLabel: 'clear_triggers_button',
+ onPressed: vm.clearAllTriggers,
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/sections/user_section.dart b/examples/demo_fm/lib/widgets/sections/user_section.dart
new file mode 100644
index 00000000..efd53193
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/sections/user_section.dart
@@ -0,0 +1,108 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../theme.dart';
+import '../../viewmodels/app_viewmodel.dart';
+import '../dialogs.dart';
+import '../action_button.dart';
+import '../section_card.dart';
+
+class UserSection extends StatelessWidget {
+ final VoidCallback? onInfoTap;
+
+ const UserSection({super.key, this.onInfoTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final vm = context.watch();
+
+ return SectionCard(
+ title: 'User',
+ sectionKey: 'user',
+ onInfoTap: onInfoTap,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: AppSpacing.cardPadding,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text('Status', style: Theme.of(context).textTheme.bodyMedium),
+ Semantics(
+ identifier: 'user_status_value',
+ container: true,
+ child: Text(
+ vm.isLoggedIn ? 'Logged In' : 'Anonymous',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ fontFamily: 'monospace',
+ color: vm.isLoggedIn
+ ? AppColors.osSuccess
+ : AppColors.osGrey600,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const Divider(),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ 'External ID',
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ Semantics(
+ identifier: 'user_external_id_value',
+ container: true,
+ child: SelectableText(
+ vm.isLoggedIn ? (vm.externalUserId ?? '') : '—',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ fontFamily: 'monospace',
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ AppSpacing.gapBox,
+ PrimaryButton(
+ label: vm.isLoggedIn ? 'SWITCH USER' : 'LOGIN USER',
+ semanticsLabel: 'login_user_button',
+ onPressed: () async {
+ final result = await showDialog(
+ context: context,
+ builder: (_) => const SingleInputDialog(
+ title: 'Login User',
+ fieldLabel: 'External User Id',
+ confirmLabel: 'Login',
+ semanticsLabel: 'login_user_id_input',
+ ),
+ );
+ if (result != null && context.mounted) {
+ await vm.loginUser(result);
+ }
+ },
+ ),
+ if (vm.isLoggedIn) ...[
+ AppSpacing.gapBox,
+ DestructiveButton(
+ label: 'LOGOUT USER',
+ semanticsLabel: 'logout_user_button',
+ onPressed: () async {
+ await vm.logoutUser();
+ },
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
diff --git a/examples/demo_fm/lib/widgets/toggle_row.dart b/examples/demo_fm/lib/widgets/toggle_row.dart
new file mode 100644
index 00000000..8bfa0428
--- /dev/null
+++ b/examples/demo_fm/lib/widgets/toggle_row.dart
@@ -0,0 +1,49 @@
+import 'package:flutter/material.dart';
+
+import '../theme.dart';
+
+class ToggleRow extends StatelessWidget {
+ final String label;
+ final String? description;
+ final bool value;
+ final ValueChanged? onChanged;
+ final String? semanticsLabel;
+
+ const ToggleRow({
+ super.key,
+ required this.label,
+ this.description,
+ required this.value,
+ this.onChanged,
+ this.semanticsLabel,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ Widget tile = SwitchListTile(
+ title: Text(label, style: Theme.of(context).textTheme.bodyMedium),
+ subtitle: description != null
+ ? Text(
+ description!,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: AppColors.osGrey600,
+ ),
+ )
+ : null,
+ value: value,
+ onChanged: onChanged != null ? (v) => onChanged!(v) : null,
+ contentPadding: EdgeInsets.zero,
+ dense: true,
+ visualDensity: VisualDensity.compact,
+ );
+ if (semanticsLabel != null) {
+ tile = Semantics(
+ identifier: semanticsLabel,
+ container: true,
+ toggled: value,
+ child: tile,
+ );
+ }
+ return tile;
+ }
+}
diff --git a/examples/demo_fm/pubspec.yaml b/examples/demo_fm/pubspec.yaml
new file mode 100644
index 00000000..6eaf8ee7
--- /dev/null
+++ b/examples/demo_fm/pubspec.yaml
@@ -0,0 +1,46 @@
+name: demo
+description: OneSignal Flutter SDK Demo App
+publish_to: 'none'
+version: 1.0.0+1
+
+environment:
+ sdk: ^3.7.0
+
+dependencies:
+ flutter:
+ sdk: flutter
+ # Issue #1138 reproduction: use the in-tree SDK (currently 5.5.5) — this
+ # is the "broken" combo where the affected users report
+ # addClickListener never firing on Android background/killed taps.
+ onesignal_flutter:
+ path: ../../
+ # Firebase Messaging added to mimic the affected users' setup
+ # (they use FCM alongside OneSignal for a chat feature).
+ firebase_core: ^3.8.0
+ firebase_messaging: ^15.1.5
+ provider: ^6.1.0
+ shared_preferences: ^2.3.0
+ http: ^1.2.0
+ url_launcher: ^6.2.0
+ flutter_svg: ^2.0.0
+ flutter_dotenv: ^5.2.1
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+ flutter_lints: ^5.0.0
+ flutter_launcher_icons: ^0.14.3
+
+flutter_launcher_icons:
+ android: true
+ ios: true
+ remove_alpha_ios: true
+ image_path: "assets/onesignal_logo_icon_padded.png"
+ adaptive_icon_background: "#FFFFFF"
+ adaptive_icon_foreground: "assets/onesignal_logo_icon_padded.png"
+
+flutter:
+ uses-material-design: true
+ assets:
+ - assets/onesignal_logo.svg
+ - .env
diff --git a/examples/demo_fm/tools/send_fcm.sh b/examples/demo_fm/tools/send_fcm.sh
new file mode 100755
index 00000000..3e459fa7
--- /dev/null
+++ b/examples/demo_fm/tools/send_fcm.sh
@@ -0,0 +1,119 @@
+#!/usr/bin/env bash
+#
+# Send a non-OneSignal push directly through the FCM HTTP v1 API, to exercise
+# the FlutterFire path in examples/demo_fm (onMessage / onBackgroundMessage /
+# onMessageOpenedApp / getInitialMessage). See examples/demo_fm/README.md.
+#
+# Usage:
+# FCM_TOKEN= ./send_fcm.sh notif # notification message
+# FCM_TOKEN= ./send_fcm.sh data # data-only message
+# FCM_TOKEN= ./send_fcm.sh both # notification + data
+# ./send_fcm.sh notif # token as 2nd arg
+#
+# Modes: notif (alert), data (silent/background; unreliable on iOS), both
+# (alert + data payload; use to test the data path on iOS).
+#
+# Auth (first that works wins):
+# 1. $ACCESS_TOKEN if already exported
+# 2. service-account.json next to this script -> minted via oauth2l/gcloud
+# 3. gcloud auth print-access-token
+#
+# Project id is read from android/app/google-services.json.
+set -euo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+gs="$here/../android/app/google-services.json"
+
+mode="${1:-notif}"
+token="${2:-${FCM_TOKEN:-}}"
+
+if [[ -z "$token" ]]; then
+ echo "ERROR: no device token. Pass as 2nd arg or set FCM_TOKEN." >&2
+ echo "Grab it from logs: adb logcat | rg '\\[FCM token\\]'" >&2
+ exit 1
+fi
+
+if [[ ! -f "$gs" ]]; then
+ echo "ERROR: $gs not found (drop your google-services.json there)." >&2
+ exit 1
+fi
+
+project_id="$(grep -o '"project_id": *"[^"]*"' "$gs" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
+if [[ -z "$project_id" ]]; then
+ echo "ERROR: could not read project_id from $gs" >&2
+ exit 1
+fi
+
+# Mint an OAuth access token straight from the service account (scope:
+# firebase.messaging) using only openssl + python3. This authenticates as the
+# project's service account regardless of any ambient gcloud/ADC credentials,
+# which is what FCM HTTP v1 requires (a personal/ADC token gets rejected with
+# THIRD_PARTY_AUTH_ERROR).
+mint_sa_token() {
+ local sa="$1" email aud now exp hdr claim body sig jwt
+ email="$(python3 -c "import json;print(json.load(open('$sa'))['client_email'])")"
+ aud="$(python3 -c "import json;print(json.load(open('$sa')).get('token_uri','https://oauth2.googleapis.com/token'))")"
+ now="$(date +%s)"
+ exp="$((now + 3600))"
+ b64url() { openssl base64 -e -A | tr '+/' '-_' | tr -d '='; }
+ hdr="$(printf '{"alg":"RS256","typ":"JWT"}' | b64url)"
+ claim="$(printf '{"iss":"%s","scope":"https://www.googleapis.com/auth/firebase.messaging","aud":"%s","iat":%s,"exp":%s}' "$email" "$aud" "$now" "$exp" | b64url)"
+ body="$hdr.$claim"
+ sig="$(printf '%s' "$body" | openssl dgst -sha256 -sign <(python3 -c "import json;print(json.load(open('$sa'))['private_key'])") | b64url)"
+ jwt="$body.$sig"
+ curl -sS -X POST "$aud" \
+ -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
+ --data-urlencode "assertion=$jwt" \
+ | python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))"
+}
+
+# Resolve an OAuth access token (first that works wins):
+# 1. $ACCESS_TOKEN if exported
+# 2. service-account.json next to this script (preferred)
+# 3. gcloud's active credentials
+access_token="${ACCESS_TOKEN:-}"
+sa="$here/service-account.json"
+if [[ -z "$access_token" && -f "$sa" ]]; then
+ access_token="$(mint_sa_token "$sa" 2>/dev/null || true)"
+fi
+if [[ -z "$access_token" ]]; then
+ access_token="$(gcloud auth print-access-token 2>/dev/null || true)"
+fi
+if [[ -z "$access_token" ]]; then
+ echo "ERROR: no access token. Export ACCESS_TOKEN, drop a service-account.json" >&2
+ echo "next to this script, or run 'gcloud auth login'." >&2
+ exit 1
+fi
+
+case "$mode" in
+ notif)
+ # Visible notification on both platforms (iOS shows an alert via APNs).
+ payload='{"message":{"token":"'"$token"'","notification":{"title":"FCM direct","body":"non-OneSignal push"},"apns":{"payload":{"aps":{"sound":"default"}}}}}'
+ ;;
+ data)
+ # Data-only message. android.priority=high + apns content-available=1 so the
+ # app is woken in the background/killed state on both platforms.
+ # NOTE: on iOS this is a silent push and is throttled/unreliable, especially
+ # on the simulator and in the foreground. Use 'both' to test data on iOS, or
+ # test 'data' on Android where data-only delivery is reliable.
+ payload='{"message":{"token":"'"$token"'","android":{"priority":"high"},"apns":{"headers":{"apns-priority":"5"},"payload":{"aps":{"content-available":1}}},"data":{"alert":"data only","source":"fcm-direct"}}}'
+ ;;
+ both)
+ # Notification + data. Delivered as a normal alert (reliable on iOS) while
+ # still carrying a data payload, so onMessage/onMessageOpenedApp fire with
+ # data populated. Use this to exercise the data path on iOS.
+ payload='{"message":{"token":"'"$token"'","notification":{"title":"FCM direct","body":"notification + data"},"android":{"priority":"high"},"apns":{"payload":{"aps":{"sound":"default"}}},"data":{"alert":"hello","source":"fcm-direct"}}}'
+ ;;
+ *)
+ echo "ERROR: unknown mode '$mode' (use 'notif', 'data', or 'both')." >&2
+ exit 1
+ ;;
+esac
+
+echo "Sending '$mode' message via project $project_id ..." >&2
+curl -sS -X POST \
+ "https://fcm.googleapis.com/v1/projects/$project_id/messages:send" \
+ -H "Authorization: Bearer $access_token" \
+ -H "Content-Type: application/json" \
+ -d "$payload"
+echo
diff --git a/examples/demo_pods/lib/main.dart b/examples/demo_pods/lib/main.dart
index 898f1712..9075802c 100644
--- a/examples/demo_pods/lib/main.dart
+++ b/examples/demo_pods/lib/main.dart
@@ -59,7 +59,7 @@ Future main() async {
debugPrint('IAM did dismiss: ${event.message.messageId}');
});
OneSignal.InAppMessages.addClickListener((event) {
- debugPrint('IAM clicked: ${event.result.actionId}');
+ debugPrint('IAM clicked: ${event.message.messageId}');
});
// Register notification listeners
diff --git a/examples/demo_pods/lib/viewmodels/app_viewmodel.dart b/examples/demo_pods/lib/viewmodels/app_viewmodel.dart
index 1d5e5715..4d59c6b9 100644
--- a/examples/demo_pods/lib/viewmodels/app_viewmodel.dart
+++ b/examples/demo_pods/lib/viewmodels/app_viewmodel.dart
@@ -165,8 +165,16 @@ class AppViewModel extends ChangeNotifier {
OneSignal.User.pushSubscription.addObserver((state) {
_pushSubscriptionId = state.current.id;
_pushEnabled = state.current.optedIn;
+ String fmtToken(String? t) {
+ if (t == null || t.isEmpty) return 'null';
+ return t.length > 8 ? '${t.substring(0, 8)}…' : t;
+ }
+
debugPrint(
- 'Push subscription changed: id=${state.current.id}, optedIn=${state.current.optedIn}',
+ 'Push subscription changed: '
+ 'id=${state.previous.id ?? 'null'} → ${state.current.id ?? 'null'}, '
+ 'optedIn=${state.previous.optedIn} → ${state.current.optedIn}, '
+ 'token=${fmtToken(state.previous.token)} → ${fmtToken(state.current.token)}',
);
notifyListeners();
});
diff --git a/ios/onesignal_flutter/Sources/onesignal_flutter/OSFlutterNotifications.m b/ios/onesignal_flutter/Sources/onesignal_flutter/OSFlutterNotifications.m
index 027596f6..402f4293 100644
--- a/ios/onesignal_flutter/Sources/onesignal_flutter/OSFlutterNotifications.m
+++ b/ios/onesignal_flutter/Sources/onesignal_flutter/OSFlutterNotifications.m
@@ -138,10 +138,21 @@ - (void)onNotificationPermissionDidChange:(BOOL)permission {
#pragma mark Received in Notification Lifecycle Event
- (void)onWillDisplayNotification:(OSNotificationWillDisplayEvent *)event {
- self.onWillDisplayEventCache[event.notification.notificationId] = event;
+ NSString *notificationId = event.notification.notificationId;
+ // A duplicate id already in the cache means this is a re-entrant willDisplay
+ // caused by another UNUserNotificationCenterDelegate (e.g.
+ // firebase_messaging) forwarding the original willPresent back into
+ // OneSignal's swizzled handler.
+ BOOL isReentrantDuplicate =
+ self.onWillDisplayEventCache[notificationId] != nil;
+ self.onWillDisplayEventCache[notificationId] = event;
/// Our bridge layer needs to preventDefault so that the Flutter listener has
/// time to preventDefault before the notification is displayed
[event preventDefault];
+ // Only forward the first dispatch to Flutter so the listener fires once.
+ if (isReentrantDuplicate) {
+ return;
+ }
[self.channel invokeMethod:@"OneSignal#onWillDisplayNotification"
arguments:event.toJson];
}
diff --git a/lib/src/notifications.dart b/lib/src/notifications.dart
index bc4d4fb0..45efa0fd 100644
--- a/lib/src/notifications.dart
+++ b/lib/src/notifications.dart
@@ -16,6 +16,16 @@ class OneSignalNotifications {
// event listeners
List _clickListeners =
[];
+ // Clicks that arrived before the app's first click listener was registered
+ // (e.g. a cold-start / cached-engine attach drains the native queue before
+ // addClickListener runs). Buffered here instead of dropped, then flushed once
+ // a listener registers. Only filled during this initial window: after the
+ // first registration clicks are delivered or dropped, never buffered again.
+ List _pendingClickEvents =
+ [];
+ // Upper bound on buffered pre-registration clicks so the buffer can't grow
+ // unbounded if a listener is never registered. Oldest events drop past this.
+ static const int _maxPendingClickEvents = 50;
List _willDisplayListeners =
[];
@@ -125,9 +135,20 @@ class OneSignalNotifications {
Future _handleMethod(MethodCall call) async {
if (call.method == 'OneSignal#onClickNotification') {
- for (var listener in _clickListeners) {
- listener(
- OSNotificationClickEvent(call.arguments.cast()));
+ var event =
+ OSNotificationClickEvent(call.arguments.cast());
+ if (_clickListeners.isNotEmpty) {
+ for (var listener in _clickListeners) {
+ listener(event);
+ }
+ } else if (!_clickHandlerRegistered) {
+ // Buffer only before the app's first listener registration. Once a
+ // listener has been registered (and possibly removed) drop instead, so
+ // removing a listener doesn't silently accumulate clicks forever.
+ if (_pendingClickEvents.length >= _maxPendingClickEvents) {
+ _pendingClickEvents.removeAt(0);
+ }
+ _pendingClickEvents.add(event);
}
} else if (call.method == 'OneSignal#onWillDisplayNotification') {
for (var listener in _willDisplayListeners) {
@@ -180,6 +201,14 @@ class OneSignalNotifications {
_channel.invokeMethod("OneSignal#addNativeClickListener");
}
_clickListeners.add(listener);
+ // Deliver any clicks that arrived before a listener existed.
+ if (_pendingClickEvents.isNotEmpty) {
+ var pending = _pendingClickEvents;
+ _pendingClickEvents = [];
+ for (var event in pending) {
+ listener(event);
+ }
+ }
}
void removeClickListener(OnNotificationClickListener listener) {