diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa33b6c..38948798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## 0.28.0 + +Completeness pass: the Dart API now mirrors more of the native Facebook App Events surface, keeping the 1:1 Dart ↔ Kotlin ↔ Swift mapping. Purely additive except the documented debug-logging change below. + +### New native-mirroring methods + +- Add `logProductItem(...)` for product-catalog item logging, with type-safe `ProductAvailability` and `ProductCondition` enums (iOS `logProductItem`, Android `AppEventsLogger.logProductItem`). +- Add `setPushNotificationToken(String)` to register a push token for Meta push-campaign measurement (iOS `setPushNotificationsDeviceToken`, Android `setPushNotificationsRegistrationId`). +- Add `setFlushBehavior(FlushBehavior)` / `getFlushBehavior()` to control auto vs explicit-only event flushing. +- Add `getUserData()` and `getUserID()` getters. +- Add `clearUserDataForType(FacebookUserDataField)` — functional on iOS; **no-op on Android** (the Android SDK has no per-field clear; use `clearUserData()`). Documented in the README "Known Limitations". + +### Behavior change + +- Add explicit `setDebugLoggingEnabled(bool)` (verbose SDK app-event/network logging) on both platforms, and **remove the hidden debug-logging side effect** that `setAdvertiserTracking` previously triggered on Android in debug builds. Call `setDebugLoggingEnabled` explicitly if you relied on that behavior. + +### Additional standard-event convenience helpers + +- Add Dart shorthands (routed through `logEvent`) for the remaining Meta standard events: `logAchievedLevel`, `logAddedPaymentInfo`, `logCompletedTutorial`, `logSearched`, `logSpentCredits`, `logUnlockedAchievement`, `logContact`, `logCustomizeProduct`, `logDonate`, `logFindLocation`, `logSchedule`, `logSubmitApplication`, plus their `eventName*` constants. + +### Internal + +- Move the new enums to `lib/src/enums.dart` and the standard-event helpers to `lib/src/standard_events.dart` (re-exported; no import changes for consumers). +- Remove unused `GraphRequest`/`GraphResponse` imports from the Android plugin. +- Add Dart unit tests for all new methods and demonstrate them in the example app. + ## 0.27.2 - Tighten the AGP 9 Kotlin-plugin guard introduced in 0.27.1. The previous check skipped `apply plugin: "kotlin-android"` for *any* AGP 9 build, but users running AGP 9 with `android.builtInKotlin=false` (opting out of built-in Kotlin) still need the plugin applied. Now keyed on both the AGP major version and the `android.builtInKotlin` Gradle property (PR [#485](https://github.com/oddbit/flutter_facebook_app_events/pull/485)). diff --git a/README.md b/README.md index ffd23b07..2a732ec2 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,10 @@ This is a plugin-specific workaround for a [known upstream issue in the iOS SDK] `setDataProcessingOptions` is **functional on Android** but is a **no-op on iOS** (a warning is printed). Meta removed the underlying API from the Facebook iOS SDK in the 18.x series; there is no native replacement callable from the SDK. If you need to configure data use on iOS, use Meta's [Data Use Checkup](https://developers.facebook.com/docs/development/data-processing-options) tooling in the app dashboard instead. +### `clearUserDataForType` on Android + +`clearUserDataForType` is **functional on iOS** but is a **no-op on Android** (a warning is logged). The Android `AppEventsLogger` exposes no per-field clear; call `clearUserData()` to clear all previously-set user data fields at once. + ### Facebook Event Manager "Please Upgrade SDK" Warning When setting up codeless events in Facebook Event Manager, you may encounter a warning message stating: diff --git a/android/src/main/kotlin/id/oddbit/flutter/facebook_app_events/FacebookAppEventsPlugin.kt b/android/src/main/kotlin/id/oddbit/flutter/facebook_app_events/FacebookAppEventsPlugin.kt index 09ca88ba..753268a8 100644 --- a/android/src/main/kotlin/id/oddbit/flutter/facebook_app_events/FacebookAppEventsPlugin.kt +++ b/android/src/main/kotlin/id/oddbit/flutter/facebook_app_events/FacebookAppEventsPlugin.kt @@ -12,13 +12,12 @@ import android.os.Bundle import android.util.Log import com.facebook.FacebookSdk import com.facebook.appevents.AppEventsLogger -import com.facebook.GraphRequest -import com.facebook.GraphResponse import io.flutter.embedding.engine.plugins.FlutterPlugin 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.math.BigDecimal import java.util.Currency import com.facebook.LoggingBehavior @@ -74,6 +73,14 @@ class FacebookAppEventsPlugin: FlutterPlugin, MethodCallHandler { "logPurchase" -> handlePurchased(call, result) "setAdvertiserTracking" -> handleSetAdvertiserTracking(call, result) "setGraphApiVersion" -> handleSetGraphApiVersion(call, result) + "logProductItem" -> handleLogProductItem(call, result) + "setPushNotificationToken" -> handleSetPushNotificationToken(call, result) + "setFlushBehavior" -> handleSetFlushBehavior(call, result) + "getFlushBehavior" -> handleGetFlushBehavior(call, result) + "getUserData" -> handleGetUserData(call, result) + "getUserID" -> handleGetUserId(call, result) + "clearUserDataForType" -> handleClearUserDataForType(call, result) + "setDebugLoggingEnabled" -> handleSetDebugLoggingEnabled(call, result) else -> result.notImplemented() } @@ -152,12 +159,6 @@ class FacebookAppEventsPlugin: FlutterPlugin, MethodCallHandler { FacebookSdk.setAdvertiserIDCollectionEnabled(enabled && collectId) - if (BuildConfig.DEBUG) { - FacebookSdk.setIsDebugEnabled(true && enabled) - FacebookSdk.addLoggingBehavior(LoggingBehavior.APP_EVENTS) - FacebookSdk.addLoggingBehavior(LoggingBehavior.REQUESTS) - } - result.success(null) } @@ -274,4 +275,133 @@ class FacebookAppEventsPlugin: FlutterPlugin, MethodCallHandler { appEventsLogger.logPurchase(amount, currency, parameterBundle) result.success(null) } + + private fun handleLogProductItem(call: MethodCall, result: Result) { + val itemId = call.argument("itemId") + val availabilityToken = call.argument("availability") + val conditionToken = call.argument("condition") + val description = call.argument("description") + val imageLink = call.argument("imageLink") + val link = call.argument("link") + val title = call.argument("title") + val priceAmount = call.argument("priceAmount") + val currencyCode = call.argument("currency") + val gtin = call.argument("gtin") + val mpn = call.argument("mpn") + val brand = call.argument("brand") + + if (itemId == null || availabilityToken == null || conditionToken == null || + description == null || imageLink == null || link == null || title == null || + priceAmount == null || currencyCode == null) { + result.error("INVALID_ARGUMENT", "Missing required logProductItem fields", null) + return + } + if (gtin == null && mpn == null && brand == null) { + result.error("INVALID_ARGUMENT", "At least one of gtin, mpn or brand is required", null) + return + } + + val availability = productAvailabilityFromToken(availabilityToken) + val condition = productConditionFromToken(conditionToken) + if (availability == null || condition == null) { + result.error("INVALID_ARGUMENT", "Invalid availability or condition value", null) + return + } + + val parameters = call.argument>("parameters") + val parameterBundle = createBundleFromMap(parameters) ?: Bundle() + + appEventsLogger.logProductItem( + itemId, + availability, + condition, + description, + imageLink, + link, + title, + BigDecimal.valueOf(priceAmount), + Currency.getInstance(currencyCode), + gtin, + mpn, + brand, + parameterBundle + ) + result.success(null) + } + + private fun productAvailabilityFromToken(token: String): AppEventsLogger.ProductAvailability? { + return when (token) { + "inStock" -> AppEventsLogger.ProductAvailability.IN_STOCK + "outOfStock" -> AppEventsLogger.ProductAvailability.OUT_OF_STOCK + "preorder" -> AppEventsLogger.ProductAvailability.PREORDER + // Note: the Android SDK enum constant is historically misspelled. + "availableForOrder" -> AppEventsLogger.ProductAvailability.AVALIABLE_FOR_ORDER + "discontinued" -> AppEventsLogger.ProductAvailability.DISCONTINUED + else -> null + } + } + + private fun productConditionFromToken(token: String): AppEventsLogger.ProductCondition? { + return when (token) { + "newItem" -> AppEventsLogger.ProductCondition.NEW + "refurbished" -> AppEventsLogger.ProductCondition.REFURBISHED + "used" -> AppEventsLogger.ProductCondition.USED + else -> null + } + } + + private fun handleSetPushNotificationToken(call: MethodCall, result: Result) { + val token = call.arguments as? String + if (token == null) { + result.error("INVALID_ARGUMENT", "Push notification token is required", null) + return + } + AppEventsLogger.setPushNotificationsRegistrationId(token) + result.success(null) + } + + private fun handleSetFlushBehavior(call: MethodCall, result: Result) { + val behavior = when (call.arguments as? String) { + "explicitOnly" -> AppEventsLogger.FlushBehavior.EXPLICIT_ONLY + else -> AppEventsLogger.FlushBehavior.AUTO + } + AppEventsLogger.setFlushBehavior(behavior) + result.success(null) + } + + private fun handleGetFlushBehavior(call: MethodCall, result: Result) { + val token = when (AppEventsLogger.getFlushBehavior()) { + AppEventsLogger.FlushBehavior.EXPLICIT_ONLY -> "explicitOnly" + else -> "auto" + } + result.success(token) + } + + private fun handleGetUserData(call: MethodCall, result: Result) { + result.success(AppEventsLogger.getUserData()) + } + + private fun handleGetUserId(call: MethodCall, result: Result) { + result.success(AppEventsLogger.getUserID()) + } + + private fun handleClearUserDataForType(call: MethodCall, result: Result) { + // Android's AppEventsLogger has no per-field clear; this is intentionally a + // no-op. Use clearUserData() to clear all fields. See README "Known Limitations". + Log.w(logTag, "clearUserDataForType is not supported on Android; use clearUserData() to clear all fields.") + result.success(null) + } + + private fun handleSetDebugLoggingEnabled(call: MethodCall, result: Result) { + val enabled = call.arguments as? Boolean ?: false + FacebookSdk.setIsDebugEnabled(enabled) + if (enabled) { + FacebookSdk.addLoggingBehavior(LoggingBehavior.APP_EVENTS) + FacebookSdk.addLoggingBehavior(LoggingBehavior.REQUESTS) + } else { + FacebookSdk.removeLoggingBehavior(LoggingBehavior.APP_EVENTS) + FacebookSdk.removeLoggingBehavior(LoggingBehavior.REQUESTS) + } + result.success(null) + } } diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 9c3f4037..a4737e42 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.1.1" apply false - id "org.jetbrains.kotlin.android" version "1.8.22" apply false + id "com.android.application" version "8.9.2" apply false + id "org.jetbrains.kotlin.android" version "2.3.10" apply false } include ":app" diff --git a/example/lib/main.dart b/example/lib/main.dart index abe13f87..ee22667e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -13,68 +13,122 @@ class MyApp extends StatelessWidget { title: const Text('Plugin example app'), ), body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FutureBuilder( - future: facebookAppEvents.getAnonymousId(), - builder: (context, snapshot) { - final id = snapshot.data ?? '???'; - return Text('Anonymous ID: $id'); - }, - ), - MaterialButton( - child: Text("Click me!"), - onPressed: () { - facebookAppEvents.logEvent( - name: 'button_clicked', - parameters: { - 'button_id': 'the_clickme_button', - }, - ); - }, - ), - MaterialButton( - child: Text("Set user data"), - onPressed: () { - facebookAppEvents.setUserData( - email: 'opensource@oddbit.id', - firstName: 'Oddbit', - city: 'Denpasar', - country: 'Indonesia', - ); - }, - ), - MaterialButton( - child: Text("Test logAddToCart"), - onPressed: () { - facebookAppEvents.logAddToCart( - id: '1', - type: 'product', - price: 99.0, - currency: 'TRY', - ); - }, - ), - MaterialButton( - child: Text("Test purchase!"), - onPressed: () { - facebookAppEvents.logPurchase(amount: 1, currency: "USD"); - }, - ), - MaterialButton( - child: Text("Enable advertise tracking!"), - onPressed: () { - facebookAppEvents.setAdvertiserTracking(enabled: true); - }, - ), - MaterialButton( - child: Text("Disabled advertise tracking!"), - onPressed: () { - facebookAppEvents.setAdvertiserTracking(enabled: false); - }, - ), - ], + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FutureBuilder( + future: facebookAppEvents.getAnonymousId(), + builder: (context, snapshot) { + final id = snapshot.data ?? '???'; + return Text('Anonymous ID: $id'); + }, + ), + MaterialButton( + child: Text("Click me!"), + onPressed: () { + facebookAppEvents.logEvent( + name: 'button_clicked', + parameters: { + 'button_id': 'the_clickme_button', + }, + ); + }, + ), + MaterialButton( + child: Text("Set user data"), + onPressed: () { + facebookAppEvents.setUserData( + email: 'opensource@oddbit.id', + firstName: 'Oddbit', + city: 'Denpasar', + country: 'Indonesia', + ); + }, + ), + MaterialButton( + child: Text("Test logAddToCart"), + onPressed: () { + facebookAppEvents.logAddToCart( + id: '1', + type: 'product', + price: 99.0, + currency: 'TRY', + ); + }, + ), + MaterialButton( + child: Text("Test purchase!"), + onPressed: () { + facebookAppEvents.logPurchase(amount: 1, currency: "USD"); + }, + ), + MaterialButton( + child: Text("Enable advertise tracking!"), + onPressed: () { + facebookAppEvents.setAdvertiserTracking(enabled: true); + }, + ), + MaterialButton( + child: Text("Disabled advertise tracking!"), + onPressed: () { + facebookAppEvents.setAdvertiserTracking(enabled: false); + }, + ), + MaterialButton( + child: Text("Log product item"), + onPressed: () { + facebookAppEvents.logProductItem( + itemId: 'SKU-1', + availability: ProductAvailability.inStock, + condition: ProductCondition.newItem, + description: 'Comfortable running shoes', + imageLink: 'https://example.com/shoes.png', + link: 'https://example.com/shoes', + title: 'Running Shoes', + priceAmount: 79.99, + currency: 'USD', + gtin: '0123456789012', + ); + }, + ), + MaterialButton( + child: Text("Flush events explicitly only"), + onPressed: () { + facebookAppEvents + .setFlushBehavior(FlushBehavior.explicitOnly); + }, + ), + MaterialButton( + child: Text("Register push token"), + onPressed: () { + facebookAppEvents.setPushNotificationToken('example-token'); + }, + ), + MaterialButton( + child: Text("Clear email user data"), + onPressed: () { + facebookAppEvents + .clearUserDataForType(FacebookUserDataField.email); + }, + ), + MaterialButton( + child: Text("Enable SDK debug logging"), + onPressed: () { + facebookAppEvents.setDebugLoggingEnabled(true); + }, + ), + MaterialButton( + child: Text("Log search"), + onPressed: () { + facebookAppEvents.logSearched( + searchString: 'running shoes', + contentType: 'product', + ); + }, + ), + ], + ), ), ), ), diff --git a/example/pubspec.lock b/example/pubspec.lock index 7fd7a576..057ffa50 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -47,7 +47,7 @@ packages: path: ".." relative: true source: path - version: "0.27.1" + version: "0.28.0" fake_async: dependency: transitive description: diff --git a/ios/facebook_app_events.podspec b/ios/facebook_app_events.podspec index e7183a86..89bccfb9 100644 --- a/ios/facebook_app_events.podspec +++ b/ios/facebook_app_events.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'facebook_app_events' - s.version = '0.27.2' + s.version = '0.28.0' s.summary = 'Flutter plugin for Facebook Analytics and App Events' s.description = <<-DESC Flutter plugin for Facebook Analytics and App Events diff --git a/ios/facebook_app_events/Sources/facebook_app_events/FacebookAppEventsPlugin.swift b/ios/facebook_app_events/Sources/facebook_app_events/FacebookAppEventsPlugin.swift index 27b9753d..9281e70d 100644 --- a/ios/facebook_app_events/Sources/facebook_app_events/FacebookAppEventsPlugin.swift +++ b/ios/facebook_app_events/Sources/facebook_app_events/FacebookAppEventsPlugin.swift @@ -72,6 +72,22 @@ public class FacebookAppEventsPlugin: NSObject, FlutterPlugin { handleSetAdvertiserTracking(call, result: result) case "setGraphApiVersion": handleSetGraphApiVersion(call, result: result) + case "logProductItem": + handleLogProductItem(call, result: result) + case "setPushNotificationToken": + handleSetPushNotificationToken(call, result: result) + case "setFlushBehavior": + handleSetFlushBehavior(call, result: result) + case "getFlushBehavior": + handleGetFlushBehavior(call, result: result) + case "getUserData": + handleGetUserData(call, result: result) + case "getUserID": + handleGetUserID(call, result: result) + case "clearUserDataForType": + handleClearUserDataForType(call, result: result) + case "setDebugLoggingEnabled": + handleSetDebugLoggingEnabled(call, result: result) default: result(FlutterMethodNotImplemented) } @@ -230,4 +246,152 @@ public class FacebookAppEventsPlugin: NSObject, FlutterPlugin { result(nil) } + + private func handleLogProductItem(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let arguments = call.arguments as? [String: Any] ?? [:] + guard let itemId = arguments["itemId"] as? String, + let availabilityToken = arguments["availability"] as? String, + let conditionToken = arguments["condition"] as? String, + let description = arguments["description"] as? String, + let imageLink = arguments["imageLink"] as? String, + let link = arguments["link"] as? String, + let title = arguments["title"] as? String, + let priceAmount = arguments["priceAmount"] as? Double, + let currency = arguments["currency"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing required logProductItem fields", details: nil)) + return + } + + let gtin = arguments["gtin"] as? String + let mpn = arguments["mpn"] as? String + let brand = arguments["brand"] as? String + if gtin == nil && mpn == nil && brand == nil { + result(FlutterError(code: "INVALID_ARGUMENT", message: "At least one of gtin, mpn or brand is required", details: nil)) + return + } + + guard let availability = Self.productAvailability(from: availabilityToken), + let condition = Self.productCondition(from: conditionToken) else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "Invalid availability or condition value", details: nil)) + return + } + + let rawParams = arguments["parameters"] as? [String: Any] ?? [:] + let parameters: [AppEvents.ParameterName: Any] = Dictionary( + uniqueKeysWithValues: rawParams.map { key, value in + (AppEvents.ParameterName(key), value) + } + ) + + AppEvents.shared.logProductItem( + itemId, + availability: availability, + condition: condition, + description: description, + imageLink: imageLink, + link: link, + title: title, + priceAmount: priceAmount, + currency: currency, + gtin: gtin, + mpn: mpn, + brand: brand, + parameters: parameters + ) + result(nil) + } + + private static func productAvailability(from token: String) -> AppEvents.ProductAvailability? { + switch token { + case "inStock": return .inStock + case "outOfStock": return .outOfStock + case "preorder": return .preOrder + case "availableForOrder": return .availableForOrder + case "discontinued": return .discontinued + default: return nil + } + } + + private static func productCondition(from token: String) -> AppEvents.ProductCondition? { + switch token { + case "newItem": return .new + case "refurbished": return .refurbished + case "used": return .used + default: return nil + } + } + + private func handleSetPushNotificationToken(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let token = call.arguments as? String else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "Push notification token is required", details: nil)) + return + } + // The ObjC `setPushNotificationsDeviceTokenString:` is renamed in Swift + // to `setPushNotificationsDeviceToken(_:)` via NS_SWIFT_NAME, overloaded + // with the Data variant. Passing a String resolves to the String overload; + // `setPushNotificationsDeviceTokenString` does not exist in Swift. + AppEvents.shared.setPushNotificationsDeviceToken(token) + result(nil) + } + + private func handleSetFlushBehavior(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let behavior: AppEvents.FlushBehavior = (call.arguments as? String) == "explicitOnly" ? .explicitOnly : .auto + AppEvents.shared.flushBehavior = behavior + result(nil) + } + + private func handleGetFlushBehavior(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch AppEvents.shared.flushBehavior { + case .explicitOnly: + result("explicitOnly") + default: + result("auto") + } + } + + private func handleGetUserData(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + result(AppEvents.shared.getUserData()) + } + + private func handleGetUserID(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + result(AppEvents.shared.userID) + } + + private func handleClearUserDataForType(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let token = call.arguments as? String, + let type = Self.userDataType(from: token) else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "A valid user data field is required", details: nil)) + return + } + AppEvents.shared.clearUserData(forType: type) + result(nil) + } + + private static func userDataType(from token: String) -> FBSDKAppEventUserDataType? { + switch token { + case "email": return .email + case "firstName": return .firstName + case "lastName": return .lastName + case "phone": return .phone + case "dateOfBirth": return .dateOfBirth + case "gender": return .gender + case "city": return .city + case "state": return .state + case "zip": return .zip + case "country": return .country + default: return nil + } + } + + private func handleSetDebugLoggingEnabled(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let enabled = call.arguments as? Bool ?? false + if enabled { + Settings.shared.enableLoggingBehavior(.appEvents) + Settings.shared.enableLoggingBehavior(.networkRequests) + } else { + Settings.shared.disableLoggingBehavior(.appEvents) + Settings.shared.disableLoggingBehavior(.networkRequests) + } + result(nil) + } } diff --git a/lib/facebook_app_events.dart b/lib/facebook_app_events.dart index c02085d0..3f7e1619 100644 --- a/lib/facebook_app_events.dart +++ b/lib/facebook_app_events.dart @@ -8,6 +8,12 @@ import 'dart:convert'; import 'package:flutter/services.dart'; +import 'src/enums.dart'; + +export 'src/enums.dart'; + +part 'src/standard_events.dart'; + /// MethodChannel name used by the plugin. const channelName = 'flutter.oddbit.id/facebook_app_events'; @@ -42,6 +48,18 @@ class FacebookAppEvents { static const eventNameStartTrial = "StartTrial"; static const eventNameAdImpression = "AdImpression"; static const eventNameAdClick = "AdClick"; + static const eventNameAchievedLevel = "fb_mobile_level_achieved"; + static const eventNameAddedPaymentInfo = "fb_mobile_add_payment_info"; + static const eventNameCompletedTutorial = "fb_mobile_tutorial_completion"; + static const eventNameSearched = "fb_mobile_search"; + static const eventNameSpentCredits = "fb_mobile_spent_credits"; + static const eventNameUnlockedAchievement = "fb_mobile_achievement_unlocked"; + static const eventNameContact = "Contact"; + static const eventNameCustomizeProduct = "CustomizeProduct"; + static const eventNameDonate = "Donate"; + static const eventNameFindLocation = "FindLocation"; + static const eventNameSchedule = "Schedule"; + static const eventNameSubmitApplication = "SubmitApplication"; static const _paramNameValueToSum = "_valueToSum"; static const paramNameAdType = "fb_ad_type"; @@ -50,6 +68,9 @@ class FacebookAppEvents { static const paramNameRegistrationMethod = "fb_registration_method"; static const paramNamePaymentInfoAvailable = "fb_payment_info_available"; static const paramNameNumItems = "fb_num_items"; + static const paramNameLevel = "fb_level"; + static const paramNameSearchString = "fb_search_string"; + static const paramNameDescription = "fb_description"; static const paramValueYes = "1"; static const paramValueNo = "0"; @@ -452,6 +473,10 @@ class FacebookAppEvents { /// This typically needs to be aligned with your user consent flow and the /// platform's privacy requirements. /// + /// Note: this method no longer toggles verbose SDK debug logging as a side + /// effect on Android. Use [setDebugLoggingEnabled] to control SDK logging + /// explicitly on both platforms. + /// /// See documentation: /// - [iOS Settings](https://developers.facebook.com/docs/reference/iossdk/current/FBSDKCoreKit/classes/settings.html) /// - [Android FacebookSdk](https://developers.facebook.com/docs/reference/androidsdk/current/facebook/com/facebook/FacebookSdk.html) @@ -595,6 +620,115 @@ class FacebookAppEvents { return _channel.invokeMethod('setGraphApiVersion', version); } + /// Logs a product-catalog item so it can be matched for dynamic ads and + /// product-catalog audiences. + /// + /// At least one of [gtin], [mpn] or [brand] must be provided — this is a + /// requirement of the native SDK. + /// + /// See documentation: + /// - [iOS](https://developers.facebook.com/docs/reference/iossdk/current/FBSDKCoreKit/classes/fbsdkappevents.html) + /// - [Android](https://developers.facebook.com/docs/reference/androidsdk/current/facebook/com/facebook/appevents/appeventslogger.html) + Future logProductItem({ + required String itemId, + required ProductAvailability availability, + required ProductCondition condition, + required String description, + required String imageLink, + required String link, + required String title, + required double priceAmount, + required String currency, + String? gtin, + String? mpn, + String? brand, + Map? parameters, + }) { + assert( + gtin != null || mpn != null || brand != null, + 'logProductItem requires at least one of gtin, mpn or brand.', + ); + + final args = { + 'itemId': itemId, + 'availability': availability.name, + 'condition': condition.name, + 'description': description, + 'imageLink': imageLink, + 'link': link, + 'title': title, + 'priceAmount': priceAmount, + 'currency': currency, + 'gtin': gtin, + 'mpn': mpn, + 'brand': brand, + 'parameters': parameters, + }; + + return _channel.invokeMethod( + 'logProductItem', + _filterOutNulls(args), + ); + } + + /// Registers a push notification [token] with the SDK so Meta can attribute + /// push-driven app opens and measure push campaigns. + /// + /// Platform mapping: + /// - iOS: `AppEvents.setPushNotificationsDeviceToken(_:)` (the String overload). + /// The underlying `setPushNotificationsDeviceTokenString:` Objective-C + /// selector is renamed to `setPushNotificationsDeviceToken(_:)` in Swift via + /// `NS_SWIFT_NAME`, so that is the symbol this plugin calls. + /// - Android: `AppEventsLogger.setPushNotificationsRegistrationId`. + Future setPushNotificationToken(String token) { + return _channel.invokeMethod('setPushNotificationToken', token); + } + + /// Sets the [FlushBehavior] controlling when events are sent to Meta. + /// + /// Use [FlushBehavior.explicitOnly] to suppress automatic flushing and only + /// send events when [flush] is called. + Future setFlushBehavior(FlushBehavior behavior) { + return _channel.invokeMethod('setFlushBehavior', behavior.name); + } + + /// Returns the current [FlushBehavior]. + Future getFlushBehavior() async { + final token = await _channel.invokeMethod('getFlushBehavior'); + return flushBehaviorFromWire(token); + } + + /// Returns the JSON-encoded hashed user data currently set on the SDK, or + /// `null` if none has been set. See [setUserData]. + Future getUserData() { + return _channel.invokeMethod('getUserData'); + } + + /// Returns the user id previously set via [setUserID], or `null`. + Future getUserID() { + return _channel.invokeMethod('getUserID'); + } + + /// Clears a single previously-set [field] of user data. + /// + /// Platform behavior: + /// - iOS: clears the given field via `clearUserDataForType`. + /// - Android: no-op. `AppEventsLogger` exposes no per-field clear; use + /// [clearUserData] to clear all fields at once. + Future clearUserDataForType(FacebookUserDataField field) { + return _channel.invokeMethod('clearUserDataForType', field.name); + } + + /// Enables or disables verbose Facebook SDK debug logging (app events and + /// network requests). + /// + /// This replaces the implicit debug-logging side effect that previously lived + /// in [setAdvertiserTracking] on Android; call this explicitly when you want + /// SDK logs during development. + Future setDebugLoggingEnabled(bool enabled) { + return _channel.invokeMethod('setDebugLoggingEnabled', enabled); + } + // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // diff --git a/lib/src/enums.dart b/lib/src/enums.dart new file mode 100644 index 00000000..f6891b31 --- /dev/null +++ b/lib/src/enums.dart @@ -0,0 +1,92 @@ +// Copyright (c) Oddbit (https://oddbit.id) +// +// This source file is part of facebook_app_events. +// Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE. + +/// Type-safe enums that mirror the Facebook App Events native SDK enumerations. +/// +/// Each value carries a stable wire token (its [Enum.name]) that the platform +/// handlers map back to the corresponding native SDK enum. Keeping the token +/// equal to the Dart `name` keeps the Dart ↔ Kotlin ↔ Swift contract obvious. +library; + +/// Controls when the SDK flushes logged events to Meta's servers. +/// +/// Mirrors `FBSDKAppEventsFlushBehavior` (iOS) and +/// `AppEventsLogger.FlushBehavior` (Android). +enum FlushBehavior { + /// Events are flushed periodically and when the app is backgrounded. + /// This is the SDK default. + auto, + + /// Events are only sent when [FacebookAppEvents.flush] is called explicitly. + explicitOnly, +} + +/// Stock availability of a catalog item logged via +/// [FacebookAppEvents.logProductItem]. +/// +/// Mirrors `FBSDKProductAvailability` (iOS) and +/// `AppEventsLogger.ProductAvailability` (Android). +enum ProductAvailability { + /// Item ships immediately. + inStock, + + /// No plan to restock. + outOfStock, + + /// Available in the future. + preorder, + + /// Ships in 1–2 weeks. + availableForOrder, + + /// Discontinued. + discontinued, +} + +/// Condition of a catalog item logged via +/// [FacebookAppEvents.logProductItem]. +/// +/// Mirrors `FBSDKProductCondition` (iOS) and +/// `AppEventsLogger.ProductCondition` (Android). +enum ProductCondition { + /// A brand new item. Named `newItem` because `new` is a reserved Dart keyword; + /// maps to the native `NEW` condition. + newItem, + + /// A refurbished item. + refurbished, + + /// A used item. + used, +} + +/// A single field of the hashed user data set via +/// [FacebookAppEvents.setUserData], used by +/// [FacebookAppEvents.clearUserDataForType]. +/// +/// Mirrors `FBSDKAppEventUserDataType` on iOS. Android's `AppEventsLogger` +/// has no per-field clear, so [FacebookAppEvents.clearUserDataForType] is a +/// no-op there (see the iOS/Android divergence note in the README). +enum FacebookUserDataField { + email, + firstName, + lastName, + phone, + dateOfBirth, + gender, + city, + state, + zip, + country, +} + +/// Parses a [FlushBehavior] from a wire token, defaulting to +/// [FlushBehavior.auto] for unknown values. +FlushBehavior flushBehaviorFromWire(String? token) { + for (final value in FlushBehavior.values) { + if (value.name == token) return value; + } + return FlushBehavior.auto; +} diff --git a/lib/src/standard_events.dart b/lib/src/standard_events.dart new file mode 100644 index 00000000..85898e3a --- /dev/null +++ b/lib/src/standard_events.dart @@ -0,0 +1,180 @@ +// Copyright (c) Oddbit (https://oddbit.id) +// +// This source file is part of facebook_app_events. +// Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE. + +part of '../facebook_app_events.dart'; + +/// Convenience wrappers for Meta's remaining predefined standard events. +/// +/// Each method routes through [FacebookAppEvents.logEvent] with the correct +/// standard event name and parameters, so there is no additional native code: +/// the behavior is identical to calling `logEvent` directly with the matching +/// `eventName*` constant. +/// +/// See [standard events](https://developers.facebook.com/docs/app-events/best-practices#standard-events). +extension StandardEventLogging on FacebookAppEvents { + /// Log this event when the user reaches a level in the app. + Future logAchievedLevel({ + String? level, + Map? parameters, + }) { + return logEvent( + name: FacebookAppEvents.eventNameAchievedLevel, + parameters: { + if (parameters != null) ...parameters, + if (level != null) FacebookAppEvents.paramNameLevel: level, + }, + ); + } + + /// Log this event when the user adds payment information. + Future logAddedPaymentInfo({ + Map? parameters, + }) { + return logEvent( + name: FacebookAppEvents.eventNameAddedPaymentInfo, + parameters: parameters, + ); + } + + /// Log this event when the user completes the app's tutorial flow. + Future logCompletedTutorial({ + String? contentId, + Map? parameters, + }) { + return logEvent( + name: FacebookAppEvents.eventNameCompletedTutorial, + parameters: { + if (parameters != null) ...parameters, + if (contentId != null) FacebookAppEvents.paramNameContentId: contentId, + }, + ); + } + + /// Log this event when the user performs a search in the app. + Future logSearched({ + String? searchString, + String? contentType, + Map? parameters, + }) { + return logEvent( + name: FacebookAppEvents.eventNameSearched, + parameters: { + if (parameters != null) ...parameters, + if (searchString != null) + FacebookAppEvents.paramNameSearchString: searchString, + if (contentType != null) + FacebookAppEvents.paramNameContentType: contentType, + }, + ); + } + + /// Log this event when the user spends in-app credits. + /// + /// To be eligible for ad revenue optimization (ROAS), include the + /// [valueToSum] (the credits value) and [currency]. + Future logSpentCredits({ + double? valueToSum, + String? currency, + String? contentType, + String? contentId, + Map? parameters, + }) { + return logEvent( + name: FacebookAppEvents.eventNameSpentCredits, + valueToSum: valueToSum, + parameters: { + if (parameters != null) ...parameters, + if (currency != null) FacebookAppEvents.paramNameCurrency: currency, + if (contentType != null) + FacebookAppEvents.paramNameContentType: contentType, + if (contentId != null) FacebookAppEvents.paramNameContentId: contentId, + }, + ); + } + + /// Log this event when the user unlocks an achievement. + Future logUnlockedAchievement({ + String? description, + Map? parameters, + }) { + return logEvent( + name: FacebookAppEvents.eventNameUnlockedAchievement, + parameters: { + if (parameters != null) ...parameters, + if (description != null) + FacebookAppEvents.paramNameDescription: description, + }, + ); + } + + /// Log this event when the user contacts your business. + Future logContact({ + Map? parameters, + }) { + return logEvent( + name: FacebookAppEvents.eventNameContact, + parameters: parameters, + ); + } + + /// Log this event when the user customizes a product. + Future logCustomizeProduct({ + Map? parameters, + }) { + return logEvent( + name: FacebookAppEvents.eventNameCustomizeProduct, + parameters: parameters, + ); + } + + /// Log this event when the user donates funds. + /// + /// To be eligible for ad revenue optimization (ROAS), include the + /// [valueToSum] (the donation amount) and [currency]. + Future logDonate({ + double? valueToSum, + String? currency, + Map? parameters, + }) { + return logEvent( + name: FacebookAppEvents.eventNameDonate, + valueToSum: valueToSum, + parameters: { + if (parameters != null) ...parameters, + if (currency != null) FacebookAppEvents.paramNameCurrency: currency, + }, + ); + } + + /// Log this event when the user locates one of your physical locations. + Future logFindLocation({ + Map? parameters, + }) { + return logEvent( + name: FacebookAppEvents.eventNameFindLocation, + parameters: parameters, + ); + } + + /// Log this event when the user schedules an appointment. + Future logSchedule({ + Map? parameters, + }) { + return logEvent( + name: FacebookAppEvents.eventNameSchedule, + parameters: parameters, + ); + } + + /// Log this event when the user submits an application (e.g. job, loan). + Future logSubmitApplication({ + Map? parameters, + }) { + return logEvent( + name: FacebookAppEvents.eventNameSubmitApplication, + parameters: parameters, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 6ad9c786..afe54ec3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: facebook_app_events description: Flutter plugin for Facebook App Events, an app measurement solution that provides insight on app usage and user engagement in Facebook Analytics. -version: 0.27.2 +version: 0.28.0 homepage: https://oddb.it/app-events-pubspec environment: diff --git a/test/facebook_app_events_test.dart b/test/facebook_app_events_test.dart index 125351b3..630a7691 100644 --- a/test/facebook_app_events_test.dart +++ b/test/facebook_app_events_test.dart @@ -526,4 +526,336 @@ void main() { ); }); }); + + group('Product catalog', () { + test('logProductItem forwards required fields and enum tokens', () async { + await facebookAppEvents.logProductItem( + itemId: 'SKU-1', + availability: ProductAvailability.inStock, + condition: ProductCondition.newItem, + description: 'A product', + imageLink: 'https://example.com/img.png', + link: 'https://example.com/buy', + title: 'Product', + priceAmount: 9.99, + currency: 'USD', + gtin: '0123456789012', + ); + + expect( + methodCall, + isMethodCall( + 'logProductItem', + arguments: { + 'itemId': 'SKU-1', + 'availability': 'inStock', + 'condition': 'newItem', + 'description': 'A product', + 'imageLink': 'https://example.com/img.png', + 'link': 'https://example.com/buy', + 'title': 'Product', + 'priceAmount': 9.99, + 'currency': 'USD', + 'gtin': '0123456789012', + }, + ), + ); + }); + + test('logProductItem maps availableForOrder/used tokens', () async { + await facebookAppEvents.logProductItem( + itemId: 'SKU-2', + availability: ProductAvailability.availableForOrder, + condition: ProductCondition.used, + description: 'desc', + imageLink: 'https://example.com/i', + link: 'https://example.com/l', + title: 'title', + priceAmount: 1.0, + currency: 'EUR', + brand: 'Acme', + ); + + final args = methodCall?.arguments as Map; + expect(args['availability'], 'availableForOrder'); + expect(args['condition'], 'used'); + expect(args['brand'], 'Acme'); + expect(args.containsKey('gtin'), isFalse); + expect(args.containsKey('mpn'), isFalse); + }); + }); + + group('Flush behavior', () { + test('setFlushBehavior forwards behavior token', () async { + await facebookAppEvents.setFlushBehavior(FlushBehavior.explicitOnly); + + expect( + methodCall, + isMethodCall('setFlushBehavior', arguments: 'explicitOnly'), + ); + }); + + test('getFlushBehavior maps token back to enum', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall m) async { + methodCall = m; + return 'explicitOnly'; + }); + + final behavior = await facebookAppEvents.getFlushBehavior(); + + expect(methodCall, isMethodCall('getFlushBehavior', arguments: null)); + expect(behavior, FlushBehavior.explicitOnly); + }); + + test('getFlushBehavior defaults to auto for unknown token', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall m) async => null); + + final behavior = await facebookAppEvents.getFlushBehavior(); + expect(behavior, FlushBehavior.auto); + }); + }); + + group('Getters and granular user data', () { + test('getUserData returns the value from the platform', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall m) async { + methodCall = m; + return '{"em":"hashed-email"}'; + }); + + final data = await facebookAppEvents.getUserData(); + + expect(methodCall, isMethodCall('getUserData', arguments: null)); + expect(data, '{"em":"hashed-email"}'); + }); + + test('getUserID returns the value from the platform', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall m) async { + methodCall = m; + return 'user-42'; + }); + + final id = await facebookAppEvents.getUserID(); + + expect(methodCall, isMethodCall('getUserID', arguments: null)); + expect(id, 'user-42'); + }); + + test('clearUserDataForType forwards field token', () async { + await facebookAppEvents + .clearUserDataForType(FacebookUserDataField.email); + expect( + methodCall, + isMethodCall('clearUserDataForType', arguments: 'email'), + ); + }); + }); + + group('Push token and debug logging', () { + test('setPushNotificationToken forwards token as scalar', () async { + await facebookAppEvents.setPushNotificationToken('tok-123'); + expect( + methodCall, + isMethodCall('setPushNotificationToken', arguments: 'tok-123'), + ); + }); + + test('setDebugLoggingEnabled forwards boolean', () async { + await facebookAppEvents.setDebugLoggingEnabled(true); + expect( + methodCall, + isMethodCall('setDebugLoggingEnabled', arguments: true), + ); + }); + }); + + group('Additional standard events', () { + test('logAchievedLevel forwards level', () async { + await facebookAppEvents.logAchievedLevel(level: '5'); + + expect( + methodCall, + isMethodCall( + 'logEvent', + arguments: { + 'name': 'fb_mobile_level_achieved', + 'parameters': {'fb_level': '5'}, + }, + ), + ); + }); + + test('logSearched forwards search string and content type', () async { + await facebookAppEvents.logSearched( + searchString: 'shoes', + contentType: 'product', + ); + + expect( + methodCall, + isMethodCall( + 'logEvent', + arguments: { + 'name': 'fb_mobile_search', + 'parameters': { + 'fb_search_string': 'shoes', + 'fb_content_type': 'product', + }, + }, + ), + ); + }); + + test('logUnlockedAchievement forwards description', () async { + await facebookAppEvents.logUnlockedAchievement(description: 'first_win'); + + expect( + methodCall, + isMethodCall( + 'logEvent', + arguments: { + 'name': 'fb_mobile_achievement_unlocked', + 'parameters': {'fb_description': 'first_win'}, + }, + ), + ); + }); + + test('logDonate forwards valueToSum and currency', () async { + await facebookAppEvents.logDonate(valueToSum: 10.0, currency: 'USD'); + + expect( + methodCall, + isMethodCall( + 'logEvent', + arguments: { + 'name': 'Donate', + 'parameters': {'fb_currency': 'USD'}, + '_valueToSum': 10.0, + }, + ), + ); + }); + + test('logSubmitApplication routes through logEvent', () async { + await facebookAppEvents.logSubmitApplication(); + + expect( + methodCall, + isMethodCall( + 'logEvent', + arguments: {'name': 'SubmitApplication'}, + ), + ); + }); + + test('logSpentCredits forwards valueToSum and content', () async { + await facebookAppEvents.logSpentCredits( + valueToSum: 100.0, + currency: 'USD', + contentType: 'coins', + contentId: 'pack-1', + ); + + expect( + methodCall, + isMethodCall( + 'logEvent', + arguments: { + 'name': 'fb_mobile_spent_credits', + 'parameters': { + 'fb_currency': 'USD', + 'fb_content_type': 'coins', + 'fb_content_id': 'pack-1', + }, + '_valueToSum': 100.0, + }, + ), + ); + }); + + test('logAddedPaymentInfo routes through logEvent with parameters', + () async { + await facebookAppEvents.logAddedPaymentInfo( + parameters: {'fb_success': '1'}, + ); + + expect( + methodCall, + isMethodCall( + 'logEvent', + arguments: { + 'name': 'fb_mobile_add_payment_info', + 'parameters': {'fb_success': '1'}, + }, + ), + ); + }); + + test('logCompletedTutorial forwards contentId', () async { + await facebookAppEvents.logCompletedTutorial(contentId: 'intro-1'); + + expect( + methodCall, + isMethodCall( + 'logEvent', + arguments: { + 'name': 'fb_mobile_tutorial_completion', + 'parameters': {'fb_content_id': 'intro-1'}, + }, + ), + ); + }); + + test('logContact routes through logEvent', () async { + await facebookAppEvents.logContact(); + + expect( + methodCall, + isMethodCall( + 'logEvent', + arguments: {'name': 'Contact'}, + ), + ); + }); + + test('logCustomizeProduct routes through logEvent', () async { + await facebookAppEvents.logCustomizeProduct(); + + expect( + methodCall, + isMethodCall( + 'logEvent', + arguments: {'name': 'CustomizeProduct'}, + ), + ); + }); + + test('logFindLocation routes through logEvent', () async { + await facebookAppEvents.logFindLocation(); + + expect( + methodCall, + isMethodCall( + 'logEvent', + arguments: {'name': 'FindLocation'}, + ), + ); + }); + + test('logSchedule routes through logEvent', () async { + await facebookAppEvents.logSchedule(); + + expect( + methodCall, + isMethodCall( + 'logEvent', + arguments: {'name': 'Schedule'}, + ), + ); + }); + }); }