Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle upsell flow in the End of Year flow #3066

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import au.com.shiftyjelly.pocketcasts.models.to.LongestEpisode as LongestEpisodeData
Expand All @@ -47,6 +48,7 @@ class EndOfYearViewModel @AssistedInject constructor(
}
private val _switchStory = MutableSharedFlow<Unit>()
internal val switchStory get() = _switchStory.asSharedFlow()
private val isStoryAutoProgressEnabled = MutableStateFlow(false)

internal val uiState = combine(
syncState,
Expand All @@ -73,7 +75,11 @@ class EndOfYearViewModel @AssistedInject constructor(
SyncState.Synced -> {
val (stats, randomShowIds) = eoyStats.run(year, viewModelScope).await()
val stories = createStories(stats, randomShowIds, subscriptionTier)
UiState.Synced(stories, progress)
UiState.Synced(
stories = stories,
isPaidAccount = subscriptionTier.isPaid,
storyProgress = progress,
)
}
}

Expand Down Expand Up @@ -134,6 +140,7 @@ class EndOfYearViewModel @AssistedInject constructor(
countDownJob = launch {
var currentProgress = 0f
while (currentProgress < 1f) {
isStoryAutoProgressEnabled.first { it }
currentProgress += 0.01f
progress.value = currentProgress
delay(progressDelay)
Expand All @@ -144,6 +151,42 @@ class EndOfYearViewModel @AssistedInject constructor(
}
}

internal fun resumeStoryAutoProgress() {
isStoryAutoProgressEnabled.value = true
}

internal fun pauseStoryAutoProgress() {
isStoryAutoProgressEnabled.value = false
}

internal fun getNextStoryIndex(currentIndex: Int): Int? {
val state = uiState.value as? UiState.Synced ?: return null
val stories = state.stories

val nextStory = stories.getOrNull(currentIndex + 1) ?: return null
return if (state.isPaidAccount || nextStory.isFree) {
currentIndex + 1
} else {
stories.drop(currentIndex + 1)
.firstOrNull { it.isFree }
?.let(stories::indexOf)
}.takeIf { it != -1 }
}

internal fun getPreviousStoryIndex(currentIndex: Int): Int? {
val state = uiState.value as? UiState.Synced ?: return null
val stories = state.stories

val previousStory = state.stories.getOrNull(currentIndex - 1) ?: return null
return if (state.isPaidAccount || previousStory.isFree) {
currentIndex - 1
} else {
stories.take(currentIndex)
.lastOrNull { it.isFree }
?.let(stories::indexOf)
}?.takeIf { it != -1 }
}

private fun getRandomShowIds(stats: EndOfYearStats): RandomShowIds? {
val showIds = stats.playedPodcastIds
return if (showIds.isNotEmpty()) {
Expand Down Expand Up @@ -182,13 +225,15 @@ internal sealed interface UiState {
@Immutable
data class Synced(
val stories: List<Story>,
val isPaidAccount: Boolean,
override val storyProgress: Float,
) : UiState
}

@Immutable
internal sealed interface Story {
val previewDuration: Duration? get() = 7.seconds
val isFree: Boolean get() = true
MiSikora marked this conversation as resolved.
Show resolved Hide resolved

data object Cover : Story

Expand Down Expand Up @@ -222,14 +267,16 @@ internal sealed interface Story {
) : Story

data object PlusInterstitial : Story {
override val previewDuration: Duration? get() = null
override val previewDuration = null
}

data class YearVsYear(
val lastYearDuration: Duration,
val thisYearDuration: Duration,
val subscriptionTier: SubscriptionTier?,
) : Story {
override val isFree = false

val yearOverYearChange
get() = when {
lastYearDuration == thisYearDuration -> 1.0
Expand All @@ -243,6 +290,8 @@ internal sealed interface Story {
val completedCount: Int,
val subscriptionTier: SubscriptionTier?,
) : Story {
override val isFree = false

val completionRate
get() = when {
listenedCount == 0 -> 1.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,31 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.ComposeView
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
import au.com.shiftyjelly.pocketcasts.endofyear.ui.StoriesPage
import au.com.shiftyjelly.pocketcasts.repositories.endofyear.EndOfYearManager
import au.com.shiftyjelly.pocketcasts.settings.onboarding.OnboardingFlow
import au.com.shiftyjelly.pocketcasts.settings.onboarding.OnboardingLauncher
import au.com.shiftyjelly.pocketcasts.settings.onboarding.OnboardingUpgradeSource
import au.com.shiftyjelly.pocketcasts.ui.helper.StatusBarColor
import au.com.shiftyjelly.pocketcasts.utils.Util
import au.com.shiftyjelly.pocketcasts.views.fragments.BaseAppCompatDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import android.R as AndroidR
import au.com.shiftyjelly.pocketcasts.ui.R as UR

Expand Down Expand Up @@ -72,31 +80,55 @@ class StoriesFragment : BaseAppCompatDialogFragment() {
}
val state by viewModel.uiState.collectAsState()
val pagerState = rememberPagerState(pageCount = { (state as? UiState.Synced)?.stories?.size ?: 0 })
val storyChanger = rememberStoryChanger(pagerState, viewModel)

StoriesPage(
state = state,
pagerState = pagerState,
onChangeStory = storyChanger::change,
onClickUpsell = ::startUpsellFlow,
onClose = ::dismiss,
)

LaunchedEffect(state::class) {
if (state is UiState.Synced) {
snapshotFlow { pagerState.currentPage }.collect { index ->
val stories = (state as? UiState.Synced)?.stories
if (stories != null) {
viewModel.onStoryChanged(stories[index])
}
}
}
}

LaunchedEffect(Unit) {
viewModel.switchStory.collect {
val stories = (state as? UiState.Synced)?.stories.orEmpty()
if (stories.getOrNull(pagerState.currentPage) is Story.Ending) {
dismiss()
} else if (!pagerState.isScrollInProgress) {
pagerState.scrollToPage(pagerState.currentPage + 1)
} else {
storyChanger.change(moveForward = true)
}
}
}

LaunchedEffect(state::class) {
if (state is UiState.Synced) {
// Track displayed page to not report it twice from different events.
// This can happen, for example, after the first launch.
// Both currentPage and pageCount trigger an event when the pager is set up.
var lastStory: Story? = null
// Inform VM about a story changed due to explicit changes of the current page.
launch {
snapshotFlow { pagerState.currentPage }.collect { index ->
val stories = (state as? UiState.Synced)?.stories
val newStory = stories?.getOrNull(index)
if (newStory != null && lastStory != newStory) {
lastStory = newStory
viewModel.onStoryChanged(newStory)
}
}
}
// Inform VM about a story changed due to a change in the stories list
// This happens when a user sucessfully upgrades their account.
launch {
snapshotFlow { pagerState.pageCount }.collect {
val stories = (state as? UiState.Synced)?.stories
val newStory = stories?.getOrNull(pagerState.currentPage)
if (newStory != null && lastStory != newStory) {
lastStory = newStory
viewModel.onStoryChanged(newStory)
}
}
}
}
}
Expand All @@ -108,12 +140,33 @@ class StoriesFragment : BaseAppCompatDialogFragment() {
super.onDismiss(dialog)
}

private fun startUpsellFlow() {
val flow = OnboardingFlow.Upsell(OnboardingUpgradeSource.END_OF_YEAR)
OnboardingLauncher.openOnboardingFlow(requireActivity(), flow)
}

override fun onResume() {
super.onResume()
viewModel.resumeStoryAutoProgress()
}

override fun onPause() {
// Pause auto progress when the fragment is not active.
// This makes sure that users see all stories and they
// won't auto switch for example when signing up takes
// some time or when the EoY flow is interruped by
// some other user actions such as a phone call.
viewModel.pauseStoryAutoProgress()
super.onPause()
}

enum class StoriesSource(val value: String) {
MODAL("modal"),
PROFILE("profile"),
USER_LOGIN("user_login"),
UNKNOWN("unknown"),
;

companion object {
fun fromString(source: String) = entries.find { it.value == source } ?: UNKNOWN
}
Expand All @@ -129,3 +182,34 @@ class StoriesFragment : BaseAppCompatDialogFragment() {
}
}
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun rememberStoryChanger(
pagerState: PagerState,
viewModel: EndOfYearViewModel,
): StoryChanger {
val scope = rememberCoroutineScope()
return remember(pagerState) { StoryChanger(pagerState, viewModel, scope) }
}

@OptIn(ExperimentalFoundationApi::class)
private class StoryChanger(
private val pagerState: PagerState,
private val viewModel: EndOfYearViewModel,
private val scope: CoroutineScope,
) {
fun change(moveForward: Boolean) {
if (!pagerState.isScrollInProgress) {
val currentPage = pagerState.currentPage
val nextIndex = if (moveForward) {
viewModel.getNextStoryIndex(currentPage)
} else {
viewModel.getPreviousStoryIndex(currentPage)
}
if (nextIndex != null) {
scope.launch { pagerState.scrollToPage(nextIndex) }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import au.com.shiftyjelly.pocketcasts.ui.R as UR
internal fun PlusInterstitialStory(
story: Story.PlusInterstitial,
measurements: EndOfYearMeasurements,
onClickUpsell: () -> Unit,
) {
Column(
modifier = Modifier
Expand All @@ -54,6 +55,7 @@ internal fun PlusInterstitialStory(
PlusInfo(
story = story,
measurements = measurements,
onClickUpsell = onClickUpsell,
)
}
}
Expand Down Expand Up @@ -121,6 +123,7 @@ private fun WaitText(
private fun PlusInfo(
story: Story.PlusInterstitial,
measurements: EndOfYearMeasurements,
onClickUpsell: () -> Unit,
) {
Column(
modifier = Modifier.background(
Expand Down Expand Up @@ -157,7 +160,7 @@ private fun PlusInfo(
)
OutlinedEoyButton(
text = stringResource(LR.string.eoy_story_stories_subscribe_to_plus_button_label),
onClick = {},
onClick = onClickUpsell,
)
}
}
Expand All @@ -169,6 +172,7 @@ private fun PlusInterstitialPreview() {
PlusInterstitialStory(
story = Story.PlusInterstitial,
measurements = measurements,
onClickUpsell = {},
)
}
}
Loading