Skip to content
Merged
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/survey-wait-for-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog_flutter": patch
---

Show a survey that arrives before `PosthogObserver` has a context once the next navigation happens, instead of dropping it and blocking later surveys.
2 changes: 2 additions & 0 deletions posthog_flutter/lib/src/posthog_observer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';

import 'posthog.dart';
import 'surveys/survey_service.dart';

/// Extracts a PostHog screen name from Flutter [RouteSettings].
///
Expand Down Expand Up @@ -113,6 +114,7 @@ class PosthogObserver extends RouteObserver<ModalRoute<dynamic>>
// don't clear current context if it's null
if (context != null) {
_currentContext = context;
SurveyService().onContextAvailable();
}
}

Expand Down
51 changes: 39 additions & 12 deletions posthog_flutter/lib/src/surveys/survey_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class SurveyService {
Route<dynamic>? _currentSurveyRoute;
PostHogDisplaySurvey? _currentSurvey;
Completer<void>? _programmaticDismissal;
Completer<bool>? _contextWait;

/// Shows a survey using the PosthogObserver context
Future<void> showSurvey(
Expand All @@ -39,24 +40,49 @@ class SurveyService {
return;
}

// Use the PosthogObserver's context to show the survey
if (PosthogObserver.currentContext != null) {
printIfDebug('[PostHog] Using PosthogObserver context for survey');
return _showSurveyWithNavigator(
survey,
onShown,
onResponse,
onClosed,
PosthogObserver.currentContext!,
var context = PosthogObserver.currentContext;
if (context == null) {
printIfDebug(
'[PostHog] No valid context to show the survey yet, it will be shown on the next navigation. If it never shows, make sure that you have installed PosthogObserver correctly in your app.',
);
// The native SDK keeps this survey active until it is closed, and closing
// it would record a dismissal for a survey the user never saw.
do {
if (!await _waitForContext() || _isShowingSurvey) return;
context = PosthogObserver.currentContext;
} while (context == null);
}
if (!context.mounted) return;

// If we can't show the survey, log an error
printIfDebug(
'[PostHog] Cannot show survey: No valid context found. To fix this make sure that you have installed PosthogObserver correctly in your app.',
printIfDebug('[PostHog] Using PosthogObserver context for survey');
return _showSurveyWithNavigator(
survey,
onShown,
onResponse,
onClosed,
context,
);
}

/// Completes with true once [PosthogObserver] reports a context, or false
/// when the wait was cancelled by [hideSurvey] or replaced by a newer survey.
Future<bool> _waitForContext() {
_endContextWait(show: false);
final contextWait = Completer<bool>();
_contextWait = contextWait;
return contextWait.future;
}

void _endContextWait({required bool show}) {
final contextWait = _contextWait;
_contextWait = null;
contextWait?.complete(show);
}

/// Called by [PosthogObserver] when it has a context, so a survey that
/// arrived before then can be shown.
void onContextAvailable() => _endContextWait(show: true);

/// Shows a survey using a navigator context
Future<void> _showSurveyWithNavigator(
PostHogDisplaySurvey survey,
Expand Down Expand Up @@ -150,6 +176,7 @@ class SurveyService {

/// Hides any active survey
void hideSurvey({PostHogDisplaySurvey? survey}) {
if (survey == null) _endContextWait(show: false);
if (survey != null && !identical(survey, _currentSurvey)) return;
if (!_isShowingSurvey || _isDismissingSurvey) {
return;
Expand Down
135 changes: 135 additions & 0 deletions posthog_flutter/test/survey_wait_for_context_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:posthog_flutter/src/posthog_observer.dart';
import 'package:posthog_flutter/src/surveys/models/posthog_display_survey.dart';
import 'package:posthog_flutter/src/surveys/survey_service.dart';
import 'package:posthog_flutter/src/surveys/widgets/survey_bottom_sheet.dart';

void main() {
PostHogDisplaySurvey survey(String id) {
return PostHogDisplaySurvey.fromDict({
'id': id,
'name': 'Test survey',
'questions': [
{'type': 'link', 'question': id, 'isOptional': false, 'link': ''},
],
});
}

Future<void> pumpAppWithObserver(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
navigatorObservers: [PosthogObserver()],
home: const Scaffold(body: Text('Home')),
),
);
await tester.pumpAndSettle();
}

tearDown(() {
SurveyService().hideSurvey();
PosthogObserver.clearCurrentContext();
});

testWidgets('shows a survey that arrived before the observer had a context', (
tester,
) async {
final shown = <String>[];
final closed = <String>[];

final showSurvey = SurveyService().showSurvey(
survey('early'),
(survey) => shown.add(survey.id),
(_, __, ___) async => null,
(survey) => closed.add(survey.id),
);
await tester.pump();
expect(closed, isEmpty);

await pumpAppWithObserver(tester);

expect(find.byType(SurveyBottomSheet), findsOneWidget);
expect(shown, ['early']);
expect(closed, isEmpty);

SurveyService().hideSurvey();
await tester.pumpAndSettle();
await showSurvey;
});

testWidgets(
'shows a waiting survey on the next navigation after the context was cleared',
(
tester,
) async {
await pumpAppWithObserver(tester);
PosthogObserver.clearCurrentContext();

final showSurvey = SurveyService().showSurvey(
survey('after-clear'),
(_) {},
(_, __, ___) async => null,
(_) {},
);
await tester.pumpAndSettle();
expect(find.byType(SurveyBottomSheet), findsNothing);

tester.state<NavigatorState>(find.byType(Navigator)).push(
MaterialPageRoute<void>(
builder: (_) => const Scaffold(body: Text('Next')),
),
);
await tester.pumpAndSettle();

expect(find.byType(SurveyBottomSheet), findsOneWidget);

SurveyService().hideSurvey();
await tester.pumpAndSettle();
await showSurvey;
});

testWidgets('does not show a waiting survey that was hidden', (tester) async {
final closed = <String>[];

final showSurvey = SurveyService().showSurvey(
survey('hidden'),
(_) {},
(_, __, ___) async => null,
(survey) => closed.add(survey.id),
);
SurveyService().hideSurvey();
await showSurvey;

await pumpAppWithObserver(tester);

expect(find.byType(SurveyBottomSheet), findsNothing);
expect(closed, isEmpty);
});

testWidgets('a newer survey replaces one waiting for a context', (
tester,
) async {
final first = SurveyService().showSurvey(
survey('first'),
(_) {},
(_, __, ___) async => null,
(_) {},
);
final second = SurveyService().showSurvey(
survey('second'),
(_) {},
(_, __, ___) async => null,
(_) {},
);
await first;

await pumpAppWithObserver(tester);

expect(find.text('second'), findsOneWidget);
expect(find.text('first'), findsNothing);

SurveyService().hideSurvey();
await tester.pumpAndSettle();
await second;
});
}
Loading