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) {