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-intro-screen.md
Original file line number Diff line number Diff line change
@@ -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.
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.60.7,4.0.0)'
implementation 'com.posthog:posthog-android:[3.61.0,4.0.0)'
}

testOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ fun PostHogDisplaySurvey.toMap(): Map<String, Any?> {
"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,
)
}

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.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
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.69.0" ..< "4.0.0"),
.package(url: "https://github.com/PostHog/posthog-ios", "3.70.0" ..< "4.0.0"),
],
targets: [
.target(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand All @@ -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?,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
12 changes: 12 additions & 0 deletions posthog_flutter/lib/src/surveys/models/survey_appearance.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions posthog_flutter/lib/src/surveys/widgets/intro_message.dart
Original file line number Diff line number Diff line change
@@ -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,
),
],
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -41,6 +42,15 @@ class SurveyBottomSheet extends StatefulWidget {
class _SurveyBottomSheetState extends State<SurveyBottomSheet> {
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() {
Expand Down Expand Up @@ -216,9 +226,7 @@ class _SurveyBottomSheetState extends State<SurveyBottomSheet> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!_isCompleted)
_buildQuestion(context)
else
if (_isCompleted)
ConfirmationMessage(
onClose: _handleClose,
appearance: widget.appearance,
Expand All @@ -227,7 +235,20 @@ class _SurveyBottomSheetState extends State<SurveyBottomSheet> {
.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),
],
),
),
Expand Down
124 changes: 124 additions & 0 deletions posthog_flutter/test/survey_bottom_sheet_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> pumpSurveySheet(
WidgetTester tester,
PostHogDisplaySurvey survey,
List<String> 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 = <String>[];
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 = <String>[];
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 = <String>[];
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 = <String>[];
await pumpSurveySheet(
tester, surveyWithIntro(displayIntroScreen: true), callbackLog);

await tester.tap(find.byType(IconButton));
await tester.pumpAndSettle();

expect(callbackLog, ['shown', 'closed']);
});
});
}
Loading
Loading