Skip to content
Closed
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
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 event-level group association via `Posthog().capture(groups: ...)` (sets `$groups` on the event without persisting session groups)

# 5.11.0

- chore: update languageVersion and apiVersion from 1.8 to 2.0 on Android to be compatible with Kotlin 2.3 ([#245](https://github.com/PostHog/posthog-flutter/pull/245))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,14 @@ class PosthogFlutterPlugin :
try {
val eventName: String = call.argument("eventName")!!
val properties: Map<String, Any>? = call.argument("properties")
PostHog.capture(eventName, properties = properties)
val groups: Map<String, String>? = call.argument("groups")
PostHog.capture(
event = eventName,
properties = properties,
userProperties = null,
userPropertiesSetOnce = null,
groups = groups
)
result.success(null)
} catch (e: Throwable) {
result.error("PosthogFlutterException", e.localizedMessage, null)
Expand Down
10 changes: 10 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ class InitialScreenState extends State<InitialScreen> {
},
child: const Text("Capture Event"),
),
ElevatedButton(
onPressed: () {
_posthogFlutterPlugin.capture(
eventName: "event_with_groups",
properties: {"foo": "bar"},
groups: {"company": "company_123"},
);
},
child: const Text("Capture (groups)"),
),
],
),
const Divider(),
Expand Down
6 changes: 5 additions & 1 deletion ios/Classes/PosthogFlutterPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -560,9 +560,13 @@ extension PosthogFlutterPlugin {
let eventName = args["eventName"] as? String
{
let properties = args["properties"] as? [String: Any]
let groups = args["groups"] as? [String: String]
PostHogSDK.shared.capture(
eventName,
properties: properties
properties: properties,
userProperties: nil,
userPropertiesSetOnce: nil,
groups: groups
)
result(nil)
} else {
Expand Down
23 changes: 22 additions & 1 deletion lib/posthog_flutter_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,31 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface {
Future<void> capture({
required String eventName,
Map<String, Object>? properties,
Map<String, Object>? groups,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

on Android and iOS: groups: Map<String, String>, so lets make it the same instead of String, Object, double check the rest of the PR

}) async {
Map<String, Object>? mergedProperties =
properties == null ? null : {...properties};

if (groups != null && groups.isNotEmpty) {
mergedProperties ??= <String, Object>{};
final existingGroups = mergedProperties['\$groups'];
if (existingGroups is Map) {
mergedProperties['\$groups'] = {
...Map<String, Object>.from(
existingGroups.map(
(key, value) => MapEntry(key.toString(), value as Object),
Comment on lines +102 to +104
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

you dont need this if we make <String, String>

),
),
...groups,
};
} else {
mergedProperties['\$groups'] = groups;
}
}

return handleWebMethodCall(MethodCall('capture', {
'eventName': eventName,
if (properties != null) 'properties': properties,
if (mergedProperties != null) 'properties': mergedProperties,
}));
}

Expand Down
24 changes: 20 additions & 4 deletions lib/src/posthog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,34 @@ class Posthog {
Future<void> capture({
required String eventName,
Map<String, Object>? properties,
/// Event-level group context.
///
/// This associates *only this event* with the provided groups, without
/// persisting the group mapping for future events (unlike `group()`).
///
/// On iOS/Android, this is passed to the native SDK's `groups` parameter
/// which properly merges with any sticky groups set via `group()`.
Map<String, Object>? groups,
}) {
final propertiesCopy = properties == null ? null : {...properties};

final currentScreen = _currentScreen;
if (propertiesCopy != null &&
!propertiesCopy.containsKey('\$screen_name') &&
currentScreen != null) {
propertiesCopy['\$screen_name'] = currentScreen;
if (currentScreen != null) {
final props = propertiesCopy ?? <String, Object>{};
if (!props.containsKey('\$screen_name')) {
props['\$screen_name'] = currentScreen;
}
return _posthog.capture(
eventName: eventName,
properties: props,
groups: groups,
);
Comment on lines +103 to +107
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

not needed since it will return below anyway

}

return _posthog.capture(
eventName: eventName,
properties: propertiesCopy,
groups: groups,
);
}

Expand Down
16 changes: 16 additions & 0 deletions lib/src/posthog_flutter_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'util/platform_io_stub.dart'
if (dart.library.io) 'util/platform_io_real.dart';

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

import 'package:posthog_flutter/src/surveys/survey_service.dart';
import 'package:posthog_flutter/src/util/logging.dart';
Expand All @@ -21,6 +22,11 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface {
_methodChannel.setMethodCallHandler(_handleMethodCall);
}

@visibleForTesting
Future<dynamic> handleMethodCallForTest(MethodCall call) {
return _handleMethodCall(call);
}

/// The method channel used to interact with the native platform.
final _methodChannel = const MethodChannel('posthog_flutter');

Expand Down Expand Up @@ -175,6 +181,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface {
Future<void> capture({
required String eventName,
Map<String, Object>? properties,
Map<String, Object>? groups,
}) async {
if (!isSupportedPlatform()) {
return;
Expand All @@ -184,9 +191,18 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface {
final normalizedProperties =
properties != null ? PropertyNormalizer.normalize(properties) : null;

// Convert groups to Map<String, String> for native SDK compatibility
Map<String, String>? normalizedGroups;
if (groups != null && groups.isNotEmpty) {
normalizedGroups = groups.map(
(key, value) => MapEntry(key, value.toString()),
);
}
Comment on lines +194 to +200
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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


await _methodChannel.invokeMethod('capture', {
'eventName': eventName,
if (normalizedProperties != null) 'properties': normalizedProperties,
if (normalizedGroups != null) 'groups': normalizedGroups,
});
} on PlatformException catch (exception) {
printIfDebug('Exeption on capture: $exception');
Expand Down
1 change: 1 addition & 0 deletions lib/src/posthog_flutter_platform_interface.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface {
Future<void> capture({
required String eventName,
Map<String, Object>? properties,
Map<String, Object>? groups,
}) {
throw UnimplementedError('capture() has not been implemented.');
}
Expand Down
59 changes: 18 additions & 41 deletions test/posthog_flutter_io_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:posthog_flutter/src/posthog_config.dart';
import 'package:posthog_flutter/src/posthog_flutter_io.dart';
import 'dart:io' show Platform;

// Simplified void callback for feature flags
void emptyCallback() {}
Expand Down Expand Up @@ -40,6 +41,8 @@ void main() {
});

group('PosthogFlutterIO onFeatureFlags via setup', () {
final bool isUnsupportedPlatform = Platform.isLinux || Platform.isWindows;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is not needed, the tests should run on any machine

runs-on: ubuntu-latest

those tests wont run on CI, so we should mock results if needed


test(
'setup initializes method call handler and registers callback if provided',
() async {
Expand All @@ -51,17 +54,12 @@ void main() {
testConfig = PostHogConfig('test_api_key', onFeatureFlags: testCallback);
await posthogFlutterIO.setup(testConfig);

// To verify handler is set, we trigger the callback from native side
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
channel.name,
channel.codec
.encodeMethodCall(const MethodCall('onFeatureFlagsCallback', {})),
(ByteData? data) {},
);
// Trigger the callback using the IO implementation's test hook.
await posthogFlutterIO.handleMethodCallForTest(
const MethodCall('onFeatureFlagsCallback', {}));
expect(callbackInvoked, isTrue);
expect(log.any((call) => call.method == 'setup'), isTrue);
});
}, skip: isUnsupportedPlatform);

test('invokes callback when native sends onFeatureFlagsCallback event',
() async {
Expand All @@ -76,17 +74,11 @@ void main() {

// Native sends empty map (iOS/Android behavior)
final mockNativeArgs = <String, dynamic>{};

await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(
MethodCall('onFeatureFlagsCallback', mockNativeArgs)),
(ByteData? data) {},
);
await posthogFlutterIO.handleMethodCallForTest(
MethodCall('onFeatureFlagsCallback', mockNativeArgs));

expect(callbackInvoked, isTrue);
});
}, skip: isUnsupportedPlatform);

test(
'invokes callback when native sends onFeatureFlagsCallback with empty map (mobile behavior)',
Expand All @@ -103,16 +95,11 @@ void main() {
// Simulate mobile sending an empty map
final mockNativeArgs = <String, dynamic>{};

await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(
MethodCall('onFeatureFlagsCallback', mockNativeArgs)),
(ByteData? data) {},
);
await posthogFlutterIO.handleMethodCallForTest(
MethodCall('onFeatureFlagsCallback', mockNativeArgs));

expect(callbackInvoked, isTrue);
});
}, skip: isUnsupportedPlatform);

test('invokes callback even with malformed native args', () async {
bool callbackInvoked = false;
Expand All @@ -129,30 +116,20 @@ void main() {
'flags': 123, // Invalid type, but callback is void so it doesn't matter
};

await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(
MethodCall('onFeatureFlagsCallback', mockNativeArgsMalformed)),
(ByteData? data) {},
);
await posthogFlutterIO.handleMethodCallForTest(
MethodCall('onFeatureFlagsCallback', mockNativeArgsMalformed));

expect(callbackInvoked, isTrue);
});
}, skip: isUnsupportedPlatform);

test('does not invoke callback when no callback is registered', () async {
// Setup without callback
testConfig = PostHogConfig('test_api_key');
await posthogFlutterIO.setup(testConfig);

// This should not throw - just silently do nothing
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
channel.name,
channel.codec
.encodeMethodCall(const MethodCall('onFeatureFlagsCallback', {})),
(ByteData? data) {},
);
await posthogFlutterIO.handleMethodCallForTest(
const MethodCall('onFeatureFlagsCallback', {}));

// If we get here without exception, the test passes
expect(true, isTrue);
Expand Down
27 changes: 27 additions & 0 deletions test/posthog_flutter_platform_interface_fake.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,24 @@ class CapturedExceptionCall {
});
}

/// Captured event call data
class CapturedEventCall {
final String eventName;
final Map<String, Object>? properties;
final Map<String, Object>? groups;

CapturedEventCall({
required this.eventName,
this.properties,
this.groups,
});
}

class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface {
String? screenName;
OnFeatureFlagsCallback? registeredOnFeatureFlagsCallback;
final List<CapturedExceptionCall> capturedExceptions = [];
final List<CapturedEventCall> capturedEvents = [];
PostHogConfig? receivedConfig;

@override
Expand Down Expand Up @@ -48,4 +62,17 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface {
properties: properties,
));
}

@override
Future<void> capture({
required String eventName,
Map<String, Object>? properties,
Map<String, Object>? groups,
}) async {
capturedEvents.add(CapturedEventCall(
eventName: eventName,
properties: properties,
groups: groups,
));
}
}
51 changes: 51 additions & 0 deletions test/posthog_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,56 @@ void main() {
expect(fakePlatformInterface.registeredOnFeatureFlagsCallback,
equals(testCallback));
});

test('capture supports event-level groups via \$groups', () async {
await Posthog().capture(
eventName: 'thing_happened',
groups: {
'company': 'c_123',
'project': 42,
},
);

expect(fakePlatformInterface.capturedEvents, hasLength(1));
final call = fakePlatformInterface.capturedEvents.single;
expect(call.eventName, equals('thing_happened'));

// groups are now passed as a separate parameter (not embedded in properties)
final groups = call.groups;
expect(groups, isNotNull);
expect(groups, equals({'company': 'c_123', 'project': 42}));
});

test('capture passes groups separately from properties', () async {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this test is the same as the above, the only difference is that you pass properties, i dont follow why we need it

await Posthog().capture(
eventName: 'merged_groups',
properties: {
'some_prop': 'value',
},
groups: {
'company': 'c_new',
'project': 'p_9',
},
);

final call = fakePlatformInterface.capturedEvents.single;
// properties should not contain $groups anymore
expect(call.properties?['some_prop'], equals('value'));
// groups passed separately
expect(call.groups, equals({'company': 'c_new', 'project': 'p_9'}));
});

test('capture adds \$screen_name when groups provided', () async {
await Posthog().screen(screenName: 'Checkout');

await Posthog().capture(
eventName: 'purchase_clicked',
groups: {'project': 'p1'},
);

final call = fakePlatformInterface.capturedEvents.last;
expect(call.properties?['\$screen_name'], equals('Checkout'));
expect(call.groups, equals({'project': 'p1'}));
});
});
}