diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/EndOfYearViewModel.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/EndOfYearViewModel.kt index 6f721d5df3..8818f22d16 100644 --- a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/EndOfYearViewModel.kt +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/EndOfYearViewModel.kt @@ -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 @@ -47,6 +48,7 @@ class EndOfYearViewModel @AssistedInject constructor( } private val _switchStory = MutableSharedFlow() internal val switchStory get() = _switchStory.asSharedFlow() + private val isStoryAutoProgressEnabled = MutableStateFlow(false) internal val uiState = combine( syncState, @@ -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, + ) } } @@ -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) @@ -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()) { @@ -182,6 +225,7 @@ internal sealed interface UiState { @Immutable data class Synced( val stories: List, + val isPaidAccount: Boolean, override val storyProgress: Float, ) : UiState } @@ -189,6 +233,7 @@ internal sealed interface UiState { @Immutable internal sealed interface Story { val previewDuration: Duration? get() = 7.seconds + val isFree: Boolean get() = true data object Cover : Story @@ -222,7 +267,7 @@ internal sealed interface Story { ) : Story data object PlusInterstitial : Story { - override val previewDuration: Duration? get() = null + override val previewDuration = null } data class YearVsYear( @@ -230,6 +275,8 @@ internal sealed interface Story { val thisYearDuration: Duration, val subscriptionTier: SubscriptionTier?, ) : Story { + override val isFree = false + val yearOverYearChange get() = when { lastYearDuration == thisYearDuration -> 1.0 @@ -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 diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesFragment.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesFragment.kt index 09df971776..c5b2c46375 100644 --- a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesFragment.kt +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/StoriesFragment.kt @@ -9,10 +9,14 @@ 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 @@ -20,12 +24,16 @@ 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 @@ -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) + } + } } } } @@ -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 } @@ -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) } + } + } + } +} diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/ui/PlusInterstitialStory.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/ui/PlusInterstitialStory.kt index 1a9168bbe4..d680437251 100644 --- a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/ui/PlusInterstitialStory.kt +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/ui/PlusInterstitialStory.kt @@ -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 @@ -54,6 +55,7 @@ internal fun PlusInterstitialStory( PlusInfo( story = story, measurements = measurements, + onClickUpsell = onClickUpsell, ) } } @@ -121,6 +123,7 @@ private fun WaitText( private fun PlusInfo( story: Story.PlusInterstitial, measurements: EndOfYearMeasurements, + onClickUpsell: () -> Unit, ) { Column( modifier = Modifier.background( @@ -157,7 +160,7 @@ private fun PlusInfo( ) OutlinedEoyButton( text = stringResource(LR.string.eoy_story_stories_subscribe_to_plus_button_label), - onClick = {}, + onClick = onClickUpsell, ) } } @@ -169,6 +172,7 @@ private fun PlusInterstitialPreview() { PlusInterstitialStory( story = Story.PlusInterstitial, measurements = measurements, + onClickUpsell = {}, ) } } diff --git a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/ui/StoriesPage.kt b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/ui/StoriesPage.kt index 980e08e189..d459e6f201 100644 --- a/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/ui/StoriesPage.kt +++ b/modules/features/endofyear/src/main/java/au/com/shiftyjelly/pocketcasts/endofyear/ui/StoriesPage.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,7 +45,6 @@ import au.com.shiftyjelly.pocketcasts.compose.components.TextH10 import au.com.shiftyjelly.pocketcasts.endofyear.Story import au.com.shiftyjelly.pocketcasts.endofyear.UiState import au.com.shiftyjelly.pocketcasts.utils.Util -import kotlinx.coroutines.launch import au.com.shiftyjelly.pocketcasts.images.R as IR import au.com.shiftyjelly.pocketcasts.localization.R as LR @@ -55,6 +53,8 @@ import au.com.shiftyjelly.pocketcasts.localization.R as LR internal fun StoriesPage( state: UiState, pagerState: PagerState, + onChangeStory: (Boolean) -> Unit, + onClickUpsell: () -> Unit, onClose: () -> Unit, ) { val size = LocalContext.current.sizeLimit?.let(Modifier::size) ?: Modifier.fillMaxSize() @@ -83,6 +83,8 @@ internal fun StoriesPage( coverTextHeight = coverTextHeight, ), pagerState = pagerState, + onChangeStory = onChangeStory, + onClickUpsell = onClickUpsell, ) } @@ -120,8 +122,9 @@ private fun Stories( stories: List, measurements: EndOfYearMeasurements, pagerState: PagerState, + onChangeStory: (Boolean) -> Unit, + onClickUpsell: () -> Unit, ) { - val coroutineScope = rememberCoroutineScope() val widthPx = LocalDensity.current.run { measurements.width.toPx() } HorizontalPager( @@ -129,16 +132,8 @@ private fun Stories( userScrollEnabled = false, modifier = Modifier.pointerInput(Unit) { detectTapGestures { offset -> - if (!pagerState.isScrollInProgress) { - coroutineScope.launch { - val nextPage = if (offset.x > widthPx / 2) { - pagerState.currentPage + 1 - } else { - pagerState.currentPage - 1 - } - pagerState.scrollToPage(nextPage) - } - } + val moveForward = offset.x > widthPx / 2 + onChangeStory(moveForward) } }, ) { index -> @@ -150,7 +145,7 @@ private fun Stories( is Story.Ratings -> RatingsStory(story, measurements) is Story.TotalTime -> TotalTimeStory(story, measurements) is Story.LongestEpisode -> LongestEpisodeStory(story, measurements) - is Story.PlusInterstitial -> PlusInterstitialStory(story, measurements) + is Story.PlusInterstitial -> PlusInterstitialStory(story, measurements, onClickUpsell) is Story.YearVsYear -> YearVsYearStory(story, measurements) is Story.CompletionRate -> CompletionRateStory(story, measurements) is Story.Ending -> EndingStory(story, measurements) diff --git a/modules/features/endofyear/src/test/kotlin/au/com/shiftyjelly/pocketcasts/endofyear/EndOfYearViewModelTest.kt b/modules/features/endofyear/src/test/kotlin/au/com/shiftyjelly/pocketcasts/endofyear/EndOfYearViewModelTest.kt index fb9bac12b4..7d27e1abfa 100644 --- a/modules/features/endofyear/src/test/kotlin/au/com/shiftyjelly/pocketcasts/endofyear/EndOfYearViewModelTest.kt +++ b/modules/features/endofyear/src/test/kotlin/au/com/shiftyjelly/pocketcasts/endofyear/EndOfYearViewModelTest.kt @@ -455,6 +455,7 @@ class EndOfYearViewModelTest { subscriptionTier.emit(SubscriptionTier.NONE) viewModel.syncData() + viewModel.resumeStoryAutoProgress() val stories = (viewModel.uiState.first() as UiState.Synced).stories viewModel.switchStory.test { @@ -495,6 +496,119 @@ class EndOfYearViewModelTest { } } + @Test + fun `resume and pause stories auto switching`() = runTest { + endOfYearSync.isSynced.add(true) + endOfYearManager.stats.add(stats) + subscriptionTier.emit(SubscriptionTier.NONE) + + viewModel.syncData() + val stories = (viewModel.uiState.first() as UiState.Synced).stories + + viewModel.switchStory.test { + expectNoEvents() + + // Initially switching should be paused + viewModel.onStoryChanged(stories.getStoryOfType()) + expectNoEvents() + + // Resume after pause + viewModel.resumeStoryAutoProgress() + assertEquals(Unit, awaitItem()) + + // Pause after resume + viewModel.pauseStoryAutoProgress() + viewModel.onStoryChanged(stories.getStoryOfType()) + expectNoEvents() + } + } + + @Test + fun `get index of next story for paid account`() = runTest { + endOfYearSync.isSynced.add(true) + endOfYearManager.stats.add(stats) + subscriptionTier.emit(SubscriptionTier.PLUS) + + viewModel.syncData() + val stories = (viewModel.uiState.first() as UiState.Synced).stories + + assertEquals(1, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(2, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(3, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(4, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(5, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(6, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(7, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(8, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(9, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(null, viewModel.getNextStoryIndex(stories.indexOf())) + } + + @Test + fun `get index of previous story for paid account`() = runTest { + endOfYearSync.isSynced.add(true) + endOfYearManager.stats.add(stats) + subscriptionTier.emit(SubscriptionTier.PLUS) + + viewModel.syncData() + val stories = (viewModel.uiState.first() as UiState.Synced).stories + + assertEquals(null, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(0, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(1, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(2, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(3, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(4, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(5, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(6, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(7, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(8, viewModel.getPreviousStoryIndex(stories.indexOf())) + } + + @Test + fun `get index of next story for free account`() = runTest { + endOfYearSync.isSynced.add(true) + endOfYearManager.stats.add(stats) + subscriptionTier.emit(SubscriptionTier.NONE) + + viewModel.syncData() + val stories = (viewModel.uiState.first() as UiState.Synced).stories + + assertEquals(1, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(2, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(3, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(4, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(5, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(6, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(7, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(10, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(10, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(10, viewModel.getNextStoryIndex(stories.indexOf())) + assertEquals(null, viewModel.getNextStoryIndex(stories.indexOf())) + } + + @Test + fun `get index of previous story for free account`() = runTest { + endOfYearSync.isSynced.add(true) + endOfYearManager.stats.add(stats) + subscriptionTier.emit(SubscriptionTier.NONE) + + viewModel.syncData() + val stories = (viewModel.uiState.first() as UiState.Synced).stories + + assertEquals(null, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(0, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(1, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(2, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(3, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(4, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(5, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(6, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(7, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(7, viewModel.getPreviousStoryIndex(stories.indexOf())) + assertEquals(7, viewModel.getPreviousStoryIndex(stories.indexOf())) + } + private suspend fun TurbineTestContext.awaitStories(): List { return (awaitItem() as UiState.Synced).stories } @@ -507,6 +621,10 @@ class EndOfYearViewModelTest { return filterIsInstance().single() } + private inline fun List.indexOf(): Int { + return indexOfFirst { it is T } + } + private inline fun assertHasStory(stories: List) { assertTrue(stories.filterIsInstance().isNotEmpty()) } diff --git a/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/type/SubscriptionTier.kt b/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/type/SubscriptionTier.kt index 86eff61fba..b919784a68 100644 --- a/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/type/SubscriptionTier.kt +++ b/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/type/SubscriptionTier.kt @@ -4,10 +4,22 @@ import au.com.shiftyjelly.pocketcasts.utils.featureflag.UserTier import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = false) -enum class SubscriptionTier(val label: String) { - NONE("none"), - PLUS("plus"), - PATRON("patron"), +enum class SubscriptionTier( + val label: String, + val isPaid: Boolean, +) { + NONE( + label = "none", + isPaid = false, + ), + PLUS( + label = "plus", + isPaid = true, + ), + PATRON( + label = "patron", + isPaid = true, + ), ; override fun toString() = label