diff --git a/.changeset/survey-popup-delay.md b/.changeset/survey-popup-delay.md new file mode 100644 index 00000000..5b2af3a7 --- /dev/null +++ b/.changeset/survey-popup-delay.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +Wait `surveyPopupDelaySeconds` before showing a survey. A missing, zero, or negative delay still shows it immediately. diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt index e158e0bf..0d2b7a33 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt @@ -79,6 +79,7 @@ fun PostHogDisplaySurvey.toMap(): Map { "inputBackground" to app.inputBackground, "inputTextColor" to app.inputTextColor, "placeholder" to app.placeholder, + "surveyPopupDelaySeconds" to app.surveyPopupDelaySeconds, "displayThankYouMessage" to app.displayThankYouMessage, "thankYouMessageHeader" to app.thankYouMessageHeader, "thankYouMessageDescription" to app.thankYouMessageDescription, diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PostHogDisplaySurvey+Dict.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PostHogDisplaySurvey+Dict.swift index a9bd99d6..f9005fcd 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PostHogDisplaySurvey+Dict.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PostHogDisplaySurvey+Dict.swift @@ -93,6 +93,9 @@ if let placeholder = appearance.placeholder { appearanceDict["placeholder"] = placeholder } + if let surveyPopupDelaySeconds = appearance.surveyPopupDelaySeconds { + appearanceDict["surveyPopupDelaySeconds"] = surveyPopupDelaySeconds + } appearanceDict["displayThankYouMessage"] = appearance.displayThankYouMessage if let thankYouMessageHeader = appearance.thankYouMessageHeader { appearanceDict["thankYouMessageHeader"] = thankYouMessageHeader diff --git a/posthog_flutter/lib/src/surveys/models/posthog_display_survey.dart b/posthog_flutter/lib/src/surveys/models/posthog_display_survey.dart index ce4affeb..4237154e 100644 --- a/posthog_flutter/lib/src/surveys/models/posthog_display_survey.dart +++ b/posthog_flutter/lib/src/surveys/models/posthog_display_survey.dart @@ -115,6 +115,9 @@ class PostHogDisplaySurvey { inputBackground: a['inputBackground'] as String?, inputTextColor: a['inputTextColor'] as String?, placeholder: a['placeholder'] as String?, + surveyPopupDelaySeconds: a['surveyPopupDelaySeconds'] is num + ? (a['surveyPopupDelaySeconds'] as num).toDouble() + : null, displayThankYouMessage: a['displayThankYouMessage'] as bool? ?? true, thankYouMessageHeader: a['thankYouMessageHeader'] as String?, thankYouMessageDescription: a['thankYouMessageDescription'] as String?, diff --git a/posthog_flutter/lib/src/surveys/models/posthog_display_survey_appearance.dart b/posthog_flutter/lib/src/surveys/models/posthog_display_survey_appearance.dart index 8cf2d565..32b98b51 100644 --- a/posthog_flutter/lib/src/surveys/models/posthog_display_survey_appearance.dart +++ b/posthog_flutter/lib/src/surveys/models/posthog_display_survey_appearance.dart @@ -18,6 +18,7 @@ class PostHogDisplaySurveyAppearance { this.inputBackground, this.inputTextColor, this.placeholder, + this.surveyPopupDelaySeconds, this.displayThankYouMessage = true, this.thankYouMessageHeader, this.thankYouMessageDescription, @@ -43,6 +44,11 @@ class PostHogDisplaySurveyAppearance { final String? inputBackground; final String? inputTextColor; final String? placeholder; + + /// Seconds to wait after the survey is triggered before it is shown. + /// + /// Null or a non-positive value shows the survey immediately. + final double? surveyPopupDelaySeconds; final bool displayThankYouMessage; final String? thankYouMessageHeader; final String? thankYouMessageDescription; diff --git a/posthog_flutter/lib/src/surveys/survey_service.dart b/posthog_flutter/lib/src/surveys/survey_service.dart index 2ea8b32a..fc2be385 100644 --- a/posthog_flutter/lib/src/surveys/survey_service.dart +++ b/posthog_flutter/lib/src/surveys/survey_service.dart @@ -9,6 +9,20 @@ import 'models/survey_callbacks.dart'; import 'models/survey_appearance.dart'; import 'widgets/survey_bottom_sheet.dart'; +/// How long to wait before showing a survey. +/// +/// [seconds] comes from the survey's `surveyPopupDelaySeconds`. Null, zero, +/// and negative values show the survey immediately, matching iOS and Android. +/// Values above one day are capped at one day. +Duration surveyPopupDelay(double? seconds) { + if (seconds == null || seconds.isNaN || seconds <= 0) { + return Duration.zero; + } + const maxDelay = Duration(days: 1); + if (seconds >= maxDelay.inSeconds) return maxDelay; + return Duration(milliseconds: (seconds * 1000).round()); +} + /// A service that manages displaying surveys /// /// This service uses the PosthogObserver to access the current navigation context @@ -26,7 +40,9 @@ class SurveyService { Route? _currentSurveyRoute; PostHogDisplaySurvey? _currentSurvey; Completer? _programmaticDismissal; - Completer? _contextWait; + Completer? _pendingShow; + Timer? _popupDelayTimer; + PostHogDisplaySurvey? _delayedSurvey; /// Shows a survey using the PosthogObserver context Future showSurvey( @@ -40,6 +56,13 @@ class SurveyService { return; } + _cancelPopupDelay(); + + final delay = surveyPopupDelay(survey.appearance?.surveyPopupDelaySeconds); + if (delay > Duration.zero && !await _waitForPopupDelay(survey, delay)) { + return; + } + var context = PosthogObserver.currentContext; if (context == null) { printIfDebug( @@ -64,24 +87,43 @@ class SurveyService { ); } + /// Completes with true once the delay has elapsed, or false when the wait + /// was cancelled by [hideSurvey] or replaced by a newer survey. + Future _waitForPopupDelay( + PostHogDisplaySurvey survey, + Duration delay, + ) { + final wait = _startPendingShow(); + _delayedSurvey = survey; + _popupDelayTimer = Timer(delay, () { + _clearPopupDelay(); + _endPendingShow(show: true); + }); + return wait; + } + /// 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; + Future _waitForContext() => _startPendingShow(); + + Future _startPendingShow() { + _endPendingShow(show: false); + final pendingShow = Completer(); + _pendingShow = pendingShow; + return pendingShow.future; } - void _endContextWait({required bool show}) { - final contextWait = _contextWait; - _contextWait = null; - contextWait?.complete(show); + void _endPendingShow({required bool show}) { + final pendingShow = _pendingShow; + _pendingShow = null; + pendingShow?.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); + void onContextAvailable() { + if (_popupDelayTimer == null) _endPendingShow(show: true); + } /// Shows a survey using a navigator context Future _showSurveyWithNavigator( @@ -174,9 +216,27 @@ class SurveyService { route.navigator?.removeRoute(route); } + /// Drops a survey still waiting out its delay without calling onClosed: + /// the user never saw it, so it must not be marked seen or dismissed. + void _cancelPopupDelay() { + if (_delayedSurvey == null) return; + + _popupDelayTimer?.cancel(); + _clearPopupDelay(); + _endPendingShow(show: false); + } + + void _clearPopupDelay() { + _popupDelayTimer = null; + _delayedSurvey = null; + } + /// Hides any active survey void hideSurvey({PostHogDisplaySurvey? survey}) { - if (survey == null) _endContextWait(show: false); + if (survey == null || identical(survey, _delayedSurvey)) { + _cancelPopupDelay(); + } + if (survey == null) _endPendingShow(show: false); if (survey != null && !identical(survey, _currentSurvey)) return; if (!_isShowingSurvey || _isDismissingSurvey) { return; diff --git a/posthog_flutter/test/survey_popup_delay_test.dart b/posthog_flutter/test/survey_popup_delay_test.dart new file mode 100644 index 00000000..f762d4bb --- /dev/null +++ b/posthog_flutter/test/survey_popup_delay_test.dart @@ -0,0 +1,244 @@ +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() { + Map survey({Object? delaySeconds}) { + return { + 'id': 'survey-1', + 'name': 'Test survey', + 'questions': [ + { + 'type': 'link', + 'question': 'Welcome', + 'isOptional': false, + 'link': '', + }, + ], + if (delaySeconds != null) + 'appearance': {'surveyPopupDelaySeconds': delaySeconds}, + }; + } + + Future pumpApp(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Navigator( + observers: [PosthogObserver()], + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Home')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + void pushNextRoute(WidgetTester tester) { + tester.state(find.byType(Navigator).last).push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Next')), + ), + ); + } + + tearDown(() async { + SurveyService().hideSurvey(); + PosthogObserver.clearCurrentContext(); + }); + + testWidgets( + 'shows a survey immediately when the popup delay is unset or not positive', + ( + tester, + ) async { + await pumpApp(tester); + + for (final delay in [null, 0, -1]) { + final shown = SurveyService().showSurvey( + PostHogDisplaySurvey.fromDict(survey(delaySeconds: delay)), + (_) {}, + (_, __, ___) async => null, + (_) {}, + ); + await tester.pump(); + + expect(find.byType(SurveyBottomSheet), findsOneWidget); + SurveyService().hideSurvey(); + await tester.pumpAndSettle(); + await shown; + } + }); + + testWidgets('waits surveyPopupDelaySeconds before showing the survey', ( + tester, + ) async { + await pumpApp(tester); + final closed = []; + + final shown = SurveyService().showSurvey( + PostHogDisplaySurvey.fromDict(survey(delaySeconds: 2)), + (_) {}, + (_, __, ___) async => null, + (survey) => closed.add(survey.id), + ); + await tester.pump(); + + expect(find.byType(SurveyBottomSheet), findsNothing); + expect(closed, isEmpty); + + await tester.pump(const Duration(milliseconds: 1900)); + expect(find.byType(SurveyBottomSheet), findsNothing); + + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + expect(find.byType(SurveyBottomSheet), findsOneWidget); + expect(closed, isEmpty); + + SurveyService().hideSurvey(); + await tester.pumpAndSettle(); + await shown; + }); + + testWidgets( + 'waits for the next navigation when there is no context after the delay', + (tester) async { + await pumpApp(tester); + PosthogObserver.clearCurrentContext(); + final closed = []; + + final shown = SurveyService().showSurvey( + PostHogDisplaySurvey.fromDict(survey(delaySeconds: 1)), + (_) {}, + (_, __, ___) async => null, + (survey) => closed.add(survey.id), + ); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + expect(find.byType(SurveyBottomSheet), findsNothing); + expect(closed, isEmpty); + + pushNextRoute(tester); + await tester.pumpAndSettle(); + + expect(find.byType(SurveyBottomSheet), findsOneWidget); + expect(closed, isEmpty); + + SurveyService().hideSurvey(); + await tester.pumpAndSettle(); + await shown; + }); + + testWidgets('a navigation during the delay does not show the survey early', ( + tester, + ) async { + await pumpApp(tester); + PosthogObserver.clearCurrentContext(); + + final shown = SurveyService().showSurvey( + PostHogDisplaySurvey.fromDict(survey(delaySeconds: 2)), + (_) {}, + (_, __, ___) async => null, + (_) {}, + ); + await tester.pump(); + + pushNextRoute(tester); + await tester.pumpAndSettle(); + expect(find.byType(SurveyBottomSheet), findsNothing); + + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + expect(find.byType(SurveyBottomSheet), findsOneWidget); + + SurveyService().hideSurvey(); + await tester.pumpAndSettle(); + await shown; + }); + + test('caps a very large popup delay at one day', () { + expect(surveyPopupDelay(1e13), const Duration(days: 1)); + expect(surveyPopupDelay(double.maxFinite), const Duration(days: 1)); + }); + + testWidgets('does not show a survey hidden during its popup delay', ( + tester, + ) async { + await pumpApp(tester); + final closed = []; + + final shown = SurveyService().showSurvey( + PostHogDisplaySurvey.fromDict(survey(delaySeconds: 5)), + (_) {}, + (_, __, ___) async => null, + (survey) => closed.add(survey.id), + ); + await tester.pump(); + + SurveyService().hideSurvey(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + await shown; + + expect(find.byType(SurveyBottomSheet), findsNothing); + expect(closed, isEmpty); + }); + + testWidgets('a later survey replaces one still waiting on its delay', ( + tester, + ) async { + await pumpApp(tester); + final closed = []; + + final first = SurveyService().showSurvey( + PostHogDisplaySurvey.fromDict({ + ...survey(delaySeconds: 1), + 'id': 'first', + }), + (_) {}, + (_, __, ___) async => null, + (survey) => closed.add(survey.id), + ); + await tester.pump(); + + final second = SurveyService().showSurvey( + PostHogDisplaySurvey.fromDict({ + ...survey(delaySeconds: 2), + 'id': 'second', + 'questions': [ + { + 'type': 'link', + 'question': 'Second', + 'isOptional': false, + 'link': '', + }, + ], + }), + (_) {}, + (_, __, ___) async => null, + (survey) => closed.add(survey.id), + ); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + expect(find.byType(SurveyBottomSheet), findsNothing); + + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + expect(find.text('Second'), findsOneWidget); + expect(find.text('Welcome'), findsNothing); + expect(closed, isEmpty); + + SurveyService().hideSurvey(); + await tester.pumpAndSettle(); + await first; + await second; + + expect(find.byType(SurveyBottomSheet), findsNothing); + }); +}