Skip to content

Commit

Permalink
Add Winback analytics (#3476)
Browse files Browse the repository at this point in the history
  • Loading branch information
MiSikora authored Jan 24, 2025
1 parent ab6bb0c commit 7bf1196
Show file tree
Hide file tree
Showing 5 changed files with 499 additions and 21 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package au.com.shiftyjelly.pocketcasts.profile.winback

import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
Expand Down Expand Up @@ -59,9 +60,12 @@ import au.com.shiftyjelly.pocketcasts.localization.R as LR
class WinbackFragment : BaseDialogFragment() {
private val viewModel by viewModels<WinbackViewModel>()

private val params get() = requireNotNull(BundleCompat.getParcelable(requireArguments(), INPUT_ARGS, WinbackInitParams::class.java)) {
"Missing input parameters"
}
private val params
get() = requireNotNull(BundleCompat.getParcelable(requireArguments(), INPUT_ARGS, WinbackInitParams::class.java)) {
"Missing input parameters"
}

private var currentRoute: String? = null

override fun onCreateView(
inflater: LayoutInflater,
Expand Down Expand Up @@ -94,21 +98,34 @@ class WinbackFragment : BaseDialogFragment() {
composable(WinbackNavRoutes.WinbackOffer) {
WinbackOfferPage(
onClaimOffer = {
viewModel.trackClaimOfferTapped()
navController.navigate(WinbackNavRoutes.OfferClaimed) {
popUpTo(WinbackNavRoutes.WinbackOffer) {
inclusive = true
}
}
},
onSeeAvailablePlans = { navController.navigate(WinbackNavRoutes.AvailablePlans) },
onSeeHelpAndFeedback = { navController.navigate(WinbackNavRoutes.HelpAndFeedback) },
onContinueToCancellation = { navController.navigate(WinbackNavRoutes.CancelConfirmation) },
onSeeAvailablePlans = {
viewModel.trackAvailablePlansTapped()
navController.navigate(WinbackNavRoutes.AvailablePlans)
},
onSeeHelpAndFeedback = {
viewModel.trackHelpAndFeedbackTapped()
navController.navigate(WinbackNavRoutes.HelpAndFeedback)
},
onContinueToCancellation = {
viewModel.trackContinueCancellationTapped()
navController.navigate(WinbackNavRoutes.CancelConfirmation)
},
)
}
composable(WinbackNavRoutes.OfferClaimed) {
OfferClaimedPage(
theme = theme.activeTheme,
onConfirm = { dismiss() },
onConfirm = {
viewModel.trackOfferClaimedConfirmationTapped()
dismiss()
},
)
}
composable(WinbackNavRoutes.AvailablePlans) {
Expand All @@ -123,7 +140,10 @@ class WinbackFragment : BaseDialogFragment() {
}
},
onReload = { viewModel.loadInitialPlans() },
onGoBack = { navController.popBackStack() },
onGoBack = {
viewModel.trackPlansBackButtonTapped()
navController.popBackStack()
},
)
}
composable(WinbackNavRoutes.HelpAndFeedback) {
Expand All @@ -149,8 +169,12 @@ class WinbackFragment : BaseDialogFragment() {
composable(WinbackNavRoutes.CancelConfirmation) {
CancelConfirmationPage(
expirationDate = state.currentSubscriptionExpirationDate,
onKeepSubscription = { dismiss() },
onKeepSubscription = {
viewModel.trackKeepSubscriptionTapped()
dismiss()
},
onCancelSubscription = {
viewModel.trackCancelSubscriptionTapped()
if (handleSubscriptionCancellation(state.purchasedProductIds)) {
dismiss()
} else {
Expand All @@ -172,6 +196,15 @@ class WinbackFragment : BaseDialogFragment() {
}
}

LaunchedEffect(navController) {
navController.currentBackStackEntryFlow.collect { entry ->
val route = entry.destination.route.also { currentRoute = it }
if (route != null) {
viewModel.trackScreenShown(route)
}
}
}

SnackbarHost(
hostState = snackbarHostState,
snackbar = { data ->
Expand Down Expand Up @@ -208,6 +241,14 @@ class WinbackFragment : BaseDialogFragment() {
}
}

override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
val route = currentRoute
if (route != null) {
viewModel.trackScreenDismissed(route)
}
}

private fun handleSubscriptionCancellation(productIds: List<String>): Boolean {
return if (productIds.isNotEmpty() && params.hasGoogleSubscription) {
goToPlayStoreSubscriptions(productIds.singleOrNull())
Expand Down Expand Up @@ -257,13 +298,13 @@ data class WinbackInitParams(
}

private object WinbackNavRoutes {
const val WinbackOffer = "WinbackOffer"
const val OfferClaimed = "OfferClaimed"
const val AvailablePlans = "AvailablePlans"
const val HelpAndFeedback = "HelpAndFeedback"
const val SupportLogs = "SupportLogs"
const val StatusCheck = "StatusCheck"
const val CancelConfirmation = "CancelConfirmation"
const val WinbackOffer = "main"
const val OfferClaimed = "offer_claimed"
const val AvailablePlans = "available_plans"
const val HelpAndFeedback = "help_and_feedback"
const val SupportLogs = "logs"
const val StatusCheck = "connection_status"
const val CancelConfirmation = "cancel_confirmation"
}

private val colorAnimationSpec = tween<Color>(350)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package au.com.shiftyjelly.pocketcasts.profile.winback
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent
import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsTracker
import au.com.shiftyjelly.pocketcasts.models.type.Subscription
import au.com.shiftyjelly.pocketcasts.models.type.SubscriptionMapper
import au.com.shiftyjelly.pocketcasts.models.type.SubscriptionPricingPhase
Expand All @@ -21,7 +23,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow
Expand All @@ -31,6 +32,7 @@ import timber.log.Timber
class WinbackViewModel @Inject constructor(
private val subscriptionManager: SubscriptionManager,
private val settings: Settings,
private val tracker: AnalyticsTracker,
) : ViewModel() {
private val subscriptionMapper = SubscriptionMapper()

Expand Down Expand Up @@ -85,6 +87,7 @@ class WinbackViewModel @Inject constructor(
activity: AppCompatActivity,
newPlan: SubscriptionPlan,
) {
trackPlanSelected(newPlan.productId)
if (changePlanJob?.isActive == true) {
return
}
Expand All @@ -103,9 +106,10 @@ class WinbackViewModel @Inject constructor(
}

changePlanJob = viewModelScope.launch {
val currentProductId = loadedState.activePurchase.productId
val isChangeFlowStarted = subscriptionManager.changeProduct(
currentPurchase = currentPurchase,
currentPurchaseProductId = loadedState.activePurchase.productId,
currentPurchaseProductId = currentProductId,
newProduct = newProduct,
newProductOfferToken = newPlan.offerToken,
activity = activity,
Expand All @@ -131,6 +135,11 @@ class WinbackViewModel @Inject constructor(
}

is PurchaseEvent.Success -> {
trackPlanPurchased(
currentProductId = currentProductId,
newProductId = newProduct.productId,
)

val (newPurchases, newPurchase) = loadActivePurchase()
when (newPurchase) {
is ActivePurchaseResult.Found -> {
Expand All @@ -140,6 +149,7 @@ class WinbackViewModel @Inject constructor(
plans.copy(isChangingPlan = false, activePurchase = newPurchase.purchase)
}
}

is ActivePurchaseResult.NotFound -> {
val failure = SubscriptionPlansState.Failure(FailureReason.Default)
_uiState.value = _uiState.value.copy(subscriptionPlansState = failure)
Expand Down Expand Up @@ -216,6 +226,103 @@ class WinbackViewModel @Inject constructor(
LogBuffer.w(LogBuffer.TAG_SUBSCRIPTIONS, message)
}

internal fun trackScreenShown(screen: String) {
tracker.track(
event = AnalyticsEvent.WINBACK_SCREEN_SHOWN,
properties = mapOf("screen" to screen),
)
}

internal fun trackScreenDismissed(screen: String) {
tracker.track(
event = AnalyticsEvent.WINBACK_SCREEN_DISMISSED,
properties = mapOf("screen" to screen),
)
}

internal fun trackContinueCancellationTapped() {
tracker.track(
event = AnalyticsEvent.WINBACK_CONTINUE_BUTTON_TAP,
)
}

internal fun trackClaimOfferTapped() {
val activePurchase = (uiState.value.subscriptionPlansState as? SubscriptionPlansState.Loaded)?.activePurchase
tracker.track(
event = AnalyticsEvent.WINBACK_MAIN_SCREEN_ROW_TAP,
properties = buildMap {
put("row", "claim_offer")
activePurchase?.tier?.let { tier ->
put("tier", tier)
}
activePurchase?.frequency?.let { frequency ->
put("frequency", frequency)
}
},
)
}

internal fun trackAvailablePlansTapped() {
tracker.track(
event = AnalyticsEvent.WINBACK_MAIN_SCREEN_ROW_TAP,
properties = mapOf(
"row" to "available_plans",
),
)
}

internal fun trackHelpAndFeedbackTapped() {
tracker.track(
event = AnalyticsEvent.WINBACK_MAIN_SCREEN_ROW_TAP,
properties = mapOf(
"row" to "help_and_feedback",
),
)
}

internal fun trackOfferClaimedConfirmationTapped() {
tracker.track(
event = AnalyticsEvent.WINBACK_OFFER_CLAIMED_DONE_BUTTON_TAPPED,
)
}

internal fun trackPlansBackButtonTapped() {
tracker.track(
event = AnalyticsEvent.WINBACK_AVAILABLE_PLANS_BACK_BUTTON_TAPPED,
)
}

private fun trackPlanSelected(productId: String) {
tracker.track(
event = AnalyticsEvent.WINBACK_AVAILABLE_PLANS_SELECT_PLAN,
properties = mapOf(
"product" to productId,
),
)
}

private fun trackPlanPurchased(currentProductId: String, newProductId: String) {
tracker.track(
event = AnalyticsEvent.WINBACK_AVAILABLE_PLANS_NEW_PLAN_PURCHASE_SUCCESSFUL,
properties = mapOf(
"current_product" to currentProductId,
"new_product" to newProductId,
),
)
}

internal fun trackKeepSubscriptionTapped() {
tracker.track(
event = AnalyticsEvent.WINBACK_CANCEL_CONFIRMATION_STAY_BUTTON_TAPPED,
)
}

internal fun trackCancelSubscriptionTapped() {
tracker.track(
event = AnalyticsEvent.WINBACK_CANCEL_CONFIRMATION_CANCEL_BUTTON_TAPPED,
)
}

internal data class UiState(
val currentSubscriptionExpirationDate: Date?,
val productsDetails: List<ProductDetails>,
Expand Down Expand Up @@ -266,7 +373,19 @@ internal data class SubscriptionPlan(
internal data class ActivePurchase(
val orderId: String,
val productId: String,
)
) {
val tier get() = when (productId) {
Subscription.PLUS_MONTHLY_PRODUCT_ID, Subscription.PLUS_YEARLY_PRODUCT_ID -> "plus"
Subscription.PATRON_MONTHLY_PRODUCT_ID, Subscription.PATRON_YEARLY_PRODUCT_ID -> "patron"
else -> null
}

val frequency get() = when (productId) {
Subscription.PLUS_MONTHLY_PRODUCT_ID, Subscription.PATRON_MONTHLY_PRODUCT_ID -> "monthly"
Subscription.PLUS_YEARLY_PRODUCT_ID, Subscription.PATRON_YEARLY_PRODUCT_ID -> "yearly"
else -> null
}
}

internal enum class BillingPeriod {
Monthly,
Expand Down
Loading

0 comments on commit 7bf1196

Please sign in to comment.