From 148cf16d5b0f2c061fae68b48a082453c9732e28 Mon Sep 17 00:00:00 2001 From: Aleksandr Zhukov Date: Tue, 23 Jul 2024 18:33:11 +0200 Subject: [PATCH] Fix limits are visible in GamificationToolbar after step solving (#1114) --- ...MainGamificationToolbarActionDispatcher.kt | 17 +++++- .../MainStepQuizToolbarActionDispatcher.kt | 29 ++++----- .../widget/presentation/StateExtentions.kt | 54 +++++++++++++---- .../presentation/StudyPlanWidgetReducer.kt | 7 +-- .../mapper/StudyPlanWidgetViewStateMapper.kt | 60 +++++++++++-------- 5 files changed, 109 insertions(+), 58 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/MainGamificationToolbarActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/MainGamificationToolbarActionDispatcher.kt index c2bd2741cb..3ac1e38eb0 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/MainGamificationToolbarActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/MainGamificationToolbarActionDispatcher.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import org.hyperskill.app.core.domain.DataSourceType import org.hyperskill.app.core.presentation.ActionDispatcherOptions @@ -40,6 +41,8 @@ internal class MainGamificationToolbarActionDispatcher( private val sentryInteractor: SentryInteractor ) : CoroutineActionDispatcher(config.createConfig()) { + private var isMobileContentTrialEnabled: Boolean = false + init { stepCompletedFlow.observe() .onEach { onNewMessage(InternalMessage.StepSolved) } @@ -72,6 +75,12 @@ internal class MainGamificationToolbarActionDispatcher( .launchIn(actionScope) currentSubscriptionStateRepository.changes + .map { + it.orContentTrial( + isMobileContentTrialEnabled = isMobileContentTrialEnabled, + canMakePayments = canMakePayments() + ) + } .distinctUntilChanged() .onEach { onNewMessage(InternalMessage.SubscriptionChanged(it)) } .launchIn(actionScope) @@ -108,7 +117,10 @@ internal class MainGamificationToolbarActionDispatcher( val gamificationToolbarDataWithSource = toolbarDataDeferred.await().getOrThrow() val profile = profileDeferred.await().getOrThrow() - val canMakePayments = purchaseInteractor.canMakePayments().getOrDefault(false) + this@MainGamificationToolbarActionDispatcher.isMobileContentTrialEnabled = + profile.features.isMobileContentTrialEnabled + + val canMakePayments = canMakePayments() val subscription = getSubscription( isMobileContentTrialEnabled = profile.features.isMobileContentTrialEnabled, @@ -169,4 +181,7 @@ internal class MainGamificationToolbarActionDispatcher( subscriptionWithSource.state } } + + private suspend fun canMakePayments(): Boolean = + purchaseInteractor.canMakePayments().getOrDefault(false) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_toolbar/presentation/MainStepQuizToolbarActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_toolbar/presentation/MainStepQuizToolbarActionDispatcher.kt index fc3cf79ea0..262dbb31ac 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_toolbar/presentation/MainStepQuizToolbarActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_toolbar/presentation/MainStepQuizToolbarActionDispatcher.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.profile.domain.model.freemiumChargeLimitsStrategy import org.hyperskill.app.profile.domain.model.isMobileContentTrialEnabled @@ -30,21 +29,19 @@ internal class MainStepQuizToolbarActionDispatcher( private var isMobileContentTrialEnabled = false init { - actionScope.launch { - currentSubscriptionStateRepository - .changes - .map { subscription -> - subscription.orContentTrial( - isMobileContentTrialEnabled = isMobileContentTrialEnabled, - canMakePayments = canMakePayments() - ) - } - .distinctUntilChanged() - .onEach { - onNewMessage(InternalMessage.SubscriptionChanged(it)) - } - .launchIn(this) - } + currentSubscriptionStateRepository + .changes + .map { subscription -> + subscription.orContentTrial( + isMobileContentTrialEnabled = isMobileContentTrialEnabled, + canMakePayments = canMakePayments() + ) + } + .distinctUntilChanged() + .onEach { + onNewMessage(InternalMessage.SubscriptionChanged(it)) + } + .launchIn(actionScope) } override suspend fun doSuspendableAction(action: Action) { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StateExtentions.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StateExtentions.kt index 30607ffbb7..bb8244e361 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StateExtentions.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StateExtentions.kt @@ -10,11 +10,13 @@ import org.hyperskill.app.subscriptions.domain.model.getSubscriptionLimitType /** * @return current [StudyPlanSection]. * - * `studyPlanSections` map preserves the entry iteration order, so we can use the first element as the current section. - * * @see StudyPlanWidgetReducer.handleLearningActivitiesWithSectionsFetchSuccess */ internal fun StudyPlanWidgetFeature.State.getCurrentSection(): StudyPlanSection? = + /** + * [StudyPlanWidgetFeature.State.studyPlanSections] map preserves the entry iteration order, + * so we can use the first element as the current section. + */ studyPlanSections.values.firstOrNull()?.studyPlanSection /** @@ -37,19 +39,45 @@ internal fun StudyPlanWidgetFeature.State.getCurrentActivity(): LearningActivity /** * @param sectionId target section id. - * @return list of [LearningActivity] for the given section with [sectionId]. + * @return a sequence of [LearningActivity] for the given section with [sectionId] + * filtered by availability in [StudyPlanWidgetFeature.State.activities] */ -internal fun StudyPlanWidgetFeature.State.getSectionActivities(sectionId: Long): List = +internal fun StudyPlanWidgetFeature.State.getSectionActivities(sectionId: Long): Sequence = studyPlanSections[sectionId] ?.studyPlanSection ?.activities - ?.mapNotNull { id -> activities[id] } ?: emptyList() + ?.asSequence() + ?.mapNotNull { id -> activities[id] } + ?: emptySequence() + +/** + * @param sectionId target section id. + * @param activityId target activity id in the section with id = [sectionId] + * + * @return true in case of MobileContentTrial subscription and + * this activity number is bigger then the free topics count. + * Otherwise returns false (activity with [activityId] is unlocked). + */ +internal fun StudyPlanWidgetFeature.State.isActivityLocked( + sectionId: Long, + activityId: Long, +): Boolean { + val unlockedActivitiesCount = getUnlockedActivitiesCount(sectionId) + return unlockedActivitiesCount != null && + getSectionActivities(sectionId) + .take(unlockedActivitiesCount) + .map { it.id } + .contains(activityId) + .not() +} /** - * Returns unlocked activities ids list in case of MobileContentTrial subscription. + * @param sectionId target sectionId. + * + * @return unlocked activities count for [sectionId] in case of MobileContentTrial subscription. * Otherwise returns null (all the activities are unlocked). */ -internal fun StudyPlanWidgetFeature.State.getUnlockedActivitiesIds(sectionId: Long): List? { +internal fun StudyPlanWidgetFeature.State.getUnlockedActivitiesCount(sectionId: Long): Int? { val section = studyPlanSections[sectionId]?.studyPlanSection ?: return null val isRootTopicsSection = section.type == StudyPlanSectionType.ROOT_TOPICS val isTopicsLimitEnabled = @@ -59,12 +87,18 @@ internal fun StudyPlanWidgetFeature.State.getUnlockedActivitiesIds(sectionId: Lo ) == SubscriptionLimitType.TOPICS val unlockedActivitiesCount = profile?.feautureValues?.mobileContentTrialFreeTopics?.minus(learnedTopicsCount) return if (isRootTopicsSection && isTopicsLimitEnabled && unlockedActivitiesCount != null) { - section.activities.take(unlockedActivitiesCount) + unlockedActivitiesCount } else { null } } +/** + * @return true in case of MobileContentTrial subscription, + * only on root topics section and + * all the free topics are solved. + * Otherwise returns false. + */ internal fun StudyPlanWidgetFeature.State.isPaywallShown(): Boolean { val rootTopicsSections = studyPlanSections @@ -75,8 +109,8 @@ internal fun StudyPlanWidgetFeature.State.isPaywallShown(): Boolean { val hasOnlyOneRootTopicSection = rootTopicsSections.count() == 1 return if (hasOnlyOneRootTopicSection) { val rootTopicsSectionId = rootTopicsSections.first().studyPlanSection.id - val unlockedActivitiesIds = getUnlockedActivitiesIds(rootTopicsSectionId) - unlockedActivitiesIds?.isEmpty() == true + val unlockedActivitiesCount = getUnlockedActivitiesCount(rootTopicsSectionId) + unlockedActivitiesCount == 0 } else { false } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StudyPlanWidgetReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StudyPlanWidgetReducer.kt index 92e132b1fa..d326d8f4f1 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StudyPlanWidgetReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StudyPlanWidgetReducer.kt @@ -368,7 +368,7 @@ class StudyPlanWidgetReducer : StateReducer { private fun handleActivityClicked(state: State, message: Message.ActivityClicked): StudyPlanWidgetReducerResult { val activity = state.activities[message.activityId] ?: return state to emptySet() - val isActivityLocked = isActivityLocked(state, message.activityId, message.sectionId) + val isActivityLocked = state.isActivityLocked(message.sectionId, message.activityId) val logAnalyticEventAction = InternalAction.LogAnalyticEvent( StudyPlanClickedActivityHyperskillAnalyticEvent( @@ -399,11 +399,6 @@ class StudyPlanWidgetReducer : StateReducer { return state to setOf(activityTargetAction, logAnalyticEventAction) } - private fun isActivityLocked(state: State, activityId: Long, sectionId: Long): Boolean { - val unlockedActivitiesIds = state.getUnlockedActivitiesIds(sectionId) - return unlockedActivitiesIds != null && activityId !in unlockedActivitiesIds - } - private fun handleSubscribeClicked(state: State): StudyPlanWidgetReducerResult = if (state.isPaywallShown()) { state to setOf( diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/view/mapper/StudyPlanWidgetViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/view/mapper/StudyPlanWidgetViewStateMapper.kt index 9bf4ae7c6c..57825a23d2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/view/mapper/StudyPlanWidgetViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/view/mapper/StudyPlanWidgetViewStateMapper.kt @@ -9,7 +9,7 @@ import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature import org.hyperskill.app.study_plan.widget.presentation.getCurrentActivity import org.hyperskill.app.study_plan.widget.presentation.getCurrentSection import org.hyperskill.app.study_plan.widget.presentation.getSectionActivities -import org.hyperskill.app.study_plan.widget.presentation.getUnlockedActivitiesIds +import org.hyperskill.app.study_plan.widget.presentation.getUnlockedActivitiesCount import org.hyperskill.app.study_plan.widget.presentation.isPaywallShown import org.hyperskill.app.study_plan.widget.view.model.StudyPlanWidgetViewState import org.hyperskill.app.study_plan.widget.view.model.StudyPlanWidgetViewState.SectionContent @@ -73,44 +73,54 @@ class StudyPlanWidgetViewStateMapper(private val dateFormatter: SharedDateFormat if (sectionInfo.isExpanded) { when (sectionInfo.contentStatus) { StudyPlanWidgetFeature.ContentStatus.IDLE -> SectionContent.Collapsed + StudyPlanWidgetFeature.ContentStatus.ERROR -> SectionContent.Error StudyPlanWidgetFeature.ContentStatus.LOADING -> { - val activities = state.getSectionActivities(sectionInfo.studyPlanSection.id) - if (activities.isEmpty()) { - SectionContent.Loading - } else { - getContent( - activities = activities, - currentActivityId = currentActivityId, - unlockedActivitiesIds = state.getUnlockedActivitiesIds(sectionInfo.studyPlanSection.id) - ) - } + getContent( + state = state, + sectionInfo = sectionInfo, + currentActivityId = currentActivityId, + emptyActivitiesState = SectionContent.Loading + ) } - StudyPlanWidgetFeature.ContentStatus.ERROR -> SectionContent.Error StudyPlanWidgetFeature.ContentStatus.LOADED -> { - val activities = state.getSectionActivities(sectionInfo.studyPlanSection.id) - if (activities.isNotEmpty()) { - getContent( - activities = activities, - currentActivityId = currentActivityId, - unlockedActivitiesIds = state.getUnlockedActivitiesIds(sectionInfo.studyPlanSection.id) - ) - } else { - SectionContent.Error - } + getContent( + state = state, + sectionInfo = sectionInfo, + currentActivityId = currentActivityId, + emptyActivitiesState = SectionContent.Error + ) } } } else { SectionContent.Collapsed } + private fun getContent( + state: StudyPlanWidgetFeature.State, + sectionInfo: StudyPlanWidgetFeature.StudyPlanSectionInfo, + currentActivityId: Long?, + emptyActivitiesState: SectionContent + ): SectionContent { + val activities = state.getSectionActivities(sectionInfo.studyPlanSection.id).toList() + return if (activities.isEmpty()) { + emptyActivitiesState + } else { + getContent( + activities = activities, + currentActivityId = currentActivityId, + unlockedActivitiesCount = state.getUnlockedActivitiesCount(sectionInfo.studyPlanSection.id) + ) + } + } + private fun getContent( activities: List, currentActivityId: Long?, - unlockedActivitiesIds: List? + unlockedActivitiesCount: Int? ): SectionContent.Content = SectionContent.Content( - sectionItems = activities.map { activity -> - val isLocked = unlockedActivitiesIds != null && activity.id !in unlockedActivitiesIds + sectionItems = activities.mapIndexed { index, activity -> + val isLocked = unlockedActivitiesCount != null && index + 1 > unlockedActivitiesCount StudyPlanWidgetViewState.SectionItem( id = activity.id, title = LearningActivityTextsMapper.mapLearningActivityToTitle(activity),