diff --git a/.changeset/survey-intro-screen.md b/.changeset/survey-intro-screen.md new file mode 100644 index 00000000..72adb1a9 --- /dev/null +++ b/.changeset/survey-intro-screen.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +Surveys can now display an optional intro screen before the first question, configured via the new `displayIntroScreen`, `introScreenHeader`, `introScreenDescription`, `introScreenDescriptionContentType`, and `introScreenButtonText` appearance fields. The intro is rendered by the Dart survey UI as the leading mirror of the confirmation message: advancing past it records no response and sends no survey event, while closing the survey from the intro still sends the normal `survey dismissed` event. Requires posthog-ios >= 3.70.0 and posthog-android >= 3.59.0, which forward the new appearance fields over the bridge. diff --git a/posthog_flutter/android/build.gradle b/posthog_flutter/android/build.gradle index 8674e166..ca79394b 100644 --- a/posthog_flutter/android/build.gradle +++ b/posthog_flutter/android/build.gradle @@ -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.60.7,4.0.0)' + implementation 'com.posthog:posthog-android:[3.61.0,4.0.0)' } testOptions { 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 b8fcb15c..10efea8d 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 @@ -81,6 +81,11 @@ fun PostHogDisplaySurvey.toMap(): Map { "thankYouMessageDescription" to app.thankYouMessageDescription, "thankYouMessageDescriptionContentType" to app.thankYouMessageDescriptionContentType?.value, "thankYouMessageCloseButtonText" to app.thankYouMessageCloseButtonText, + "displayIntroScreen" to app.displayIntroScreen, + "introScreenHeader" to app.introScreenHeader, + "introScreenDescription" to app.introScreenDescription, + "introScreenDescriptionContentType" to app.introScreenDescriptionContentType?.value, + "introScreenButtonText" to app.introScreenButtonText, ) } diff --git a/posthog_flutter/darwin/posthog_flutter.podspec b/posthog_flutter/darwin/posthog_flutter.podspec index 69e3dd0e..19fc5ef4 100644 --- a/posthog_flutter/darwin/posthog_flutter.podspec +++ b/posthog_flutter/darwin/posthog_flutter.podspec @@ -21,8 +21,8 @@ Postog flutter plugin s.ios.dependency 'Flutter' s.osx.dependency 'FlutterMacOS' - # ~> Version 3.69.0 up to, but not including, 4.0.0 - s.dependency 'PostHog', '>= 3.69.0', '< 4.0.0' + # ~> Version 3.70.0 up to, but not including, 4.0.0 + s.dependency 'PostHog', '>= 3.70.0', '< 4.0.0' s.ios.deployment_target = '13.0' # PH iOS SDK 3.0.0 requires >= 10.15 diff --git a/posthog_flutter/darwin/posthog_flutter/Package.swift b/posthog_flutter/darwin/posthog_flutter/Package.swift index 56e24a8f..e4a24d60 100644 --- a/posthog_flutter/darwin/posthog_flutter/Package.swift +++ b/posthog_flutter/darwin/posthog_flutter/Package.swift @@ -14,7 +14,7 @@ let package = Package( ], dependencies: [ .package(name: "FlutterFramework", path: "../FlutterFramework"), - .package(url: "https://github.com/PostHog/posthog-ios", "3.69.0" ..< "4.0.0"), + .package(url: "https://github.com/PostHog/posthog-ios", "3.70.0" ..< "4.0.0"), ], targets: [ .target( 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 c9912d62..a710dc32 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 @@ -101,6 +101,17 @@ if let thankYouMessageCloseButtonText = appearance.thankYouMessageCloseButtonText { appearanceDict["thankYouMessageCloseButtonText"] = thankYouMessageCloseButtonText } + appearanceDict["displayIntroScreen"] = appearance.displayIntroScreen + if let introScreenHeader = appearance.introScreenHeader { + appearanceDict["introScreenHeader"] = introScreenHeader + } + if let introScreenDescription = appearance.introScreenDescription { + appearanceDict["introScreenDescription"] = introScreenDescription + appearanceDict["introScreenDescriptionContentType"] = appearance.introScreenDescriptionContentType?.rawValue + } + if let introScreenButtonText = appearance.introScreenButtonText { + appearanceDict["introScreenButtonText"] = introScreenButtonText + } dict["appearance"] = appearanceDict } 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 82dbff38..984dc0e5 100644 --- a/posthog_flutter/lib/src/surveys/models/posthog_display_survey.dart +++ b/posthog_flutter/lib/src/surveys/models/posthog_display_survey.dart @@ -94,6 +94,11 @@ class PostHogDisplaySurvey { final thankYouMessageDescriptionContentType = PostHogDisplaySurveyTextContentType.fromInt(thankYouContentTypeRaw); + final introContentTypeRaw = + a['introScreenDescriptionContentType'] as int? ?? 1; + final introScreenDescriptionContentType = + PostHogDisplaySurveyTextContentType.fromInt(introContentTypeRaw); + appearance = PostHogDisplaySurveyAppearance( fontFamily: a['fontFamily'] as String?, backgroundColor: a['backgroundColor'] as String?, @@ -115,6 +120,11 @@ class PostHogDisplaySurvey { thankYouMessageDescriptionContentType, thankYouMessageCloseButtonText: a['thankYouMessageCloseButtonText'] as String?, + displayIntroScreen: a['displayIntroScreen'] as bool? ?? false, + introScreenHeader: a['introScreenHeader'] as String?, + introScreenDescription: a['introScreenDescription'] as String?, + introScreenDescriptionContentType: introScreenDescriptionContentType, + introScreenButtonText: a['introScreenButtonText'] 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 00c6e531..8cf2d565 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 @@ -23,6 +23,11 @@ class PostHogDisplaySurveyAppearance { this.thankYouMessageDescription, this.thankYouMessageDescriptionContentType, this.thankYouMessageCloseButtonText, + this.displayIntroScreen = false, + this.introScreenHeader, + this.introScreenDescription, + this.introScreenDescriptionContentType, + this.introScreenButtonText, }); final String? fontFamily; @@ -44,4 +49,9 @@ class PostHogDisplaySurveyAppearance { final PostHogDisplaySurveyTextContentType? thankYouMessageDescriptionContentType; final String? thankYouMessageCloseButtonText; + final bool displayIntroScreen; + final String? introScreenHeader; + final String? introScreenDescription; + final PostHogDisplaySurveyTextContentType? introScreenDescriptionContentType; + final String? introScreenButtonText; } diff --git a/posthog_flutter/lib/src/surveys/models/survey_appearance.dart b/posthog_flutter/lib/src/surveys/models/survey_appearance.dart index aa3f9143..ac3696d5 100644 --- a/posthog_flutter/lib/src/surveys/models/survey_appearance.dart +++ b/posthog_flutter/lib/src/surveys/models/survey_appearance.dart @@ -22,6 +22,10 @@ class SurveyAppearance { this.thankYouMessageHeader = 'Thank you for your feedback!', this.thankYouMessageDescription, this.thankYouMessageCloseButtonText = 'Close', + this.displayIntroScreen = false, + this.introScreenHeader, + this.introScreenDescription, + this.introScreenButtonText = 'Get started', this.borderColor = const Color(0xFFBDBDBD), this.inputBackgroundColor = Colors.white, this.inputTextColor = Colors.black, @@ -45,6 +49,10 @@ class SurveyAppearance { final String thankYouMessageHeader; final String? thankYouMessageDescription; final String thankYouMessageCloseButtonText; + final bool displayIntroScreen; + final String? introScreenHeader; + final String? introScreenDescription; + final String introScreenButtonText; final Color borderColor; final Color inputBackgroundColor; final Color inputTextColor; @@ -101,6 +109,10 @@ class SurveyAppearance { thankYouMessageDescription: appearance?.thankYouMessageDescription, thankYouMessageCloseButtonText: appearance?.thankYouMessageCloseButtonText ?? 'Close', + displayIntroScreen: appearance?.displayIntroScreen ?? false, + introScreenHeader: appearance?.introScreenHeader, + introScreenDescription: appearance?.introScreenDescription, + introScreenButtonText: appearance?.introScreenButtonText ?? 'Get started', borderColor: _colorFromHex(appearance?.borderColor) ?? const Color(0xFFBDBDBD), inputBackgroundColor: inputBackgroundColor, diff --git a/posthog_flutter/lib/src/surveys/widgets/intro_message.dart b/posthog_flutter/lib/src/surveys/widgets/intro_message.dart new file mode 100644 index 00000000..db64633f --- /dev/null +++ b/posthog_flutter/lib/src/surveys/widgets/intro_message.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; +import '../models/survey_appearance.dart'; +import 'survey_button.dart'; + +/// Intro screen shown before the first question — the leading mirror of +/// [ConfirmationMessage]. Advancing records no response and sends no survey +/// event; it is a pure UI transition handled by the bottom sheet. +class IntroMessage extends StatelessWidget { + const IntroMessage({ + super.key, + required this.onStart, + this.appearance = SurveyAppearance.defaultAppearance, + this.introScreenDescriptionContentType, + }); + + final VoidCallback onStart; + final SurveyAppearance appearance; + final PostHogDisplaySurveyTextContentType? introScreenDescriptionContentType; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (appearance.introScreenHeader?.isNotEmpty == true) + Text( + appearance.introScreenHeader!, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: appearance.descriptionTextColor, + ), + textAlign: TextAlign.center, + ), + if (appearance.introScreenDescription?.isNotEmpty == true && + introScreenDescriptionContentType == + PostHogDisplaySurveyTextContentType.text) ...[ + const SizedBox(height: 16), + Text( + appearance.introScreenDescription!, + style: TextStyle( + fontSize: 16, + color: appearance.descriptionTextColor, + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 20), + SurveyButton( + onPressed: onStart, + text: appearance.introScreenButtonText, + appearance: appearance, + ), + ], + ); + } +} diff --git a/posthog_flutter/lib/src/surveys/widgets/survey_bottom_sheet.dart b/posthog_flutter/lib/src/surveys/widgets/survey_bottom_sheet.dart index 1e59bdf3..dc1ce6c1 100644 --- a/posthog_flutter/lib/src/surveys/widgets/survey_bottom_sheet.dart +++ b/posthog_flutter/lib/src/surveys/widgets/survey_bottom_sheet.dart @@ -16,6 +16,7 @@ import 'rating_question.dart'; import 'choice_question.dart'; import 'confirmation_message.dart'; import 'survey_icon.dart'; +import 'intro_message.dart'; /// A bottom sheet that displays a survey to the user. class SurveyBottomSheet extends StatefulWidget { @@ -41,6 +42,15 @@ class SurveyBottomSheet extends StatefulWidget { class _SurveyBottomSheetState extends State { int _currentIndex = 0; bool _isCompleted = false; + // Advancing past the intro is a pure UI transition: no response is recorded + // and no survey event is sent. The X button keeps closing the survey. + // The intro has no default header, so an intro with no copy at all is + // skipped instead of drawing an empty sheet with a lone button. + late bool _showingIntroScreen = + (widget.survey.appearance?.displayIntroScreen ?? false) && + ((widget.survey.appearance?.introScreenHeader?.isNotEmpty ?? false) || + (widget.survey.appearance?.introScreenDescription?.isNotEmpty ?? + false)); @override void initState() { @@ -216,9 +226,7 @@ class _SurveyBottomSheetState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!_isCompleted) - _buildQuestion(context) - else + if (_isCompleted) ConfirmationMessage( onClose: _handleClose, appearance: widget.appearance, @@ -227,7 +235,20 @@ class _SurveyBottomSheetState extends State { .appearance ?.thankYouMessageDescriptionContentType ?? PostHogDisplaySurveyTextContentType.text, - ), + ) + else if (_showingIntroScreen) + IntroMessage( + onStart: () => + setState(() => _showingIntroScreen = false), + appearance: widget.appearance, + introScreenDescriptionContentType: widget + .survey + .appearance + ?.introScreenDescriptionContentType ?? + PostHogDisplaySurveyTextContentType.text, + ) + else + _buildQuestion(context), ], ), ), diff --git a/posthog_flutter/test/survey_bottom_sheet_test.dart b/posthog_flutter/test/survey_bottom_sheet_test.dart new file mode 100644 index 00000000..cffb0d9f --- /dev/null +++ b/posthog_flutter/test/survey_bottom_sheet_test.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/surveys/models/posthog_display_survey.dart'; +import 'package:posthog_flutter/src/surveys/models/survey_appearance.dart'; +import 'package:posthog_flutter/src/surveys/models/survey_callbacks.dart'; +import 'package:posthog_flutter/src/surveys/widgets/survey_bottom_sheet.dart'; + +void main() { + PostHogDisplaySurvey surveyWithIntro({ + required bool displayIntroScreen, + String? header = 'Welcome!', + String? description = 'Two quick questions.', + }) { + return PostHogDisplaySurvey.fromDict({ + 'id': 'survey-1', + 'name': 'Test survey', + 'questions': [ + { + 'type': 'open', + 'question': 'What can we do better?', + 'isOptional': false, + }, + ], + 'appearance': { + 'displayIntroScreen': displayIntroScreen, + if (header != null) 'introScreenHeader': header, + if (description != null) 'introScreenDescription': description, + 'introScreenDescriptionContentType': 1, + 'introScreenButtonText': 'Get started', + }, + }); + } + + // Presents the sheet the same way SurveyService does in production. + Future pumpSurveySheet( + WidgetTester tester, + PostHogDisplaySurvey survey, + List callbackLog, + ) async { + await tester + .pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox()))); + final context = tester.element(find.byType(Scaffold)); + unawaited(showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: false, + builder: (context) => SurveyBottomSheet( + survey: survey, + appearance: SurveyAppearance.fromPostHog(survey.appearance), + onShown: (_) => callbackLog.add('shown'), + onResponse: (_, index, __) async { + callbackLog.add('response:$index'); + return const PostHogSurveyNextQuestion( + questionIndex: 0, + isSurveyCompleted: true, + ); + }, + onClosed: (_) => callbackLog.add('closed'), + ), + )); + await tester.pumpAndSettle(); + } + + group('SurveyBottomSheet intro screen', () { + testWidgets( + 'shows the intro before the first question and advances without callbacks', + (tester) async { + final callbackLog = []; + await pumpSurveySheet( + tester, surveyWithIntro(displayIntroScreen: true), callbackLog); + + // Intro visible, question not yet rendered + expect(find.text('Welcome!'), findsOneWidget); + expect(find.text('Two quick questions.'), findsOneWidget); + expect(find.text('What can we do better?'), findsNothing); + + await tester.tap(find.text('Get started')); + await tester.pumpAndSettle(); + + // Question 1 visible; advancing recorded no response and closed nothing + expect(find.text('What can we do better?'), findsOneWidget); + expect(find.text('Welcome!'), findsNothing); + expect(callbackLog, ['shown']); + }); + + testWidgets('does not show the intro when displayIntroScreen is off', + (tester) async { + final callbackLog = []; + await pumpSurveySheet( + tester, surveyWithIntro(displayIntroScreen: false), callbackLog); + + expect(find.text('Welcome!'), findsNothing); + expect(find.text('What can we do better?'), findsOneWidget); + }); + + testWidgets( + 'skips the intro when it has neither a header nor a description', + (tester) async { + final callbackLog = []; + await pumpSurveySheet( + tester, + surveyWithIntro( + displayIntroScreen: true, header: null, description: null), + callbackLog); + + expect(find.text('Get started'), findsNothing); + expect(find.text('What can we do better?'), findsOneWidget); + }); + + testWidgets('closing from the intro screen still notifies onClosed', + (tester) async { + final callbackLog = []; + await pumpSurveySheet( + tester, surveyWithIntro(displayIntroScreen: true), callbackLog); + + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect(callbackLog, ['shown', 'closed']); + }); + }); +} diff --git a/posthog_flutter/test/surveys_test.dart b/posthog_flutter/test/surveys_test.dart index 8d1e4031..723cefa2 100644 --- a/posthog_flutter/test/surveys_test.dart +++ b/posthog_flutter/test/surveys_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/posthog_observer.dart'; import 'package:posthog_flutter/src/surveys/models/posthog_display_link_question.dart'; import 'package:posthog_flutter/src/surveys/models/posthog_display_survey.dart'; +import 'package:posthog_flutter/src/surveys/models/posthog_display_survey_text_content_type.dart'; import 'package:posthog_flutter/src/surveys/survey_service.dart'; import 'package:posthog_flutter/src/surveys/widgets/survey_bottom_sheet.dart'; @@ -160,4 +161,47 @@ void main() { expect(question.link, ''); }); }); + + group('PostHogDisplaySurvey.fromDict intro screen appearance', () { + Map surveyWithAppearance(Map appearance) { + return { + 'id': 'survey-1', + 'name': 'Test survey', + 'questions': [ + {'type': 'open', 'question': 'Feedback?', 'isOptional': false}, + ], + 'appearance': appearance, + }; + } + + test('parses the intro screen fields from the native payload', () { + final survey = PostHogDisplaySurvey.fromDict(surveyWithAppearance({ + 'displayIntroScreen': true, + 'introScreenHeader': 'Welcome!', + 'introScreenDescription': 'Two quick questions.', + 'introScreenDescriptionContentType': 0, + 'introScreenButtonText': 'Get started', + })); + + final appearance = survey.appearance!; + expect(appearance.displayIntroScreen, true); + expect(appearance.introScreenHeader, 'Welcome!'); + expect(appearance.introScreenDescription, 'Two quick questions.'); + expect(appearance.introScreenDescriptionContentType, + PostHogDisplaySurveyTextContentType.html); + expect(appearance.introScreenButtonText, 'Get started'); + }); + + test('absent intro screen keys default to disabled', () { + final survey = PostHogDisplaySurvey.fromDict(surveyWithAppearance({ + 'thankYouMessageHeader': 'Thanks!', + })); + + final appearance = survey.appearance!; + expect(appearance.displayIntroScreen, false); + expect(appearance.introScreenHeader, isNull); + expect(appearance.introScreenDescriptionContentType, + PostHogDisplaySurveyTextContentType.text); + }); + }); }