Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/display-survey-manual-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog_flutter": minor
---

Add `Posthog().displaySurvey(surveyId)` to display a survey on demand, bypassing display conditions (targeting flags, event triggers, and seen/wait-period checks). This is the counterpart of the web SDK's `posthog.displaySurvey()` and also enables API-type surveys, which are never auto-displayed. On Android and iOS the survey is displayed by the native SDK (requires posthog-android >= 3.56.0 / posthog-ios >= 3.67.0); on Web it is displayed by the JS SDK as a popover.
66 changes: 66 additions & 0 deletions api/posthog_flutter.api.json
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,28 @@
"type": "method",
"typeParameterNames": []
},
{
"entryPoints": [],
"isDeprecated": false,
"isExperimental": false,
"isStatic": false,
"name": "displaySurvey",
"parameters": [
{
"isDeprecated": false,
"isExperimental": false,
"isNamed": false,
"isRequired": true,
"name": "surveyId",
"relativePath": "lib/src/posthog_flutter_platform_interface.dart",
"typeName": "String"
}
],
"relativePath": "lib/src/posthog_flutter_platform_interface.dart",
"returnTypeName": "Future<void>",
"type": "method",
"typeParameterNames": []
},
{
"entryPoints": [],
"isDeprecated": false,
Expand Down Expand Up @@ -1595,6 +1617,28 @@
"type": "method",
"typeParameterNames": []
},
{
"entryPoints": [],
"isDeprecated": false,
"isExperimental": false,
"isStatic": false,
"name": "displaySurvey",
"parameters": [
{
"isDeprecated": false,
"isExperimental": false,
"isNamed": false,
"isRequired": true,
"name": "surveyId",
"relativePath": "lib/src/posthog_flutter_platform_interface.dart",
"typeName": "String"
}
],
"relativePath": "lib/src/posthog_flutter_platform_interface.dart",
"returnTypeName": "Future<void>",
"type": "method",
"typeParameterNames": []
},
{
"entryPoints": [],
"isDeprecated": false,
Expand Down Expand Up @@ -4369,6 +4413,28 @@
"type": "method",
"typeParameterNames": []
},
{
"entryPoints": [],
"isDeprecated": false,
"isExperimental": false,
"isStatic": false,
"name": "displaySurvey",
"parameters": [
{
"isDeprecated": false,
"isExperimental": false,
"isNamed": false,
"isRequired": true,
"name": "surveyId",
"relativePath": "lib/src/posthog.dart",
"typeName": "String"
}
],
"relativePath": "lib/src/posthog.dart",
"returnTypeName": "Future<void>",
"type": "method",
"typeParameterNames": []
},
{
"entryPoints": [],
"isDeprecated": false,
Expand Down
2 changes: 1 addition & 1 deletion posthog_flutter/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ android {
dependencies {
testImplementation 'org.jetbrains.kotlin:kotlin-test'
testImplementation 'org.mockito:mockito-core:5.0.0'
implementation 'com.posthog:posthog-android:[3.55.0,4.0.0)'
implementation 'com.posthog:posthog-android:[3.56.0,4.0.0)'
}

testOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,10 @@ class PosthogFlutterPlugin :
handleSurveyAction(call, result)
}

"displaySurvey" -> {
displaySurvey(call, result)
}

else -> {
result.notImplemented()
}
Expand Down Expand Up @@ -1813,6 +1817,19 @@ class PosthogFlutterPlugin :
}
}

private fun displaySurvey(
call: MethodCall,
result: Result,
) {
try {
val surveyId: String = call.argument("surveyId")!!
PostHog.displaySurvey(surveyId)
result.success(null)
} catch (e: Throwable) {
result.error("PosthogFlutterException", e.localizedMessage, null)
}
}

// MARK: - Survey Action Handling

private fun handleSurveyAction(
Expand Down
4 changes: 2 additions & 2 deletions posthog_flutter/darwin/posthog_flutter.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ Postog flutter plugin
s.ios.dependency 'Flutter'
s.osx.dependency 'FlutterMacOS'

# ~> Version 3.66.0 up to, but not including, 4.0.0
s.dependency 'PostHog', '>= 3.66.0', '< 4.0.0'
# ~> Version 3.67.0 up to, but not including, 4.0.0
s.dependency 'PostHog', '>= 3.67.0', '< 4.0.0'

s.ios.deployment_target = '13.0'
# PH iOS SDK 3.0.0 requires >= 10.15
Expand Down
2 changes: 1 addition & 1 deletion posthog_flutter/darwin/posthog_flutter/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let package = Package(
],
dependencies: [
.package(name: "FlutterFramework", path: "../FlutterFramework"),
.package(url: "https://github.com/PostHog/posthog-ios", "3.66.0" ..< "4.0.0"),
.package(url: "https://github.com/PostHog/posthog-ios", "3.67.0" ..< "4.0.0"),
],
targets: [
.target(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,13 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin {
// surveys only supported on iOS
result(nil)
#endif
case "displaySurvey":
#if os(iOS)
displaySurvey(call, result: result)
#else
// surveys only supported on iOS
result(nil)
#endif
default:
result(FlutterMethodNotImplemented)
}
Expand Down Expand Up @@ -461,6 +468,18 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin {
invokeFlutterMethod("hideSurveys", arguments: nil)
}

private func displaySurvey(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let surveyId = args["surveyId"] as? String
else {
result(FlutterError(code: "InvalidArguments", message: "Missing surveyId", details: nil))
return
}

PostHogSDK.shared.displaySurvey(surveyId)
result(nil)
}

private func handleSurveyAction(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let survey = currentSurvey,
let args = call.arguments as? [String: Any],
Expand Down
7 changes: 7 additions & 0 deletions posthog_flutter/lib/posthog_flutter_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,13 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface {
// Not supported on web - surveys handled by posthog-js
}

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

@override
Future<void> captureException({
required Object error,
Expand Down
18 changes: 18 additions & 0 deletions posthog_flutter/lib/src/posthog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -765,5 +765,23 @@ class Posthog {
/// current platform.
Future<bool> isSessionReplayActive() => _posthog.isSessionReplayActive();

/// Displays the survey with the given ID on demand, regardless of its
/// display conditions.
///
/// The survey must be running and part of your project's surveys. Display
/// conditions (targeting flags, event triggers, and the seen/wait-period
/// checks) are bypassed, so this also works for API-type surveys, which are
/// never displayed automatically. If another survey is already being
/// displayed, this call is ignored.
///
/// Surveys must be enabled via `PostHogConfig.surveys` (mobile) or in your
/// web snippet configuration. On Android and iOS the survey is rendered by
/// the native SDK through the regular Flutter survey UI; on Web it is
/// rendered by the JS SDK as a popover.
///
/// Returns a [Future] that completes when the display request has been sent.
Future<void> displaySurvey(String surveyId) =>
_posthog.displaySurvey(surveyId);

Posthog._internal();
}
15 changes: 15 additions & 0 deletions posthog_flutter/lib/src/posthog_flutter_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,21 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface {
}
}

@override
Future<void> displaySurvey(String surveyId) async {
if (!isSupportedPlatform()) {
return;
}

try {
await _methodChannel.invokeMethod('displaySurvey', {
'surveyId': surveyId,
});
} on PlatformException catch (exception) {
printIfDebug('Exception on displaySurvey: $exception');
}
}

@override
Future<bool> isFeatureEnabled(String key) async {
if (!isSupportedPlatform()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface {
throw UnimplementedError('showSurvey() has not been implemented.');
}

Future<void> displaySurvey(String surveyId) {
throw UnimplementedError('displaySurvey() has not been implemented.');
}

Future<void> group({
required String groupType,
required String groupKey,
Expand Down
13 changes: 13 additions & 0 deletions posthog_flutter/lib/src/posthog_flutter_web_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ extension PostHogExtension on PostHog {
external void startSessionRecording();
external void stopSessionRecording();
external bool sessionRecordingStarted();
// May be absent on older posthog-js builds; the call site guards with try/catch.
external void displaySurvey(JSAny surveyId, JSAny options);
external SessionManager? get sessionManager;
// ignore: non_constant_identifier_names
external void _overrideSDKInfo(JSAny sdkName, JSAny sdkVersion);
Expand Down Expand Up @@ -420,6 +422,17 @@ Future<dynamic> handleWebMethodCall(MethodCall call) async {
case 'surveyAction':
// not supported on Web
break;
case 'displaySurvey':
final surveyId = args['surveyId'] as String;
try {
posthog?.displaySurvey(
stringToJSAny(surveyId),
mapToJSAny({'displayType': 'popover'}),
);
} catch (e) {
printIfDebug('Exception on displaySurvey: $e');
}
break;
Comment on lines +425 to +435

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

displaySurvey is completely non-functional on Flutter Web: PosthogFlutterWeb never forwards to the new handler case

must_fix bug

Why we think it's a valid issue
  • Checked: Traced the Web call path end-to-end β€” posthog.dart:783-784 (displaySurvey β†’ _posthog.displaySurvey), the platform-instance wiring in posthog_flutter_web.dart:40 (registerWith sets instance = PosthogFlutterWeb()), the full posthog_flutter_web.dart (lines 1-431), the abstract base in posthog_flutter_platform_interface.dart, the IO override, and the test files.
  • Found: PosthogFlutterWeb overrides every supported method to route through handleWebMethodCall (e.g. register at web.dart:237, capture at 122) and even explicitly no-ops openUrl/showSurvey (web.dart:358-365), but has NO displaySurvey override β€” grep for displaySurvey returns zero hits in posthog_flutter_web.dart. So on Web the call falls through to the abstract default Future<void> displaySurvey(String surveyId) { throw UnimplementedError(...); } at platform_interface.dart:168-170. By contrast PosthogFlutterIO.displaySurvey (io.dart:515) does override and forward over the method channel.
  • Found: The switch case at web_handler.dart:425-435 and the extern binding at 69-70 are only reachable via a handleWebMethodCall(MethodCall('displaySurvey', ...)), which nothing invokes given the missing override β€” dead code on Web.
  • Found: Tests confirm the gap: posthog_flutter_io_test.dart:286 exercises only the method-channel/IO path, and posthog_test.dart:693 drives a hand-written fake (posthog_flutter_platform_interface_fake.dart:194) β€” neither instantiates PosthogFlutterWeb nor calls handleWebMethodCall with 'displaySurvey'.
  • Impact: await Posthog().displaySurvey('id') on Flutter Web throws UnimplementedError instead of forwarding to posthog.displaySurvey(surveyId, {displayType: 'popover'}), so the Web behavior the PR adds and documents (doc-comment at posthog.dart:779-780: 'on Web it is rendered by the JS SDK as a popover') is entirely non-functional. This is a real, directly-introduced correctness/contract defect with a named trigger and consequence, and the fix (add the override mirroring the other methods + a Web test) is concrete and actionable.
Issue description

The new case 'displaySurvey': branch added to handleWebMethodCall (lines 425-435) correctly builds and issues the JS SDK call posthog.displaySurvey(surveyId, {displayType: 'popover'}), but this code path is unreachable in practice. On Flutter Web, PosthogFlutterWeb.registerWith sets PosthogFlutterPlatformInterface.instance = instance (lib/posthog_flutter_web.dart:40), and Posthog()._posthog (posthog.dart:22-23) calls methods directly on that instance rather than through the method channel. Every other platform-interface method that PosthogFlutterWeb supports (setup, identify, capture, register, addExceptionStep, etc.) is explicitly overridden in posthog_flutter_web.dart to call handleWebMethodCall(MethodCall(...)) β€” but displaySurvey is not overridden there at all (confirmed by reading the full, current lib/posthog_flutter_web.dart, which has no displaySurvey override). Because PosthogFlutterPlatformInterface is abstract and PosthogFlutterWeb extends it directly, calling Posthog().displaySurvey('survey-123') on Web resolves to the base class's default in posthog_flutter_platform_interface.dart:168-171: Future<void> displaySurvey(String surveyId) { throw UnimplementedError('displaySurvey() has not been implemented.'); }. That method is not async, so the UnimplementedError is thrown synchronously the moment the call is made, before the added JS-forwarding logic in this file ever runs. In other words, the entire Web implementation added by this PR (the extern JS binding at lines 69-70 and the switch case at 425-435) is dead code, and the feature the PR title/changeset/doc-comments advertise for Web ('on Web it is displayed by the JS SDK as a popover') is completely broken. This gap is not caught by the PR's new tests: posthog_flutter_io_test.dart only exercises the IO/method-channel path (mobile), and posthog_test.dart only exercises a hand-written fake PosthogFlutterPlatformInterface β€” neither test instantiates PosthogFlutterWeb or invokes handleWebMethodCall with 'displaySurvey', so nothing exercises the actual Web code path added here.

Suggested fix

Add the missing override in lib/posthog_flutter_web.dart (mirroring the pattern used for every other supported method, e.g. unregister/register) so calls are actually forwarded to this handler:

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

Without this override, the switch case added in posthog_flutter_web_handler.dart can never execute. After adding the override, add a test that exercises PosthogFlutterWeb().displaySurvey(...) (or at least calls handleWebMethodCall with a 'displaySurvey' MethodCall directly) so this regression is caught in CI, consistent with how the mobile path is tested in posthog_flutter_io_test.dart.

Prompt to fix with AI (copy-paste)
## Context
@posthog_flutter/lib/src/posthog_flutter_web_handler.dart#L425-435

<issue_description>
The new `case 'displaySurvey':` branch added to `handleWebMethodCall` (lines 425-435) correctly builds and issues the JS SDK call `posthog.displaySurvey(surveyId, {displayType: 'popover'})`, but this code path is unreachable in practice. On Flutter Web, `PosthogFlutterWeb.registerWith` sets `PosthogFlutterPlatformInterface.instance = instance` (lib/posthog_flutter_web.dart:40), and `Posthog()._posthog` (posthog.dart:22-23) calls methods directly on that instance rather than through the method channel. Every other platform-interface method that `PosthogFlutterWeb` supports (setup, identify, capture, register, addExceptionStep, etc.) is explicitly overridden in `posthog_flutter_web.dart` to call `handleWebMethodCall(MethodCall(...))` β€” but `displaySurvey` is not overridden there at all (confirmed by reading the full, current `lib/posthog_flutter_web.dart`, which has no `displaySurvey` override). Because `PosthogFlutterPlatformInterface` is abstract and `PosthogFlutterWeb extends` it directly, calling `Posthog().displaySurvey('survey-123')` on Web resolves to the base class's default in `posthog_flutter_platform_interface.dart:168-171`: `Future<void> displaySurvey(String surveyId) { throw UnimplementedError('displaySurvey() has not been implemented.'); }`. That method is not `async`, so the `UnimplementedError` is thrown synchronously the moment the call is made, before the added JS-forwarding logic in this file ever runs. In other words, the entire Web implementation added by this PR (the extern JS binding at lines 69-70 and the switch case at 425-435) is dead code, and the feature the PR title/changeset/doc-comments advertise for Web ('on Web it is displayed by the JS SDK as a popover') is completely broken. This gap is not caught by the PR's new tests: `posthog_flutter_io_test.dart` only exercises the IO/method-channel path (mobile), and `posthog_test.dart` only exercises a hand-written fake `PosthogFlutterPlatformInterface` β€” neither test instantiates `PosthogFlutterWeb` or invokes `handleWebMethodCall` with `'displaySurvey'`, so nothing exercises the actual Web code path added here.
</issue_description>

<issue_validation>
- **Checked:** Traced the Web call path end-to-end β€” `posthog.dart:783-784` (`displaySurvey` β†’ `_posthog.displaySurvey`), the platform-instance wiring in `posthog_flutter_web.dart:40` (`registerWith` sets `instance = PosthogFlutterWeb()`), the full `posthog_flutter_web.dart` (lines 1-431), the abstract base in `posthog_flutter_platform_interface.dart`, the IO override, and the test files.
- **Found:** `PosthogFlutterWeb` overrides every supported method to route through `handleWebMethodCall` (e.g. `register` at web.dart:237, `capture` at 122) and even explicitly no-ops `openUrl`/`showSurvey` (web.dart:358-365), but has NO `displaySurvey` override β€” `grep` for `displaySurvey` returns zero hits in `posthog_flutter_web.dart`. So on Web the call falls through to the abstract default `Future<void> displaySurvey(String surveyId) { throw UnimplementedError(...); }` at platform_interface.dart:168-170. By contrast `PosthogFlutterIO.displaySurvey` (io.dart:515) does override and forward over the method channel.
- **Found:** The switch case at web_handler.dart:425-435 and the extern binding at 69-70 are only reachable via a `handleWebMethodCall(MethodCall('displaySurvey', ...))`, which nothing invokes given the missing override β€” dead code on Web.
- **Found:** Tests confirm the gap: `posthog_flutter_io_test.dart:286` exercises only the method-channel/IO path, and `posthog_test.dart:693` drives a hand-written fake (`posthog_flutter_platform_interface_fake.dart:194`) β€” neither instantiates `PosthogFlutterWeb` nor calls `handleWebMethodCall` with `'displaySurvey'`.
- **Impact:** `await Posthog().displaySurvey('id')` on Flutter Web throws `UnimplementedError` instead of forwarding to `posthog.displaySurvey(surveyId, {displayType: 'popover'})`, so the Web behavior the PR adds and documents (doc-comment at posthog.dart:779-780: 'on Web it is rendered by the JS SDK as a popover') is entirely non-functional. This is a real, directly-introduced correctness/contract defect with a named trigger and consequence, and the fix (add the override mirroring the other methods + a Web test) is concrete and actionable.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Add the missing override in `lib/posthog_flutter_web.dart` (mirroring the pattern used for every other supported method, e.g. `unregister`/`register`) so calls are actually forwarded to this handler:

```dart
@override
Future<void> displaySurvey(String surveyId) async {
  return handleWebMethodCall(
    MethodCall('displaySurvey', {'surveyId': surveyId}),
  );
}

Without this override, the switch case added in posthog_flutter_web_handler.dart can never execute. After adding the override, add a test that exercises PosthogFlutterWeb().displaySurvey(...) (or at least calls handleWebMethodCall with a 'displaySurvey' MethodCall directly) so this regression is caught in CI, consistent with how the mobile path is tested in posthog_flutter_io_test.dart.
</potential_solution>


</details>

case 'captureException':
final properties = safeMapConversion(args['properties']);
properties.addAll(_getLocationProperties());
Expand Down
8 changes: 8 additions & 0 deletions posthog_flutter/test/posthog_flutter_io_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,14 @@ void main() {
]);
});

test('displaySurvey sends the surveyId over the method channel', () async {
await posthogFlutterIO.displaySurvey('survey-123');

final call = log.firstWhere((c) => c.method == 'displaySurvey');
final args = Map<String, dynamic>.from(call.arguments as Map);
expect(args, {'surveyId': 'survey-123'});
});

test('setGroupPropertiesForFlags sends groupType and properties', () async {
await posthogFlutterIO.setGroupPropertiesForFlags(
'organization',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface {
});
}

// Call tracking for displaySurvey
final List<String> displaySurveyCalls = [];

@override
Future<void> displaySurvey(String surveyId) async {
displaySurveyCalls.add(surveyId);
}

@override
Future<Object?> getFeatureFlag({required String key}) async {
return featureFlagValues[key];
Expand Down
31 changes: 31 additions & 0 deletions posthog_flutter/test/posthog_flutter_web_handler_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,37 @@ void main() {
});
});

// Guards the web wiring: PosthogFlutterWeb must override displaySurvey so
// the call actually reaches posthog-js. Without the override it falls through
// to the platform interface and throws UnimplementedError on web.
group('PosthogFlutterWeb displaySurvey', () {
String? capturedSurveyId;
JSAny? capturedOptions;

setUp(() {
capturedSurveyId = null;
capturedOptions = null;

final fake = JSObject();
fake.setProperty(
'displaySurvey'.toJS,
((JSString surveyId, JSAny? options) {
capturedSurveyId = surveyId.toDart;
capturedOptions = options;
}).toJS,
);
globalContext.setProperty('posthog'.toJS, fake);
});

test('override forwards the survey ID and popover options to posthog-js',
() async {
await PosthogFlutterWeb().displaySurvey('survey-123');

expect(capturedSurveyId, 'survey-123');
expect(capturedOptions.dartify(), {'displayType': 'popover'});
});
});

// captureException must route through posthog-js's captureException (not the
// generic capture) so it attaches required metadata and any buffered
// $exception_steps. The Dart-built payload is passed as additionalProperties,
Expand Down
16 changes: 16 additions & 0 deletions posthog_flutter/test/posthog_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,22 @@ void main() {
});
});

group('Posthog displaySurvey', () {
late PosthogFlutterPlatformFake fake;

setUp(() async {
fake = PosthogFlutterPlatformFake();
PosthogFlutterPlatformInterface.instance = fake;
await Posthog().close();
});

test('forwards the survey ID to the platform interface', () async {
await Posthog().displaySurvey('survey-123');

expect(fake.displaySurveyCalls, ['survey-123']);
});
});

group('PostHogExceptionStepsConfig', () {
test('toMap serializes the native defaults', () {
final config = PostHogConfig('test_project_token');
Expand Down
Loading