Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,6 @@ flutter_macos_arm64_stable.zip

# Claude Code
.claude/settings.local.json

# SwiftPM build artifacts
ios/klaviyo_flutter_sdk/.build/
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

### What's New

- **Push action events** — New `onPushAction` stream emits typed `KlaviyoPushAction` events (`OpenWebUrl`, `ActionButtonTapped`) when a user taps a Klaviyo push carrying an Open External URL (`open_url`) action or an action button. URLs are forwarded verbatim, including special schemes such as `mailto:`, `tel:`, and `sms:`. On iOS both body `open_url` taps and action-button taps surface; on Android `open_url` is handled externally by the native SDK, so only `deep_link` / `open_app` action-button taps surface.
- **Native SDK upgrade** — Now consuming `KlaviyoSwift ~> 5.4.0` and `klaviyo-android-sdk 4.5.0`, which adds the non-web `open_url` scheme allowlist (`mailto:`, `tel:`, `sms:`) on Android.

## 0.2.0 - 2026-05-04

### What's New
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,46 @@ subscription.cancel();
| `buttonLabel` | `String` | `FormCtaClicked` only | Label of the tapped CTA button |
| `deepLinkUrl` | `String` | `FormCtaClicked` only | Deep link URL configured for the CTA |

## Push Action Events

Subscribe to `onPushAction` to observe taps on Klaviyo pushes that carry an **Open External URL** (`open_url`) action or **action buttons**. Events are typed via the sealed `KlaviyoPushAction` class, so Dart pattern matching is exhaustive.

```dart
import 'dart:async';
import 'package:klaviyo_flutter_sdk/klaviyo_flutter_sdk.dart';
import 'package:logging/logging.dart';

final _logger = Logger('MyApp');

final StreamSubscription<KlaviyoPushAction> subscription =
KlaviyoSDK().onPushAction.listen((action) {
switch (action) {
case OpenWebUrl():
_logger.info('Open external URL: ${action.url}');
case ActionButtonTapped():
_logger.info(
'Action button "${action.label}" (${action.action}) — '
'id: ${action.buttonId}, url: ${action.url}',
);
}
});

// Cancel when no longer needed (e.g. in dispose())
subscription.cancel();
```

**Field reference:**

| Field | Type | Subtypes | Notes |
|-------|------|----------|-------|
| `url` | `String` | `OpenWebUrl` | The external URL. May be `http(s)` or a special scheme such as `mailto:`, `tel:`, `sms:` |
| `buttonId` | `String` | `ActionButtonTapped` | Identifier of the tapped action button |
| `label` | `String` | `ActionButtonTapped` | Display label of the tapped button |
| `action` | `String` | `ActionButtonTapped` | One of `open_app`, `deep_link`, `open_url` |
| `url` | `String?` | `ActionButtonTapped` | URL for `deep_link` / `open_url` buttons; `null` for `open_app` |

> **Platform behavior:** The native SDK still performs the actual navigation (opening the URL or launching the app); these events let your app react in parallel. On **iOS**, both body `open_url` taps and action-button taps surface. On **Android**, `open_url` is dispatched to an external handler by the native SDK and does **not** reach the app, so only `deep_link` / `open_app` action-button taps surface on Android.

## Deep Linking

Klaviyo supports [Deep Links](https://help.klaviyo.com/hc/en-us/articles/14750403974043) for tracking link clicks and navigating to specific content within your app. This works with push notifications, in-app messages, and Klaviyo tracking links.
Expand Down Expand Up @@ -818,6 +858,7 @@ The main SDK class. All methods are accessed via `KlaviyoSDK()`.
| `apiKey` | `String?` | Current API key |
| `onPushNotification` | `Stream<Map<String, dynamic>>` | Push notification events |
| `onFormLifecycleEvent` | `Stream<FormLifecycleEvent>` | Typed in-app form lifecycle events |
| `onPushAction` | `Stream<KlaviyoPushAction>` | Typed `open_url` / action-button push tap events |

### Models

Expand Down
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ android {
}
}

def klaviyoSdkVersion = "4.4.0"
def klaviyoSdkVersion = "4.5.0"

// Whether to include the full forms module (in-app forms rendering).
// Set klaviyoIncludeForms=false in your host app's gradle.properties to exclude forms.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -602,9 +602,51 @@ class KlaviyoFlutterSdkPlugin :
"data" to notificationData,
),
)

forwardActionButtonTap(notificationData)
}
} catch (e: Exception) {
Registry.log.error("Error handling push: ${e.message}", e)
}
}

/**
* Surfaces an action-button tap as a typed `push_action_button_tapped`
* event, in parallel with the `push_notification_opened` event.
*
* The native SDK only launches the host app (carrying these `Button *`
* extras) for `deep_link` / `open_app` buttons; `open_url` buttons are
* dispatched to an external handler by the SDK and never reach the app, so
* they are not bridged here. The button's URL (when present) is forwarded
* verbatim regardless of scheme — scheme handling stays in the native SDK.
*/
private fun forwardActionButtonTap(notificationData: Map<String, Any?>) {
val buttonId = notificationData["Button ID"] as? String ?: return

// The native SDK serializes the action as a display name; map it back to
// the raw token shared with iOS and the Dart layer. "Open URL" is
// intentionally absent: the SDK dispatches open_url buttons externally,
// so they never launch the host app and never reach this code path.
val action =
when (notificationData["Button Action"] as? String) {
"Open App" -> "open_app"
"Deep Link" -> "deep_link"
else -> return
}

val data =
mutableMapOf<String, Any?>(
"id" to buttonId,
"action" to action,
)
(notificationData["Button Label"] as? String)?.let { data["label"] = it }
(notificationData["Button Link"] as? String)?.let { data["url"] = it }

eventSink?.success(
mapOf(
"type" to "push_action_button_tapped",
"data" to data,
),
)
}
}
32 changes: 32 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,38 @@ Future<void> _setupFCM() async {
/// Tracked to prevent duplicate subscriptions on SDK re-initialization.
StreamSubscription<Map<String, dynamic>>? _silentPushSubscription;

/// Subscription for push action (open_url / action button) events.
/// Tracked to prevent duplicate subscriptions on SDK re-initialization.
StreamSubscription<KlaviyoPushAction>? _pushActionSubscription;

/// Set up listener for push action events (open_url taps and action buttons).
/// Called from ProfileTab after SDK initialization.
void setupPushActionListener() {
final klaviyo = KlaviyoSDK();
if (!klaviyo.isInitialized) {
_logger.warning(
'Cannot set up push action listener: SDK not initialized',
);
return;
}

// Cancel any existing subscription to prevent duplicates
_pushActionSubscription?.cancel();

_pushActionSubscription = klaviyo.onPushAction.listen((action) {
switch (action) {
case OpenWebUrl():
_logger.info('Push open_url tapped: ${action.url}');
case ActionButtonTapped():
_logger.info(
'Push action button tapped — id: ${action.buttonId}, '
'label: "${action.label}", action: ${action.action}, '
'url: ${action.url}',
);
}
});
}

/// Set up listener for silent push notifications.
/// Called from ProfileTab after SDK initialization.
void setupSilentPushListener() {
Expand Down
5 changes: 4 additions & 1 deletion example/lib/tabs/profile_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import 'package:klaviyo_flutter_sdk/klaviyo_flutter_sdk.dart';
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../main.dart' show setupSilentPushListener;
import '../main.dart' show setupSilentPushListener, setupPushActionListener;
import 'forms_tab.dart';
import 'geofencing_tab.dart';

Expand Down Expand Up @@ -103,6 +103,9 @@ class _ProfileTabState extends State<ProfileTab> {
// Set up silent push listener now that SDK is initialized
setupSilentPushListener();

// Set up push action (open_url / action button) listener
setupPushActionListener();

// Sync current profile values
await _syncCurrentProfile();

Expand Down
6 changes: 3 additions & 3 deletions ios/klaviyo_flutter_sdk.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ A Flutter plugin that provides a wrapper around the native Klaviyo SDKs for iOS
s.resources = ['klaviyo_flutter_sdk/Sources/klaviyo_flutter_sdk/klaviyo-sdk-configuration.plist']

s.dependency 'Flutter'
s.dependency 'KlaviyoSwift', '~> 5.3.1'
s.dependency 'KlaviyoSwift', '~> 5.4.0'
# Forms: included by default, set to 'false' to exclude
if ENV['KLAVIYO_INCLUDE_FORMS'] != 'false'
s.dependency 'KlaviyoForms', '~> 5.3.1'
s.dependency 'KlaviyoForms', '~> 5.4.0'
end

# Conditional location dependency based on environment variable
# Default is FALSE (opt-in for geofencing)
include_location = ENV['KLAVIYO_INCLUDE_LOCATION'] == 'true'
if include_location
s.dependency 'KlaviyoLocation', '~> 5.3.1'
s.dependency 'KlaviyoLocation', '~> 5.4.0'
end

s.platform = :ios, '13.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public class KlaviyoFlutterSdkPlugin: NSObject, FlutterPlugin {
private var cachedError: [String: Any]?
private var cachedOpenedNotification: [String: Any]?
private var cachedSilentPush: [String: Any]?
private var cachedPushAction: [String: Any]?

// MARK: - Flutter Plugin Registration

Expand Down Expand Up @@ -392,6 +393,10 @@ extension KlaviyoFlutterSdkPlugin: FlutterStreamHandler {
events(cachedSilentPush)
self.cachedSilentPush = nil
}
if let cachedPushAction {
events(cachedPushAction)
self.cachedPushAction = nil
}
return nil
}

Expand Down Expand Up @@ -487,12 +492,95 @@ extension KlaviyoFlutterSdkPlugin {
cachedOpenedNotification = eventPayload
}

// 3. Pass to Native Klaviyo SDK
// 3. Surface the open_url action / action-button tap as a typed push action
// event, in parallel with the raw opened-notification event above.
forwardPushAction(for: response, userInfo: userInfo)

// 4. Pass to Native Klaviyo SDK
// We pass a dummy completion handler because the Host App owns the real one.
// No-op: We let the host app finish the system callback
_ = KlaviyoSDK().handle(notificationResponse: response, withCompletionHandler: {})
}

/// Mirrors an `open_url` body tap or an action-button tap to Flutter as a
/// typed push-action event. The native SDK still performs the actual
/// dispatch (opening the URL / launching the app); this only forwards the
/// event so Flutter apps can react.
///
/// Scheme handling is deliberately left to the native SDK: whatever URL the
/// payload carries is forwarded verbatim, including special schemes such as
/// `mailto:`/`tel:`/`sms:`. The plugin does not re-implement the SDK's
/// scheme allowlist.
private func forwardPushAction(
for response: UNNotificationResponse,
userInfo: [AnyHashable: Any]
) {
let actionIdentifier = response.actionIdentifier

var eventPayload: [String: Any]?

if actionIdentifier == UNNotificationDefaultActionIdentifier {
// Body tap. A deep-link `url` takes precedence over `web_url` in the
// native SDK, but only when it parses as a URL (mirrors
// `klaviyoDeepLinkURL`); otherwise the SDK dispatches `web_url`.
let deepLinkUrl = (userInfo["url"] as? String).flatMap(URL.init(string:))
if deepLinkUrl == nil,
let webUrl = userInfo["web_url"] as? String, !webUrl.isEmpty {
eventPayload = [
"type": "push_open_web_url",
"data": ["url": webUrl]
]
}
} else if actionIdentifier != UNNotificationDismissActionIdentifier {
// Action-button tap: the identifier is the tapped button's id. Look
// it up in body.action_buttons to recover its label/action/url.
guard let body = userInfo["body"] as? [String: Any],
let buttons = body["action_buttons"] as? [[String: Any]],
let button = buttons.first(where: {
$0["id"] as? String == actionIdentifier
}) else {
if #available(iOS 14.0, *) {
Logger.klaviyoFlutterSDK.warning(
"No action button matches identifier '\(actionIdentifier)'; dropping tap."
)
}
return
}
// Dart's KlaviyoPushAction.fromMap requires `action`, so drop
// incomplete taps here with a log rather than silently downstream.
guard let action = button["action"] as? String else {
if #available(iOS 14.0, *) {
Logger.klaviyoFlutterSDK.warning(
"Action button '\(actionIdentifier)' has no action type; dropping tap."
)
}
return
}
var data: [String: Any] = ["id": actionIdentifier, "action": action]
if let label = button["label"] as? String {
data["label"] = label
}
if let buttonUrl = button["url"] as? String {
data["url"] = buttonUrl
}
eventPayload = [
"type": "push_action_button_tapped",
"data": data
]
}

guard let eventPayload else { return }

if let eventSink {
eventSink(eventPayload)
} else {
if #available(iOS 14.0, *) {
Logger.klaviyoFlutterSDK.notice("Flutter not ready. Caching push action event.")
}
cachedPushAction = eventPayload
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale iOS push action cache

Medium Severity

When Flutter is not ready, forwardPushAction stores a push action in cachedPushAction but returns early when the latest tap has no typed action without clearing that cache. After a second notification open overwrites cachedOpenedNotification, onListen can deliver a mismatched push_open_web_url or button event from an earlier tap.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0e58931. Configure here.

}

/// Manual Forwarding Helper - Silent Push
/// This should be called from the Host App's AppDelegate
/// in `didReceiveRemoteNotification:fetchCompletionHandler:`.
Expand Down
1 change: 1 addition & 0 deletions lib/klaviyo_flutter_sdk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export 'src/models/klaviyo_location.dart';
export 'src/models/in_app_form_config.dart';
export 'src/models/geofence.dart';
export 'src/models/form_lifecycle_event.dart';
export 'src/models/klaviyo_push_action.dart';
export 'src/enums/klaviyo_log_level.dart';
export 'src/services/klaviyo_native_wrapper.dart';
export 'src/exceptions/klaviyo_exception.dart';
Expand Down
23 changes: 23 additions & 0 deletions lib/src/klaviyo_sdk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'models/klaviyo_event.dart';
import 'models/in_app_form_config.dart';
import 'models/geofence.dart';
import 'models/form_lifecycle_event.dart';
import 'models/klaviyo_push_action.dart';
import 'enums/klaviyo_log_level.dart';
import 'services/klaviyo_native_wrapper.dart';
import 'package:logging/logging.dart';
Expand Down Expand Up @@ -423,6 +424,28 @@ class KlaviyoSDK {
}
});

/// Get typed push action events stream.
///
/// Emits a [KlaviyoPushAction] when a user taps a push whose action opens an
/// external web URL ([OpenWebUrl]), or taps an action button on a push
/// ([ActionButtonTapped]). Filters for the `push_open_web_url` and
/// `push_action_button_tapped` event types and parses the native map.
/// Malformed events are logged and dropped rather than crashing the stream.
Stream<KlaviyoPushAction> get onPushAction => _nativeWrapper.onPushActionEvent
.where(
(event) =>
event['type'] == 'push_open_web_url' ||
event['type'] == 'push_action_button_tapped',
)
.expand((event) {
try {
return [KlaviyoPushAction.fromMap(event)];
} catch (e) {
_logger.warning('Dropping malformed push action event: $e');
return [];
}
});
Comment on lines +440 to +447

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate klaviyo_sdk.dart =="
fd -a 'klaviyo_sdk\.dart$' .

echo "== Extract relevant lines =="
if [ -f lib/src/klaviyo_sdk.dart ]; then
  sed -n '425,455p' lib/src/klaviyo_sdk.dart | cat -n
fi

echo "== Check require_trailing_commas config references =="
rg -n "require_trailing_commas|trailing_commas|avoid_trailing_commas|dart format" . -g '!build/**' -g '!dist/**' -g '!node_modules/**' || true

echo "== Check local analyzer/dart configuration files =="
for f in analysis_options.yaml pubspec.yaml pubspec.yaml.lock .dart_tool/package_config.json; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    sed -n '1,220p' "$f"
  fi
done

echo "== Extract surrounding stream chain with line numbers =="
nl -ba lib/src/klaviyo_sdk.dart | sed -n '420,455p'

Repository: klaviyo/klaviyo-flutter-sdk

Length of output: 4125


🌐 Web query:

Dart analyzer require_trailing_commas trailing comma lint Dart style

💡 Result:

The require_trailing_commas lint rule in Dart is designed to enforce the use of trailing commas in multi-line parameter and argument lists [1]. Key details regarding its usage and behavior: How to Enable: To enable this lint, add it to your analysis_options.yaml file under the linter rules section [1]: linter: rules: - require_trailing_commas Behavior and Limitations: - The rule encourages using trailing commas for all multi-line parameter and argument lists. If a list fits on a single line, a trailing comma is not required [1]. - It is intended to complement the Dart formatter (dart format), which also automatically manages trailing commas [1][2]. - NOTE: The rule assumes code has already been processed by dart format; it may produce false positives on unformatted code [1]. - For projects targeting Dart language version 3.7 or later (using the new "tall" style formatter), the require_trailing_commas lint rule is automatically disabled, as the formatter's automated behavior takes precedence [3]. Interaction with Formatter Configuration: In Dart 3.7 and later, the default formatter behavior is to automatically add or remove trailing commas based on whether a construct spans multiple lines [2][4]. Users who prefer manual control over line breaks—where the presence of a trailing comma forces a construct to split across lines—can opt into this behavior by configuring the formatter in analysis_options.yaml [2][5][6]: formatter: trailing_commas: preserve When this preserve setting is used, it restores the ability to force line breaks via trailing commas, which was a standard feature of the older Dart formatter [5][6]. Using the lint rule alongside this setting helps maintain consistent application of that style across the codebase [7].

Citations:


🌐 Web query:

Flutter 3.24 Flutter SDK Dart SDK version version

💡 Result:

The Flutter 3.24 SDK release is paired with Dart 3.5 [1][2]. This release was officially announced on August 6, 2024 [1][2].

Citations:


Add the required trailing comma to the multiline expand call.

The repository enforces require_trailing_commas, so keep the callable comma even though the argument happens to be a block expression:

Proposed formatting fix
-        .expand((event) {
+        .expand(
+          (event) {
           try {
             return [KlaviyoPushAction.fromMap(event)];
           } catch (e) {
             _logger.warning('Dropping malformed push action event: $e');
             return [];
           }
-      });
+          },
+        );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.expand((event) {
try {
return [KlaviyoPushAction.fromMap(event)];
} catch (e) {
_logger.warning('Dropping malformed push action event: $e');
return [];
}
});
.expand(
(event) {
try {
return [KlaviyoPushAction.fromMap(event)];
} catch (e) {
_logger.warning('Dropping malformed push action event: $e');
return [];
}
},
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/src/klaviyo_sdk.dart` around lines 440 - 447, Add the required trailing
comma after the multiline callback argument in the expand call handling
KlaviyoPushAction.fromMap within the event transformation chain, preserving the
existing error handling and behavior.

Source: Coding guidelines


/// Private methods
void _ensureInitialized() {
if (!_isInitialized) {
Expand Down
Loading
Loading