diff --git a/CHANGELOG.md b/CHANGELOG.md index aa737379..c1aa1080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- feat: add `getFeatureFlagResult` API ([#279](https://github.com/PostHog/posthog-flutter/pull/279)) + # 5.13.0 - chore: add support for thumbs up/down surveys ([#257](https://github.com/PostHog/posthog-flutter/pull/257)) diff --git a/android/build.gradle b/android/build.gradle index 7c20f520..b6a40db4 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -53,8 +53,8 @@ android { dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation 'org.mockito:mockito-core:5.0.0' - // + Version 3.30.0 and the versions up to 4.0.0, not including 4.0.0 and higher - implementation 'com.posthog:posthog-android:[3.30.0,4.0.0]' + // + Version 3.31.0 and the versions up to 4.0.0, not including 4.0.0 and higher + implementation 'com.posthog:posthog-android:[3.31.0,4.0.0]' } testOptions { diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index fc5685c8..885c3e34 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -146,6 +146,10 @@ class PosthogFlutterPlugin : getFeatureFlagPayload(call, result) } + "getFeatureFlagResult" -> { + getFeatureFlagResult(call, result) + } + "register" -> { register(call, result) } @@ -367,6 +371,36 @@ class PosthogFlutterPlugin : } } + private fun getFeatureFlagResult( + call: MethodCall, + result: Result, + ) { + try { + val featureFlagKey = call.argument("key") + if (featureFlagKey.isNullOrEmpty()) { + result.error("PosthogFlutterException", "Missing argument: key", null) + return + } + val sendEvent: Boolean = call.argument("sendEvent") ?: true + val flagResult = PostHog.getFeatureFlagResult(featureFlagKey, sendEvent) + + if (flagResult != null) { + result.success( + mapOf( + "key" to flagResult.key, + "enabled" to flagResult.enabled, + "variant" to flagResult.variant, + "payload" to flagResult.payload, + ), + ) + } else { + result.success(null) + } + } catch (e: Throwable) { + result.error("PosthogFlutterException", e.localizedMessage, null) + } + } + private fun identify( call: MethodCall, result: Result, diff --git a/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index d1eb3418..c5ff4cad 100644 --- a/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -39,4 +39,19 @@ internal class PosthogFlutterPluginTest { Mockito.verify(mockResult).success(true) } + + @Test + fun onMethodCall_getFeatureFlagResult_missingKey_returnsError() { + val plugin = PosthogFlutterPlugin() + + val call = MethodCall("getFeatureFlagResult", mapOf()) + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(call, mockResult) + + Mockito.verify(mockResult).error( + Mockito.eq("PosthogFlutterException"), + Mockito.eq("Missing argument: key"), + Mockito.isNull() + ) + } } diff --git a/example/lib/main.dart b/example/lib/main.dart index 9092b410..d14d3722 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -490,6 +490,16 @@ class InitialScreenState extends State { }, child: const Text("getFeatureFlagPayload"), ), + ElevatedButton( + onPressed: () async { + final result = await _posthogFlutterPlugin + .getFeatureFlagResult("feature_name"); + setState(() { + _result = result?.toString(); + }); + }, + child: const Text("getFeatureFlagResult"), + ), ElevatedButton( onPressed: () async { await _posthogFlutterPlugin.reloadFeatureFlags(); diff --git a/ios/Classes/PosthogFlutterPlugin.swift b/ios/Classes/PosthogFlutterPlugin.swift index 918edbbd..ac42ada1 100644 --- a/ios/Classes/PosthogFlutterPlugin.swift +++ b/ios/Classes/PosthogFlutterPlugin.swift @@ -183,6 +183,8 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { isFeatureEnabled(call, result: result) case "getFeatureFlagPayload": getFeatureFlagPayload(call, result: result) + case "getFeatureFlagResult": + getFeatureFlagResult(call, result: result) case "identify": identify(call, result: result) case "capture": @@ -531,6 +533,31 @@ extension PosthogFlutterPlugin { } } + private func getFeatureFlagResult( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + if let args = call.arguments as? [String: Any], + let featureFlagKey = args["key"] as? String + { + let sendEvent = args["sendEvent"] as? Bool ?? true + let flagResult = PostHogSDK.shared.getFeatureFlagResult(featureFlagKey, sendFeatureFlagEvent: sendEvent) + + if let flagResult { + result([ + "key": flagResult.key, + "enabled": flagResult.enabled, + "variant": flagResult.variant as Any, + "payload": flagResult.payload as Any + ]) + } else { + result(nil) + } + } else { + _badArgumentError(result) + } + } + private func identify( _ call: FlutterMethodCall, result: @escaping FlutterResult diff --git a/ios/posthog_flutter.podspec b/ios/posthog_flutter.podspec index 4f5f2e17..ecfc7b4f 100644 --- a/ios/posthog_flutter.podspec +++ b/ios/posthog_flutter.podspec @@ -21,8 +21,8 @@ Postog flutter plugin s.ios.dependency 'Flutter' s.osx.dependency 'FlutterMacOS' - # ~> Version 3.38.0 up to, but not including, 4.0.0 - s.dependency 'PostHog', '>= 3.38.0', '< 4.0.0' + # ~> Version 3.40.0 up to, but not including, 4.0.0 + s.dependency 'PostHog', '>= 3.40.0', '< 4.0.0' s.ios.deployment_target = '13.0' # PH iOS SDK 3.0.0 requires >= 10.15 diff --git a/lib/posthog_flutter.dart b/lib/posthog_flutter.dart index dafab8b3..8b700ba9 100644 --- a/lib/posthog_flutter.dart +++ b/lib/posthog_flutter.dart @@ -1,5 +1,6 @@ library posthog_flutter; +export 'src/feature_flag_result.dart'; export 'src/posthog.dart'; export 'src/posthog_config.dart'; export 'src/posthog_event.dart'; diff --git a/lib/posthog_flutter_web.dart b/lib/posthog_flutter_web.dart index 99849151..09cd7fcb 100644 --- a/lib/posthog_flutter_web.dart +++ b/lib/posthog_flutter_web.dart @@ -9,6 +9,7 @@ import 'package:posthog_flutter/src/error_tracking/dart_exception_processor.dart import 'package:posthog_flutter/src/util/logging.dart'; import 'package:posthog_flutter/src/utils/property_normalizer.dart'; +import 'src/feature_flag_result.dart'; import 'src/posthog_config.dart'; import 'src/posthog_flutter_platform_interface.dart'; import 'src/posthog_flutter_web_handler.dart'; @@ -229,6 +230,18 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { MethodCall('getFeatureFlagPayload', {'key': key})); } + @override + Future getFeatureFlagResult({ + required String key, + bool sendEvent = true, + }) async { + final result = await handleWebMethodCall(MethodCall( + 'getFeatureFlagResult', {'key': key, 'sendEvent': sendEvent})); + + // Web SDK returns: { key, enabled, variant, payload } + return PostHogFeatureFlagResult.fromMap(result, key); + } + @override Future flush() async { return handleWebMethodCall(const MethodCall('flush')); diff --git a/lib/src/feature_flag_result.dart b/lib/src/feature_flag_result.dart new file mode 100644 index 00000000..d2b43890 --- /dev/null +++ b/lib/src/feature_flag_result.dart @@ -0,0 +1,61 @@ +/// Represents the result of a feature flag evaluation. +/// +/// Contains the flag key, whether it's enabled, the variant (for multivariate flags), +/// and any associated payload. +class PostHogFeatureFlagResult { + /// The feature flag key. + final String key; + + /// Whether the flag is enabled. + /// + /// For boolean flags, this is the flag value. + /// For multivariate flags, this is true when the flag evaluates to any variant. + final bool enabled; + + /// The variant key for multivariate flags, or null for boolean flags. + final String? variant; + + /// The JSON payload associated with the flag, if any. + final Object? payload; + + const PostHogFeatureFlagResult({ + required this.key, + required this.enabled, + this.variant, + this.payload, + }); + + @override + String toString() { + return 'PostHogFeatureFlagResult(key: $key, enabled: $enabled, variant: $variant, payload: $payload)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is PostHogFeatureFlagResult && + other.key == key && + other.enabled == enabled && + other.variant == variant; + } + + @override + int get hashCode => Object.hash(key, enabled, variant); + + /// Creates a [PostHogFeatureFlagResult] from a native SDK response map. + /// + /// The [map] should contain: key, enabled, variant, payload. + /// Falls back to [fallbackKey] if the map doesn't include a key. + /// Returns null if [result] is null or not a Map. + static PostHogFeatureFlagResult? fromMap(Object? result, String fallbackKey) { + if (result == null) return null; + if (result is! Map) return null; + + return PostHogFeatureFlagResult( + key: result['key'] as String? ?? fallbackKey, + enabled: result['enabled'] as bool? ?? false, + variant: result['variant'] as String?, + payload: result['payload'], + ); + } +} diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 6ac98230..bdb05de8 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -2,6 +2,7 @@ import 'package:meta/meta.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_exception.dart'; +import 'feature_flag_result.dart'; import 'posthog_config.dart'; import 'posthog_flutter_platform_interface.dart'; import 'posthog_observer.dart'; @@ -158,9 +159,37 @@ class Posthog { groupProperties: groupProperties, ); + /// Returns the feature flag value for the given key. + /// + /// Returns `null` if the flag doesn't exist. + /// For boolean flags, returns `true` or `false`. + /// For multivariate flags, returns the variant string. Future getFeatureFlag(String key) => _posthog.getFeatureFlag(key: key); + /// Returns the full feature flag result including value and payload. + /// + /// This is the canonical method for getting feature flag data. + /// Returns `null` if the flag doesn't exist. + /// + /// Set [sendEvent] to `false` to suppress the `$feature_flag_called` event. + /// This is useful when you only need the payload and don't want to emit the event. + /// + /// **Example:** + /// ```dart + /// final result = await Posthog().getFeatureFlagResult('my-flag'); + /// if (result != null && result.enabled) { + /// final variant = result.variant; // For multivariate flags + /// final payload = result.payload; // Associated payload data + /// } + /// ``` + Future getFeatureFlagResult(String key, + {bool sendEvent = true}) => + _posthog.getFeatureFlagResult(key: key, sendEvent: sendEvent); + + /// Returns the payload for a feature flag. + @Deprecated( + 'Use getFeatureFlagResult instead, which returns both value and payload.') Future getFeatureFlagPayload(String key) => _posthog.getFeatureFlagPayload(key: key); diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index c2187e88..12f090c7 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -13,6 +13,7 @@ import 'error_tracking/dart_exception_processor.dart'; import 'utils/capture_utils.dart'; import 'utils/property_normalizer.dart'; +import 'feature_flag_result.dart'; import 'posthog_config.dart'; import 'posthog_constants.dart'; import 'posthog_event.dart'; @@ -525,6 +526,29 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } } + @override + Future getFeatureFlagResult({ + required String key, + bool sendEvent = true, + }) async { + if (!isSupportedPlatform()) { + return null; + } + + try { + final result = await _methodChannel.invokeMethod('getFeatureFlagResult', { + 'key': key, + 'sendEvent': sendEvent, + }); + + // Native returns: { key, enabled, variant, payload } + return PostHogFeatureFlagResult.fromMap(result, key); + } on PlatformException catch (exception) { + printIfDebug('Exception on getFeatureFlagResult: $exception'); + return null; + } + } + @override Future register(String key, Object value) async { if (!isSupportedPlatform()) { diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index 3a5223f3..841d6d33 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -1,5 +1,6 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'feature_flag_result.dart'; import 'posthog_config.dart'; import 'posthog_flutter_io.dart'; @@ -133,6 +134,14 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { 'getFeatureFlagPayload() has not been implemented.'); } + Future getFeatureFlagResult({ + required String key, + bool sendEvent = true, + }) { + throw UnimplementedError( + 'getFeatureFlagResult() has not been implemented.'); + } + Future flush() { throw UnimplementedError('flush() has not been implemented.'); } diff --git a/lib/src/posthog_flutter_web_handler.dart b/lib/src/posthog_flutter_web_handler.dart index e7a0d4b4..1259aa98 100644 --- a/lib/src/posthog_flutter_web_handler.dart +++ b/lib/src/posthog_flutter_web_handler.dart @@ -30,6 +30,7 @@ extension PostHogExtension on PostHog { external bool has_opted_out_capturing(); external JSAny? getFeatureFlag(JSAny key); external JSAny? getFeatureFlagPayload(JSAny key); + external JSAny? getFeatureFlagResult(JSAny key, [JSAny? options]); external void register(JSAny properties); external void unregister(JSAny key); // ignore: non_constant_identifier_names @@ -444,6 +445,15 @@ Future handleWebMethodCall(MethodCall call) async { stringToJSAny(key), ); return featureFlag?.dartify(); + case 'getFeatureFlagResult': + final key = args['key'] as String; + final sendEvent = args['sendEvent'] as bool? ?? true; + + final result = posthog?.getFeatureFlagResult( + stringToJSAny(key), + {'send_event': sendEvent}.jsify(), + ); + return result?.dartify(); case 'register': final key = args['key'] as String; final value = args['value']; diff --git a/test/posthog_flutter_platform_interface_fake.dart b/test/posthog_flutter_platform_interface_fake.dart index 0545d375..21ae12b7 100644 --- a/test/posthog_flutter_platform_interface_fake.dart +++ b/test/posthog_flutter_platform_interface_fake.dart @@ -1,3 +1,4 @@ +import 'package:posthog_flutter/src/feature_flag_result.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; @@ -20,6 +21,13 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { final List capturedExceptions = []; PostHogConfig? receivedConfig; + // Feature flag test data + final Map featureFlagValues = {}; + final Map featureFlagPayloads = {}; + + // Call tracking for getFeatureFlagResult + final List> getFeatureFlagResultCalls = []; + @override Future screen({ required String screenName, @@ -48,4 +56,36 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { properties: properties, )); } + + @override + Future getFeatureFlag({required String key}) async { + return featureFlagValues[key]; + } + + @override + Future getFeatureFlagPayload({required String key}) async { + return featureFlagPayloads[key]; + } + + @override + Future getFeatureFlagResult({ + required String key, + bool sendEvent = true, + }) async { + getFeatureFlagResultCalls.add({'key': key, 'sendEvent': sendEvent}); + + if (!featureFlagValues.containsKey(key)) { + return null; + } + final value = featureFlagValues[key]; + final payload = featureFlagPayloads[key]; + final enabled = value != null && value != false; + final variant = (value is String) ? value : null; + return PostHogFeatureFlagResult( + key: key, + enabled: enabled, + variant: variant, + payload: payload, + ); + } } diff --git a/test/posthog_test.dart b/test/posthog_test.dart index 07ac9e8d..e356efed 100644 --- a/test/posthog_test.dart +++ b/test/posthog_test.dart @@ -32,4 +32,206 @@ void main() { equals(testCallback)); }); }); + + group('getFeatureFlagResult', () { + late PosthogFlutterPlatformFake fakePlatformInterface; + + setUp(() { + fakePlatformInterface = PosthogFlutterPlatformFake(); + PosthogFlutterPlatformInterface.instance = fakePlatformInterface; + }); + + test('returns null for non-existent flag', () async { + final result = await Posthog().getFeatureFlagResult('non-existent'); + expect(result, isNull); + }); + + test('returns correct result for boolean flag (true)', () async { + fakePlatformInterface.featureFlagValues['bool-flag'] = true; + + final result = await Posthog().getFeatureFlagResult('bool-flag'); + + expect(result, isNotNull); + expect(result!.key, equals('bool-flag')); + expect(result.enabled, isTrue); + expect(result.variant, isNull); + expect(result.payload, isNull); + }); + + test('returns correct result for boolean flag (false)', () async { + fakePlatformInterface.featureFlagValues['disabled-flag'] = false; + + final result = await Posthog().getFeatureFlagResult('disabled-flag'); + + expect(result, isNotNull); + expect(result!.key, equals('disabled-flag')); + expect(result.enabled, isFalse); + expect(result.variant, isNull); + }); + + test('returns correct result for multivariate flag', () async { + fakePlatformInterface.featureFlagValues['multi-flag'] = 'variant-a'; + + final result = await Posthog().getFeatureFlagResult('multi-flag'); + + expect(result, isNotNull); + expect(result!.key, equals('multi-flag')); + expect(result.enabled, isTrue); + expect(result.variant, equals('variant-a')); + }); + + test('includes payload when present', () async { + fakePlatformInterface.featureFlagValues['flag-with-payload'] = true; + fakePlatformInterface.featureFlagPayloads['flag-with-payload'] = { + 'discount': 10, + 'message': 'Welcome!', + }; + + final result = await Posthog().getFeatureFlagResult('flag-with-payload'); + + expect(result, isNotNull); + expect(result!.payload, isNotNull); + expect(result.payload, isA()); + final payload = result.payload as Map; + expect(payload['discount'], equals(10)); + expect(payload['message'], equals('Welcome!')); + }); + + test('multivariate flag with payload', () async { + fakePlatformInterface.featureFlagValues['multi-with-payload'] = 'control'; + fakePlatformInterface.featureFlagPayloads['multi-with-payload'] = [ + 1, + 2, + 3 + ]; + + final result = await Posthog().getFeatureFlagResult('multi-with-payload'); + + expect(result, isNotNull); + expect(result!.enabled, isTrue); + expect(result.variant, equals('control')); + expect(result.payload, equals([1, 2, 3])); + }); + + test('returns result for flag with null value', () async { + // Flag exists but has null value - should return a result, not null + fakePlatformInterface.featureFlagValues['null-value-flag'] = null; + + final result = await Posthog().getFeatureFlagResult('null-value-flag'); + + expect(result, isNotNull); + expect(result!.key, equals('null-value-flag')); + expect(result.enabled, isFalse); + }); + + test('passes sendEvent=true by default', () async { + fakePlatformInterface.featureFlagValues['test'] = true; + + await Posthog().getFeatureFlagResult('test'); + + expect(fakePlatformInterface.getFeatureFlagResultCalls.last['sendEvent'], + isTrue); + }); + + test('passes sendEvent=false when specified', () async { + fakePlatformInterface.featureFlagValues['test'] = true; + + await Posthog().getFeatureFlagResult('test', sendEvent: false); + + expect(fakePlatformInterface.getFeatureFlagResultCalls.last['sendEvent'], + isFalse); + }); + }); + + group('PostHogFeatureFlagResult', () { + test('equality', () { + final result1 = PostHogFeatureFlagResult( + key: 'flag', + enabled: true, + variant: 'a', + payload: 1, + ); + final result2 = PostHogFeatureFlagResult( + key: 'flag', + enabled: true, + variant: 'a', + payload: 1, + ); + final result3 = PostHogFeatureFlagResult( + key: 'flag', + enabled: true, + variant: 'b', + payload: 1, + ); + + expect(result1, equals(result2)); + expect(result1, isNot(equals(result3))); + }); + + test('toString', () { + final result = PostHogFeatureFlagResult( + key: 'my-flag', + enabled: true, + variant: 'test', + payload: null, + ); + + expect( + result.toString(), + equals( + 'PostHogFeatureFlagResult(key: my-flag, enabled: true, variant: test, payload: null)'), + ); + }); + + test('fromMap returns null for null input', () { + final result = PostHogFeatureFlagResult.fromMap(null, 'fallback'); + expect(result, isNull); + }); + + test('fromMap returns null for non-Map input', () { + final result = PostHogFeatureFlagResult.fromMap('not a map', 'fallback'); + expect(result, isNull); + }); + + test('fromMap parses valid map', () { + final map = { + 'key': 'my-flag', + 'enabled': true, + 'variant': 'test-variant', + 'payload': {'data': 123}, + }; + + final result = PostHogFeatureFlagResult.fromMap(map, 'fallback'); + + expect(result, isNotNull); + expect(result!.key, equals('my-flag')); + expect(result.enabled, isTrue); + expect(result.variant, equals('test-variant')); + expect(result.payload, equals({'data': 123})); + }); + + test('fromMap uses fallback key when map key is null', () { + final map = { + 'enabled': true, + 'variant': null, + 'payload': null, + }; + + final result = PostHogFeatureFlagResult.fromMap(map, 'fallback-key'); + + expect(result, isNotNull); + expect(result!.key, equals('fallback-key')); + }); + + test('fromMap defaults enabled to false when not in map', () { + final map = { + 'key': 'my-flag', + }; + + final result = PostHogFeatureFlagResult.fromMap(map, 'fallback'); + + expect(result, isNotNull); + expect(result!.enabled, isFalse); + }); + }); }