Skip to content

feat(surveys): add posthog-android-surveys-compose default UI module#541

Draft
leonhardprinz wants to merge 4 commits into
mainfrom
feat/android-surveys-compose-ui
Draft

feat(surveys): add posthog-android-surveys-compose default UI module#541
leonhardprinz wants to merge 4 commits into
mainfrom
feat/android-surveys-compose-ui

Conversation

@leonhardprinz
Copy link
Copy Markdown

@leonhardprinz leonhardprinz commented May 29, 2026

Context

Closes the long-standing gap in #102 (open since Feb 2024): the Android SDK's default surveys delegate is a stub that only logs. Customers using surveys on Android have to implement their own UI, which is a significant lift.

Driven by large customer (20+ global apps), who are blocked rolling out post-print NPS surveys on Android. iOS shipped a complete SwiftUI implementation in PostHog/posthog-ios#320 — this PR ports the same pattern to Kotlin/Compose as an opt-in module so non-Compose apps don't pay for it.

Disclosure: I'm a TAM, not an engineer. This is vibe-coded with PostHog Code using iOS PR #320 as the port-from blueprint. Please push back hard on any architectural calls that conflict with SDK conventions.

What's in this PR

  • New module :posthog-android-surveys-compose — Material 3 ModalBottomSheet + Jetpack Compose
  • NPS / Number Rating question type only — Fujifilm's primary use case (post-print NPS)
  • Theming sourced from PostHogDisplaySurveyAppearance — background, submit button, text, border, rating-button colors all pull through, plus a faithful port of the iOS hex / CSS-color name parser (140-entry table)
  • Lifecycle callbacks wiredonSurveyShown / onSurveyResponse / onSurveyClosed fire at the right moments so the SDK's survey shown / survey sent / survey dismissed events fire correctly. The delegate never calls PostHog.capture directly.
  • ComposeView injected into the foreground Activity's android.R.id.content via an ActivityLifecycleCallbacks-backed ActivityProvider — works on any Activity type (no FragmentActivity requirement)
  • @Preview composables for SurveySheet / NumberRating / BottomSection in default and themed appearances
  • Sample app wiredTrigger test survey button capturing show_test_survey, delegate set in MyApp.kt

What's NOT in this PR (planned follow-ups)

  • Open text, single-choice, multiple-choice, and link question types (placeholder rendered today so the sheet doesn't crash)
  • Emoji rating
  • Thank-you / confirmation screen (displayThankYouMessage == true path)
  • Branching logic verification across multi-question surveys
  • Dark-mode polish
  • Compose UI tests + accessibility audit

Architecture decisions

  • Separate Gradle module (sibling to :posthog-android, no changes to :posthog or :posthog-android) — non-Compose apps don't pay APK-size cost. Mirrors the AndroidX optional-Compose-module pattern.
  • Independent versioning starting at 1.0.0-alpha01 — lets this module iterate through alphas without dragging core SDK versions. Module added to binary-compatibility-validator ignoredProjects during alpha.
  • Compose BOM 2024.12.01 (matches the sample app's existing pin), Material 3 1.3.1, kotlin.plugin.compose for K2-era Compose.
  • rememberModalBottomSheetState(confirmValueChange = { it != SheetValue.Hidden }) for the "X-button-only dismissal" — matches iOS interactiveDismissDisabled() semantics with one line instead of a custom SheetState.
  • Empty consumer-rules.pro + -keep PostHogSurveysComposeDelegate — minimal consumer-side ProGuard surface; populate if downstream minified builds ever surface issues.
  • @file-level ktlint_function_naming_ignore_when_annotated_with = Composable in module .editorconfig so PascalCase @Composable functions don't trip the standard:function-naming rule.

Local verification

This is what I ran in my dev env (no Android emulator available there, so the visual checks are deferred to a real device run):

  • ./gradlew spotlessCheck — passes
  • ./gradlew detekt — passes
  • ./gradlew :posthog:build — passes (confirming no regression in core)
  • Android-specific tasks (:posthog-android-surveys-compose:assembleDebug, :posthog-android-surveys-compose:lint, full ./gradlew build) deferred to CI here because my dev env doesn't have the Android SDK

Manual test plan (for me to run on an emulator before un-drafting)

  1. Build & install the sample app on a Pixel emulator (API 34)
  2. In a PostHog dev project, create a Popover survey with an NPS question, target it on show_test_survey, set custom background/submit colors so theming is visible
  3. Tap "Trigger test survey" in the sample app
  4. Verify: sheet slides up, scale renders 0–10, tap-to-select highlights with submit color, tap same number deselects, submit fires survey sent with the right rating, X-button fires survey dismissed, swipe-down is ignored

Reviewers

cc @marandaneto @ioannisj @lucasheriques

Will also link the Feb 2026 internal Slack thread on Android surveys in a comment.

Refs #102

Adds a new optional Gradle module `posthog-android-surveys-compose` that
ports the iOS SwiftUI survey UI (PostHog/posthog-ios#320) to Jetpack
Compose. Closes the long-standing gap in #102:
the SDK's default `PostHogSurveysDelegate` only logs, so customers
have had to ship their own UI.

Driven by Fujifilm (20+ INSTAX apps) who are blocked rolling out NPS
surveys on Android. Disclosure: vibe-coded by a TAM using PostHog Code
with the iOS implementation as the port-from blueprint.

MVP scope (this commit):
- NPS / Number Rating question type only (0–10, 1–5, 1–7 scales)
- Material 3 ModalBottomSheet container with `confirmValueChange`
  intercept so only the X button dismisses (matches iOS
  `interactiveDismissDisabled` semantics)
- Theming sourced from PostHogDisplaySurveyAppearance — faithful port
  of the iOS hex/CSS-name color parser (140-entry color table)
- onSurveyShown / onSurveyResponse / onSurveyClosed callbacks wired
  so the core SDK fires `survey shown` / `survey sent` /
  `survey dismissed` events correctly
- ComposeView injected into the foreground Activity's
  android.R.id.content via an ActivityLifecycleCallbacks-backed
  ActivityProvider — works on any Activity type
- @Preview composables (default + themed)
- Sample app wired with `Trigger test survey` button capturing
  `show_test_survey`

Out of MVP (planned follow-ups): open-text/choice/link questions,
emoji rating, thank-you screen, multi-question branching coverage,
dark-mode polish, Compose UI tests, accessibility audit.

Architecture:
- Separate Gradle module — non-Compose customers don't pay APK-size
  cost. Mirrors how AndroidX ships optional Compose modules.
- Independent versioning starting at 1.0.0-alpha01 — lets the module
  iterate through alphas without dragging core SDK versions.
- Compose BOM 2024.12.01 (same as the sample app).
- No changes to `:posthog` or `:posthog-android` modules; no public
  API changes; module excluded from binary-compatibility-validator
  during alpha.

Refs: #102

Generated-By: PostHog Code
Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
Android Lint's AutoboxingStateCreation rule (warningsAsErrors enabled)
flagged mutableStateOf(0) for the question index. Switching to
mutableIntStateOf avoids the boxing and clears the lint failure.

Generated-By: PostHog Code
Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
The previous commit removed the mutableStateOf import while switching the
question index to mutableIntStateOf, but the nullable rating state at
SurveySheet.kt:189 (mutableStateOf<Int?>(null)) still needs the boxed
variant since mutableIntStateOf does not support nullable Int.

Generated-By: PostHog Code
Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
AGP aborts the build when lint is configured with a baseline file that
doesn't exist on disk (it generates one and exits to force a check-in).
Matching the empty placeholder convention used by posthog-android/.

Generated-By: PostHog Code
Task-Id: b89cbdd1-9f16-4fa4-b3d0-3f9b268b0c2b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant