Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Next

- feat: Add `onFeatureFlags` callback to `Posthog()` to listen for feature flag load events. On Web, this callback provides all flags and variants. On mobile (Android/iOS), it serves as a signal that flags have been loaded by the native SDK; the `flags` and `flagVariants` parameters will be empty in the callback, and developers should use `Posthog.getFeatureFlag()` or `Posthog.isFeatureEnabled()` to retrieve specific flag values. This allows developers to ensure flags are loaded before checking them, especially on the first app run. ([#224](https://github.com/PostHog/posthog-flutter/pull/224))

## 5.9.0

- feat: add autocapture exceptions ([#214](https://github.com/PostHog/posthog-flutter/pull/214))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import android.util.Log
import com.posthog.PersonProfiles
import com.posthog.PostHog
import com.posthog.PostHogConfig
import com.posthog.PostHogOnFeatureFlags
import com.posthog.android.PostHogAndroid
import com.posthog.android.PostHogAndroidConfig
import com.posthog.android.internal.getApplicationInfo
Expand Down Expand Up @@ -305,7 +306,14 @@ class PosthogFlutterPlugin :

sdkName = "posthog-flutter"
sdkVersion = postHogVersion

onFeatureFlags =
PostHogOnFeatureFlags {
Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.")
invokeFlutterMethod("onFeatureFlagsCallback", emptyMap<String, Any?>())
}
}

PostHogAndroid.setup(applicationContext, config)
}

Expand Down
8 changes: 8 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final config =
PostHogConfig('phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D');
config.onFeatureFlags = (flags, flagVariants, {errorsLoading}) {
debugPrint('=============');
debugPrint('[PostHog] Feature flags callback called!');
debugPrint('[PostHog] Flags: $flags');
debugPrint('[PostHog] Flag variants: $flagVariants');
debugPrint('[PostHog] Errors loading: $errorsLoading');
debugPrint('=============');
};
config.debug = true;
config.captureApplicationLifecycleEvents = false;
config.host = 'https://us.i.posthog.com';
Expand Down
30 changes: 24 additions & 6 deletions ios/Classes/PosthogFlutterPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,47 @@ import PostHog

public class PosthogFlutterPlugin: NSObject, FlutterPlugin {
private static var instance: PosthogFlutterPlugin?
private var channel: FlutterMethodChannel?

public static func getInstance() -> PosthogFlutterPlugin? {
instance
}

override init() {
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(featureFlagsDidUpdate),
name: PostHogSDK.didReceiveFeatureFlags,
object: nil
)
}

deinit {
NotificationCenter.default.removeObserver(self)
}

public static func register(with registrar: FlutterPluginRegistrar) {
let methodChannel: FlutterMethodChannel
#if os(iOS)
let channel = FlutterMethodChannel(name: "posthog_flutter", binaryMessenger: registrar.messenger())
methodChannel = FlutterMethodChannel(name: "posthog_flutter", binaryMessenger: registrar.messenger())
#elseif os(macOS)
let channel = FlutterMethodChannel(name: "posthog_flutter", binaryMessenger: registrar.messenger)
methodChannel = FlutterMethodChannel(name: "posthog_flutter", binaryMessenger: registrar.messenger)
#endif
let instance = PosthogFlutterPlugin()
instance.channel = channel
instance.channel = methodChannel
PosthogFlutterPlugin.instance = instance
initPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
registrar.addMethodCallDelegate(instance, channel: methodChannel)
}

@objc func featureFlagsDidUpdate() {
invokeFlutterMethod("onFeatureFlagsCallback", arguments: [String: Any]())
}

private let dispatchQueue = DispatchQueue(label: "com.posthog.PosthogFlutterPlugin",
target: .global(qos: .utility))

private var channel: FlutterMethodChannel?

public static func initPlugin() {
let autoInit = Bundle.main.object(forInfoDictionaryKey: "com.posthog.posthog.AUTO_INIT") as? Bool ?? true
if !autoInit {
Expand Down
221 changes: 219 additions & 2 deletions lib/posthog_flutter_web.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// In order to *not* need this ignore, consider extracting the "web" version
// of your plugin as a separate package, instead of inlining it in the same
// package as the core of your plugin.
import 'dart:js_interop';

import 'package:flutter/services.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';

import 'src/posthog_config.dart';
import 'src/posthog_flutter_platform_interface.dart';
import 'src/posthog_flutter_web_handler.dart';

Expand All @@ -20,8 +23,222 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface {
);
final PosthogFlutterWeb instance = PosthogFlutterWeb();
channel.setMethodCallHandler(instance.handleMethodCall);

// Set the platform instance so that Posthog() can call methods directly
// on the web implementation instead of going through the method channel.
// This is required because method channels can only pass serializable data,
// not function callbacks like onFeatureFlags.
PosthogFlutterPlatformInterface.instance = instance;
}

Future<dynamic> handleMethodCall(MethodCall call) async {
// The 'setup' call is now handled by the setup method override.
// Other method calls are delegated to handleWebMethodCall.
if (call.method == 'setup') {
// This case should ideally not be hit if Posthog().setup directly calls the overridden setup.
// However, to be safe, we can log or ignore.
// For now, let's assume direct call to overridden setup handles it.
return null;
}
return handleWebMethodCall(call);
}

@override
Future<void> setup(PostHogConfig config) async {
// It's assumed posthog-js is initialized by the user in their HTML.
// This setup primarily hooks into the existing posthog-js instance.

// If apiKey and host are in config, and posthog.init is to be handled by plugin:
// This is an example if we wanted the plugin to also call posthog.init()
// final jsOptions = <String, dynamic>{
// 'api_host': config.host,
// // Add other relevant options from PostHogConfig if needed for JS init
// }.jsify();
// posthog?.callMethod('init'.toJS, config.apiKey.toJS, jsOptions);

final ph = posthog;
if (config.onFeatureFlags != null && ph != null) {
final dartCallback = config.onFeatureFlags!;

// JS SDK calls with: (flags: string[], variants: Record, context?: {errorsLoading})
// Use optional positional param [jsContext] to handle when JS omits the 3rd argument
final jsCallback =
(JSArray jsFlags, JSObject jsFlagVariants, [JSObject? jsContext]) {
final List<String> flags = jsFlags.toDart.whereType<String>().toList();

Map<String, Object?> flagVariants = {};
final dartVariantsMap =
jsFlagVariants.dartify() as Map<Object?, Object?>?;
if (dartVariantsMap != null) {
flagVariants = dartVariantsMap
.map((key, value) => MapEntry(key.toString(), value));
}

bool errorsLoading = false;
if (jsContext != null) {
final contextMap = jsContext.dartify() as Map<Object?, Object?>?;
errorsLoading = contextMap?['errorsLoading'] as bool? ?? false;
}

dartCallback(flags, flagVariants, errorsLoading: errorsLoading);
}.toJS;

ph.onFeatureFlags(jsCallback);
}
}

@override
Future<void> identify({
required String userId,
Map<String, Object>? userProperties,
Map<String, Object>? userPropertiesSetOnce,
}) async {
return handleWebMethodCall(MethodCall('identify', {
'userId': userId,
if (userProperties != null) 'userProperties': userProperties,
if (userPropertiesSetOnce != null)
'userPropertiesSetOnce': userPropertiesSetOnce,
}));
}

@override
Future<void> capture({
required String eventName,
Map<String, Object>? properties,
}) async {
return handleWebMethodCall(MethodCall('capture', {
'eventName': eventName,
if (properties != null) 'properties': properties,
}));
}

@override
Future<void> screen({
required String screenName,
Map<String, Object>? properties,
}) async {
return handleWebMethodCall(MethodCall('screen', {
'screenName': screenName,
if (properties != null) 'properties': properties,
}));
}

@override
Future<void> alias({required String alias}) async {
return handleWebMethodCall(MethodCall('alias', {'alias': alias}));
}

@override
Future<String> getDistinctId() async {
final result = await handleWebMethodCall(const MethodCall('distinctId'));
return result as String? ?? '';
}

@override
Future<void> reset() async {
return handleWebMethodCall(const MethodCall('reset'));
}

@override
Future<void> disable() async {
return handleWebMethodCall(const MethodCall('disable'));
}

@override
Future<void> enable() async {
return handleWebMethodCall(const MethodCall('enable'));
}

Future<dynamic> handleMethodCall(MethodCall call) =>
handleWebMethodCall(call);
@override
Future<bool> isOptOut() async {
final result = await handleWebMethodCall(const MethodCall('isOptOut'));
return result as bool? ?? true;
}

@override
Future<void> debug(bool enabled) async {
return handleWebMethodCall(MethodCall('debug', {'debug': enabled}));
}

@override
Future<void> register(String key, Object value) async {
return handleWebMethodCall(
MethodCall('register', {'key': key, 'value': value}));
}

@override
Future<void> unregister(String key) async {
return handleWebMethodCall(MethodCall('unregister', {'key': key}));
}

@override
Future<bool> isFeatureEnabled(String key) async {
final result =
await handleWebMethodCall(MethodCall('isFeatureEnabled', {'key': key}));
return result as bool? ?? false;
}

@override
Future<void> reloadFeatureFlags() async {
return handleWebMethodCall(const MethodCall('reloadFeatureFlags'));
}

@override
Future<void> group({
required String groupType,
required String groupKey,
Map<String, Object>? groupProperties,
}) async {
return handleWebMethodCall(MethodCall('group', {
'groupType': groupType,
'groupKey': groupKey,
if (groupProperties != null) 'groupProperties': groupProperties,
}));
}

@override
Future<Object?> getFeatureFlag({required String key}) async {
return handleWebMethodCall(MethodCall('getFeatureFlag', {'key': key}));
}

@override
Future<Object?> getFeatureFlagPayload({required String key}) async {
return handleWebMethodCall(
MethodCall('getFeatureFlagPayload', {'key': key}));
}

@override
Future<void> flush() async {
return handleWebMethodCall(const MethodCall('flush'));
}

@override
Future<void> close() async {
return handleWebMethodCall(const MethodCall('close'));
}

@override
Future<String?> getSessionId() async {
final result = await handleWebMethodCall(const MethodCall('getSessionId'));
return result as String?;
}

@override
Future<void> openUrl(String url) async {
// Not supported on web
}

@override
Future<void> showSurvey(Map<String, dynamic> survey) async {
// Not supported on web - surveys handled by posthog-js
}

@override
Future<void> captureException({
required Object error,
StackTrace? stackTrace,
Map<String, Object>? properties,
}) async {
// Not implemented on web
}
}
46 changes: 42 additions & 4 deletions lib/src/posthog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,48 @@ class Posthog {

String? _currentScreen;

/// Android and iOS only
/// Only used for the manual setup
/// Requires disabling the automatic init on Android and iOS:
/// com.posthog.posthog.AUTO_INIT: false
/// Initializes the PostHog SDK.
///
/// This method sets up the connection to your PostHog instance and prepares the SDK for tracking events and feature flags.
///
/// - [config]: The [PostHogConfig] object containing your API key, host, and other settings.
/// To listen for feature flag load events, provide an `onFeatureFlags` callback in the [PostHogConfig].
///
/// **Behavior of `onFeatureFlags` callback (when provided in `PostHogConfig`):**
///
/// **Web:**
/// The callback will receive:
/// - `flags`: A list of active feature flag keys (`List<String>`).
/// - `flagVariants`: A map of feature flag keys to their variant values (`Map<String, dynamic>`).
/// - `errorsLoading`: Will be `false` as the callback firing implies success.
///
/// **Mobile (Android/iOS):**
/// The callback serves primarily as a notification that the native PostHog SDK
/// has finished loading feature flags. In this case:
/// - `flags`: Will be an empty list (`List<String>`).
/// - `flagVariants`: Will be an empty map (`Map<String, dynamic>`).
/// - `errorsLoading`: Will be `null` if the native call was successful but contained no error info, or `true` if an error occurred during Dart-side processing of the callback.
/// After this callback is invoked, you can reliably use `Posthog().getFeatureFlag('your-flag-key')`
/// or `Posthog().isFeatureEnabled('your-flag-key')` to get the values of specific flags.
///
/// **Example with `onFeatureFlags` in `PostHogConfig`:**
/// ```dart
/// final config = PostHogConfig(
/// apiKey: 'YOUR_API_KEY',
/// host: 'YOUR_POSTHOG_HOST',
/// onFeatureFlags: (flags, flagVariants, {errorsLoading}) {
/// if (errorsLoading == true) {
/// print('Error loading feature flags!');
/// return;
/// }
/// // ... process flags ...
/// },
/// );
/// await Posthog().setup(config);
/// ```
///
/// For Android and iOS, if you are performing a manual setup,
/// ensure `com.posthog.posthog.AUTO_INIT: false` is set in your native configuration.
Future<void> setup(PostHogConfig config) {
_config = config; // Store the config

Expand Down
Loading
Loading