diff --git a/.changeset/survey-wait-for-context.md b/.changeset/survey-wait-for-context.md new file mode 100644 index 00000000..0c270e9b --- /dev/null +++ b/.changeset/survey-wait-for-context.md @@ -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. diff --git a/posthog_flutter/lib/src/posthog_observer.dart b/posthog_flutter/lib/src/posthog_observer.dart index 071863a0..fbafdcf8 100644 --- a/posthog_flutter/lib/src/posthog_observer.dart +++ b/posthog_flutter/lib/src/posthog_observer.dart @@ -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]. /// @@ -113,6 +114,7 @@ class PosthogObserver extends RouteObserver> // don't clear current context if it's null if (context != null) { _currentContext = context; + SurveyService().onContextAvailable(); } } diff --git a/posthog_flutter/lib/src/surveys/survey_service.dart b/posthog_flutter/lib/src/surveys/survey_service.dart index 56476cd9..2ea8b32a 100644 --- a/posthog_flutter/lib/src/surveys/survey_service.dart +++ b/posthog_flutter/lib/src/surveys/survey_service.dart @@ -26,6 +26,7 @@ class SurveyService { Route? _currentSurveyRoute; PostHogDisplaySurvey? _currentSurvey; Completer? _programmaticDismissal; + Completer? _contextWait; /// Shows a survey using the PosthogObserver context Future showSurvey( @@ -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 _waitForContext() { + _endContextWait(show: false); + final contextWait = Completer(); + _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 _showSurveyWithNavigator( PostHogDisplaySurvey survey, @@ -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; diff --git a/posthog_flutter/test/survey_wait_for_context_test.dart b/posthog_flutter/test/survey_wait_for_context_test.dart new file mode 100644 index 00000000..68837bdb --- /dev/null +++ b/posthog_flutter/test/survey_wait_for_context_test.dart @@ -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 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 = []; + final closed = []; + + 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(find.byType(Navigator)).push( + MaterialPageRoute( + 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 = []; + + 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; + }); +}