diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt index a59bd87617..7069d3ca01 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt @@ -89,4 +89,6 @@ enum class HyperskillAnalyticTarget(val targetName: String) { GO_TO_STUDY_PLAN("go_to_study_plan"), CHANGE_TRACK("change_track"), CHANGE_PROJECT("change_project"), + BADGES_VISIBILITY_BUTTON("badges_visibility_button"), + BADGE_CARD("badges_card") } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/badges/data/repository/BadgesRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/data/repository/BadgesRepositoryImpl.kt new file mode 100644 index 0000000000..cdcfdac177 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/data/repository/BadgesRepositoryImpl.kt @@ -0,0 +1,12 @@ +package org.hyperskill.app.badges.data.repository + +import org.hyperskill.app.badges.data.source.BadgesRemoteDataSource +import org.hyperskill.app.badges.domain.model.Badge +import org.hyperskill.app.badges.domain.repository.BadgesRepository + +internal class BadgesRepositoryImpl( + private val remoteDataSource: BadgesRemoteDataSource +) : BadgesRepository { + override suspend fun getReceivedBadges(): Result> = + remoteDataSource.getReceivedBadges() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/badges/data/source/BadgesRemoteDataSource.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/data/source/BadgesRemoteDataSource.kt new file mode 100644 index 0000000000..1b81556e57 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/data/source/BadgesRemoteDataSource.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.badges.data.source + +import org.hyperskill.app.badges.domain.model.Badge + +interface BadgesRemoteDataSource { + suspend fun getReceivedBadges(): Result> +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/badges/domain/model/Badge.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/domain/model/Badge.kt new file mode 100644 index 0000000000..270c2028cd --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/domain/model/Badge.kt @@ -0,0 +1,30 @@ +package org.hyperskill.app.badges.domain.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Badge( + @SerialName("id") + val id: Long, + @SerialName("kind") + val kind: BadgeKind = BadgeKind.UNKNOWN, + @SerialName("title") + val title: String, + @SerialName("level") + val level: Int, + @SerialName("value") + val value: Int, + @SerialName("current_level_value") + val currentLevelValue: Int, + @SerialName("next_level_value") + val nextLevelValue: Int? = null, + @SerialName("is_max_level") + val isMaxLevel: Boolean, + @SerialName("image_full") + val imageFull: String, + @SerialName("image_preview") + val imagePreview: String, + @SerialName("rank") + val rank: BadgeRank = BadgeRank.UNKNOWN +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/badges/domain/model/BadgeKind.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/domain/model/BadgeKind.kt new file mode 100644 index 0000000000..98e752eefd --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/domain/model/BadgeKind.kt @@ -0,0 +1,27 @@ +package org.hyperskill.app.badges.domain.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// The order is important, don't change it! +@Serializable +enum class BadgeKind { + @SerialName("project master") + ProjectMaster, + @SerialName("topic master") + TopicMaster, + @SerialName("committed learner") + CommittedLearner, + @SerialName("brilliant mind") + BrilliantMind, + @SerialName("helping hand") + HelpingHand, + @SerialName("sweetheart") + Sweetheart, + @SerialName("benefactor") + Benefactor, + @SerialName("bounty hunter") + BountyHunter, + + UNKNOWN +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/badges/domain/model/BadgeRank.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/domain/model/BadgeRank.kt new file mode 100644 index 0000000000..7d202b8f30 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/domain/model/BadgeRank.kt @@ -0,0 +1,18 @@ +package org.hyperskill.app.badges.domain.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class BadgeRank { + @SerialName("Apprentice") + APPRENTICE, + @SerialName("Expert") + EXPERT, + @SerialName("Master") + MASTER, + @SerialName("Legendary") + LEGENDARY, + + UNKNOWN +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/badges/domain/repository/BadgesRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/domain/repository/BadgesRepository.kt new file mode 100644 index 0000000000..d489ec7fdc --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/domain/repository/BadgesRepository.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.badges.domain.repository + +import org.hyperskill.app.badges.domain.model.Badge + +interface BadgesRepository { + suspend fun getReceivedBadges(): Result> +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/badges/injection/BadgesDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/injection/BadgesDataComponent.kt new file mode 100644 index 0000000000..2cdf1b2015 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/injection/BadgesDataComponent.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.badges.injection + +import org.hyperskill.app.badges.domain.repository.BadgesRepository + +interface BadgesDataComponent { + val badgesRepository: BadgesRepository +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/badges/injection/BadgesDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/injection/BadgesDataComponentImpl.kt new file mode 100644 index 0000000000..e6d2d8d1ac --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/injection/BadgesDataComponentImpl.kt @@ -0,0 +1,18 @@ +package org.hyperskill.app.badges.injection + +import org.hyperskill.app.badges.data.repository.BadgesRepositoryImpl +import org.hyperskill.app.badges.data.source.BadgesRemoteDataSource +import org.hyperskill.app.badges.domain.repository.BadgesRepository +import org.hyperskill.app.badges.remote.BadgesRemoteDataSourceImpl +import org.hyperskill.app.core.injection.AppGraph + +internal class BadgesDataComponentImpl( + appGraph: AppGraph +) : BadgesDataComponent { + + private val badgesRemoteDataSource: BadgesRemoteDataSource = + BadgesRemoteDataSourceImpl(appGraph.networkComponent.authorizedHttpClient) + + override val badgesRepository: BadgesRepository + get() = BadgesRepositoryImpl(badgesRemoteDataSource) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/badges/remote/BadgesRemoteDataSourceImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/remote/BadgesRemoteDataSourceImpl.kt new file mode 100644 index 0000000000..5e22bd2b58 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/remote/BadgesRemoteDataSourceImpl.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.badges.remote + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.http.ContentType +import io.ktor.http.contentType +import org.hyperskill.app.badges.data.source.BadgesRemoteDataSource +import org.hyperskill.app.badges.domain.model.Badge + +class BadgesRemoteDataSourceImpl( + private val httpClient: HttpClient +) : BadgesRemoteDataSource { + override suspend fun getReceivedBadges(): Result> = + runCatching { + httpClient.get("/api/badges") { + contentType(ContentType.Application.Json) + }.body().badges + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/badges/remote/BadgesResponse.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/remote/BadgesResponse.kt new file mode 100644 index 0000000000..84c9b38086 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/badges/remote/BadgesResponse.kt @@ -0,0 +1,15 @@ +package org.hyperskill.app.badges.remote + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.hyperskill.app.badges.domain.model.Badge +import org.hyperskill.app.core.remote.Meta +import org.hyperskill.app.core.remote.MetaResponse + +@Serializable +class BadgesResponse( + @SerialName("meta") + override val meta: Meta, + @SerialName("badges") + val badges: List +) : MetaResponse \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt index f5b1c295fd..e1b4cf7251 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt @@ -4,6 +4,7 @@ import org.hyperskill.app.analytic.injection.AnalyticComponent import org.hyperskill.app.auth.injection.AuthComponent import org.hyperskill.app.auth.injection.AuthCredentialsComponent import org.hyperskill.app.auth.injection.AuthSocialComponent +import org.hyperskill.app.badges.injection.BadgesDataComponent import org.hyperskill.app.comments.injection.CommentsDataComponent import org.hyperskill.app.debug.injection.DebugComponent import org.hyperskill.app.devices.injection.DevicesDataComponent @@ -145,4 +146,6 @@ interface AppGraph { fun buildPushNotificationsComponent(): PushNotificationsComponent fun buildClickedNotificationComponent(): NotificationClickHandlingComponent fun buildProgressScreenComponent(): ProgressScreenComponent + + fun buildBadgesDataComponent(): BadgesDataComponent } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt index e8cfde1ffa..e01fe32aec 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt @@ -8,6 +8,8 @@ import org.hyperskill.app.auth.injection.AuthCredentialsComponent import org.hyperskill.app.auth.injection.AuthCredentialsComponentImpl import org.hyperskill.app.auth.injection.AuthSocialComponent import org.hyperskill.app.auth.injection.AuthSocialComponentImpl +import org.hyperskill.app.badges.injection.BadgesDataComponent +import org.hyperskill.app.badges.injection.BadgesDataComponentImpl import org.hyperskill.app.comments.injection.CommentsDataComponent import org.hyperskill.app.comments.injection.CommentsDataComponentImpl import org.hyperskill.app.debug.injection.DebugComponent @@ -419,4 +421,7 @@ abstract class BaseAppGraph : AppGraph { override fun buildProgressScreenComponent(): ProgressScreenComponent = ProgressScreenComponentImpl(this) + + override fun buildBadgesDataComponent(): BadgesDataComponent = + BadgesDataComponentImpl(this) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgeCardHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgeCardHyperskillAnalyticsEvent.kt new file mode 100644 index 0000000000..7c0780cea3 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgeCardHyperskillAnalyticsEvent.kt @@ -0,0 +1,49 @@ +package org.hyperskill.app.profile.domain.analytic.badges + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget +import org.hyperskill.app.badges.domain.model.BadgeKind + +/** + * Represents click on the badge in profile analytics event. + * + * JSON payload: + * ``` + * { + * "route": "/profile", + * "action": "click", + * "part": "main", + * "target": "badge_card", + * "context": { + * "badge_kind": "Project Mastery", + * "is_locked": true + * } + * } + * ``` + * @see HyperskillAnalyticEvent + */ +class ProfileClickedBadgeCardHyperskillAnalyticsEvent( + private val badgeKind: BadgeKind, + private val isLocked: Boolean +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Profile(), + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.BADGE_CARD +) { + companion object { + private const val PARAM_BADGE_KIND = "badge_kind" + private const val PARAM_LOCKED = "is_locked" + } + + override val params: Map + get() = super.params + mapOf( + PARAM_CONTEXT to mapOf( + PARAM_BADGE_KIND to badgeKind.name, + PARAM_LOCKED to isLocked + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgesVisibilityButtonHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgesVisibilityButtonHyperskillAnalyticsEvent.kt new file mode 100644 index 0000000000..fa9994c6c8 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgesVisibilityButtonHyperskillAnalyticsEvent.kt @@ -0,0 +1,51 @@ +package org.hyperskill.app.profile.domain.analytic.badges + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget +import org.hyperskill.app.profile.presentation.ProfileFeature + +/** + * Represents click on the showAll or showLess badges button in profile analytics event. + * + * JSON payload: + * ``` + * { + * "route": "/profile", + * "action": "click", + * "part": "main", + * "target": "badges_visibility_button", + * "context": { + * button: "show_all" + * } + * } + * ``` + * @see HyperskillAnalyticEvent + */ +class ProfileClickedBadgesVisibilityButtonHyperskillAnalyticsEvent( + private val visibilityButton: ProfileFeature.Message.BadgesVisibilityButton +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Profile(), + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.BADGES_VISIBILITY_BUTTON +) { + + companion object { + private const val PARAM_BUTTON = "button" + private const val SHOW_ALL = "show_all" + private const val SHOW_LESS = "show_less" + } + + override val params: Map + get() = super.params + mapOf( + PARAM_CONTEXT to mapOf( + PARAM_BUTTON to when (visibilityButton) { + ProfileFeature.Message.BadgesVisibilityButton.SHOW_ALL -> SHOW_ALL + ProfileFeature.Message.BadgesVisibilityButton.SHOW_LESS -> SHOW_LESS + } + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileComponent.kt index dc2e8250f8..b768ec5e38 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileComponent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileComponent.kt @@ -1,8 +1,10 @@ package org.hyperskill.app.profile.injection import org.hyperskill.app.profile.presentation.ProfileFeature +import org.hyperskill.app.profile.view.BadgesViewStateMapper import ru.nobird.app.presentation.redux.feature.Feature interface ProfileComponent { val profileFeature: Feature + val badgesViewStateMapper: BadgesViewStateMapper } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileComponentImpl.kt index 47211b665a..d0cf0ae6ff 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileComponentImpl.kt @@ -2,6 +2,7 @@ package org.hyperskill.app.profile.injection import org.hyperskill.app.core.injection.AppGraph import org.hyperskill.app.profile.presentation.ProfileFeature +import org.hyperskill.app.profile.view.BadgesViewStateMapper import ru.nobird.app.presentation.redux.feature.Feature class ProfileComponentImpl(private val appGraph: AppGraph) : ProfileComponent { @@ -16,6 +17,10 @@ class ProfileComponentImpl(private val appGraph: AppGraph) : ProfileComponent { appGraph.buildNotificationComponent().notificationInteractor, appGraph.buildMagicLinksDataComponent().urlPathProcessor, appGraph.streakFlowDataComponent.streakFlow, - appGraph.notificationFlowDataComponent.dailyStudyRemindersEnabledFlow + appGraph.notificationFlowDataComponent.dailyStudyRemindersEnabledFlow, + appGraph.buildBadgesDataComponent().badgesRepository ) + + override val badgesViewStateMapper: BadgesViewStateMapper + get() = BadgesViewStateMapper(appGraph.commonComponent.resourceProvider) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileFeatureBuilder.kt index 594c31fdc9..1350dfb7a6 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileFeatureBuilder.kt @@ -1,6 +1,7 @@ package org.hyperskill.app.profile.injection import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.badges.domain.repository.BadgesRepository import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.magic_links.domain.interactor.UrlPathProcessor import org.hyperskill.app.notification.local.domain.flow.DailyStudyRemindersEnabledFlow @@ -31,7 +32,8 @@ object ProfileFeatureBuilder { notificationInteractor: NotificationInteractor, urlPathProcessor: UrlPathProcessor, streakFlow: StreakFlow, - dailyStudyRemindersEnabledFlow: DailyStudyRemindersEnabledFlow + dailyStudyRemindersEnabledFlow: DailyStudyRemindersEnabledFlow, + badgesRepository: BadgesRepository ): Feature { val profileReducer = ProfileReducer() val profileActionDispatcher = ProfileActionDispatcher( @@ -45,7 +47,8 @@ object ProfileFeatureBuilder { notificationInteractor, urlPathProcessor, streakFlow, - dailyStudyRemindersEnabledFlow + dailyStudyRemindersEnabledFlow, + badgesRepository ) return ReduxFeature(State.Idle, profileReducer) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileActionDispatcher.kt index d6ee0c5e12..0f7697a743 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileActionDispatcher.kt @@ -1,10 +1,12 @@ package org.hyperskill.app.profile.presentation import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.badges.domain.repository.BadgesRepository import org.hyperskill.app.core.domain.repository.updateState import org.hyperskill.app.core.domain.url.HyperskillUrlPath import org.hyperskill.app.core.presentation.ActionDispatcherOptions @@ -36,7 +38,8 @@ class ProfileActionDispatcher( private val notificationInteractor: NotificationInteractor, private val urlPathProcessor: UrlPathProcessor, private val streakFlow: StreakFlow, - dailyStudyRemindersEnabledFlow: DailyStudyRemindersEnabledFlow + dailyStudyRemindersEnabledFlow: DailyStudyRemindersEnabledFlow, + private val badgesRepository: BadgesRepository ) : CoroutineActionDispatcher(config.createConfig()) { init { @@ -70,37 +73,17 @@ class ProfileActionDispatcher( val sentryTransaction = HyperskillSentryTransactionBuilder.buildProfileScreenRemoteDataLoading() sentryInteractor.startTransaction(sentryTransaction) - val currentProfile = currentProfileStateRepository - .getState(forceUpdate = true) - .getOrElse { - sentryInteractor.finishTransaction(sentryTransaction, throwable = it) - return onNewMessage(Message.ProfileFetchResult.Error) - } - - val streakResult = actionScope.async { streaksInteractor.getUserStreak(currentProfile.id) } - val streakFreezeProductResult = actionScope.async { productsInteractor.getStreakFreezeProduct() } - - val streak = streakResult.await().getOrElse { + val profileResult = fetchProfileData().getOrElse { sentryInteractor.finishTransaction(sentryTransaction, throwable = it) - return onNewMessage(Message.ProfileFetchResult.Error) + onNewMessage(Message.ProfileFetchResult.Error) + return } - val streakFreezeProduct = streakFreezeProductResult.await().getOrNull() sentryInteractor.finishTransaction(sentryTransaction) - streakFlow.notifyDataChanged(streak) - - onNewMessage( - Message.ProfileFetchResult.Success( - profile = currentProfile, - streak = streak, - streakFreezeState = getStreakFreezeState(streakFreezeProduct, streak), - dailyStudyRemindersState = ProfileFeature.DailyStudyRemindersState( - isEnabled = notificationInteractor.isDailyStudyRemindersEnabled(), - startHour = notificationInteractor.getDailyStudyRemindersIntervalStartHour() - ) - ) - ) + streakFlow.notifyDataChanged(profileResult.streak) + + onNewMessage(profileResult) } is Action.BuyStreakFreeze -> { productsInteractor.buyStreakFreeze(action.streakFreezeProductId) @@ -144,6 +127,35 @@ class ProfileActionDispatcher( } ) + private suspend fun fetchProfileData(): Result = + runCatching { + coroutineScope { + val currentProfile = + currentProfileStateRepository + .getState(forceUpdate = true) + .getOrThrow() + + val streakResult = async { streaksInteractor.getUserStreak(currentProfile.id) } + val streakFreezeProductResult = async { productsInteractor.getStreakFreezeProduct() } + val badgesDeferred = async { badgesRepository.getReceivedBadges() } + + val streak = streakResult.await().getOrThrow() + val streakFreezeProduct = streakFreezeProductResult.await().getOrNull() + val badges = badgesDeferred.await().getOrThrow() + + Message.ProfileFetchResult.Success( + profile = currentProfile, + streak = streak, + streakFreezeState = getStreakFreezeState(streakFreezeProduct, streak), + dailyStudyRemindersState = ProfileFeature.DailyStudyRemindersState( + isEnabled = notificationInteractor.isDailyStudyRemindersEnabled(), + startHour = notificationInteractor.getDailyStudyRemindersIntervalStartHour() + ), + badges = badges + ) + } + } + private fun getStreakFreezeState( streakFreezeProduct: Product?, streak: Streak? diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileFeature.kt index e6cc5c0c9f..81fd2818e8 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileFeature.kt @@ -2,6 +2,8 @@ package org.hyperskill.app.profile.presentation import kotlinx.serialization.Serializable import org.hyperskill.app.analytic.domain.model.AnalyticEvent +import org.hyperskill.app.badges.domain.model.Badge +import org.hyperskill.app.badges.domain.model.BadgeKind import org.hyperskill.app.core.domain.url.HyperskillUrlPath import org.hyperskill.app.profile.domain.model.Profile import org.hyperskill.app.streaks.domain.model.Streak @@ -33,6 +35,7 @@ interface ProfileFeature { val streak: Streak?, val streakFreezeState: StreakFreezeState?, val dailyStudyRemindersState: DailyStudyRemindersState, + val badgesState: BadgesState, val isRefreshing: Boolean = false, val isLoadingMagicLink: Boolean = false ) : State @@ -89,6 +92,8 @@ interface ProfileFeature { object AlreadyHave : StreakFreezeState } + data class BadgesState(val isExpanded: Boolean, val badges: List) + sealed interface Message { data class Initialize( val isInitCurrent: Boolean = true, @@ -116,7 +121,8 @@ interface ProfileFeature { val profile: Profile, val streak: Streak?, val streakFreezeState: StreakFreezeState?, - val dailyStudyRemindersState: DailyStudyRemindersState + val dailyStudyRemindersState: DailyStudyRemindersState, + val badges: List ) : ProfileFetchResult /** @@ -160,6 +166,16 @@ interface ProfileFeature { data class DailyStudyRemindersIntervalStartHourChanged(val startHour: Int) : Message data class DailyStudyRemindersIsEnabledChanged(val isEnabled: Boolean) : Message + /** + * Badges + */ + data class BadgesVisibilityButtonClicked(val visibilityButton: BadgesVisibilityButton) : Message + enum class BadgesVisibilityButton { + SHOW_ALL, + SHOW_LESS + } + data class BadgeClicked(val badgeKind: BadgeKind) : Message + /** * Flow messages. */ diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileReducer.kt index def1662711..2128530b8d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileReducer.kt @@ -7,6 +7,8 @@ import org.hyperskill.app.profile.domain.analytic.ProfileClickedPullToRefreshHyp import org.hyperskill.app.profile.domain.analytic.ProfileClickedSettingsHyperskillAnalyticEvent import org.hyperskill.app.profile.domain.analytic.ProfileClickedViewFullProfileHyperskillAnalyticEvent import org.hyperskill.app.profile.domain.analytic.ProfileViewedHyperskillAnalyticEvent +import org.hyperskill.app.profile.domain.analytic.badges.ProfileClickedBadgeCardHyperskillAnalyticsEvent +import org.hyperskill.app.profile.domain.analytic.badges.ProfileClickedBadgesVisibilityButtonHyperskillAnalyticsEvent import org.hyperskill.app.profile.domain.analytic.streak_freeze.StreakFreezeAnalyticState import org.hyperskill.app.profile.domain.analytic.streak_freeze.StreakFreezeCardAnalyticAction import org.hyperskill.app.profile.domain.analytic.streak_freeze.StreakFreezeClickedCardActionHyperskillAnalyticEvent @@ -19,8 +21,10 @@ import org.hyperskill.app.profile.presentation.ProfileFeature.Message import org.hyperskill.app.profile.presentation.ProfileFeature.State import ru.nobird.app.presentation.redux.reducer.StateReducer +private typealias ReducerResult = Pair> + class ProfileReducer : StateReducer { - override fun reduce(state: State, message: Message): Pair> = + override fun reduce(state: State, message: Message): ReducerResult = when (message) { is Message.Initialize -> { if (state is State.Idle || @@ -37,10 +41,14 @@ class ProfileReducer : StateReducer { } is Message.ProfileFetchResult.Success -> State.Content( - message.profile, - message.streak, - message.streakFreezeState, - message.dailyStudyRemindersState + profile = message.profile, + streak = message.streak, + streakFreezeState = message.streakFreezeState, + dailyStudyRemindersState = message.dailyStudyRemindersState, + badgesState = ProfileFeature.BadgesState( + badges = message.badges, + isExpanded = false + ) ) to emptySet() is Message.ProfileFetchResult.Error -> State.Error to emptySet() @@ -222,6 +230,8 @@ class ProfileReducer : StateReducer { } else { null } + is Message.BadgesVisibilityButtonClicked -> handleBadgesVisibilityButtonClicked(state, message) + is Message.BadgeClicked -> handleBadgeClicked(state, message) is Message.ViewedEventMessage -> state to setOf(Action.LogAnalyticEvent(ProfileViewedHyperskillAnalyticEvent())) is Message.ClickedSettingsEventMessage -> @@ -254,4 +264,40 @@ class ProfileReducer : StateReducer { is ProfileFeature.StreakFreezeState.CanBuy -> StreakFreezeAnalyticState.CAN_BUY is ProfileFeature.StreakFreezeState.NotEnoughGems -> StreakFreezeAnalyticState.NOT_ENOUGH_GEMS } + + private fun handleBadgesVisibilityButtonClicked( + state: State, + message: Message.BadgesVisibilityButtonClicked + ): ReducerResult = + if (state is State.Content) { + val badgesState = state.badgesState + state.copy( + badgesState = badgesState.copy( + isExpanded = message.visibilityButton == Message.BadgesVisibilityButton.SHOW_ALL + ) + ) to setOf( + Action.LogAnalyticEvent( + ProfileClickedBadgesVisibilityButtonHyperskillAnalyticsEvent(message.visibilityButton) + ) + ) + } else { + state to emptySet() + } + + private fun handleBadgeClicked( + state: State, + message: Message.BadgeClicked + ): ReducerResult = + if (state is State.Content) { + state to setOf( + Action.LogAnalyticEvent( + ProfileClickedBadgeCardHyperskillAnalyticsEvent( + badgeKind = message.badgeKind, + isLocked = message.badgeKind !in state.badgesState.badges.map { it.kind } + ) + ) + ) + } else { + state to emptySet() + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/view/BadgesViewState.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/view/BadgesViewState.kt new file mode 100644 index 0000000000..c7010dee0f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/view/BadgesViewState.kt @@ -0,0 +1,32 @@ +package org.hyperskill.app.profile.view + +import org.hyperskill.app.badges.domain.model.BadgeKind + +data class BadgesViewState( + val badges: List, + val isExpanded: Boolean +) { + /** + * Represent a badge in profile. + * + * [progress] is progress on the way to the next level. It is a float value from 0f to 1f. + * [nextLevel] represents a next level number. If null, then this level is the max one. + */ + data class BadgeViewState( + val kind: BadgeKind, + val title: String, + val image: BadgeImage, + val formattedCurrentLevel: String, + val nextLevel: Int?, + val progress: Float + ) + + sealed interface BadgeImage { + object Locked : BadgeImage + + data class Remote( + val fullSource: String, + val previewSource: String + ) : BadgeImage + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/view/BadgesViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/view/BadgesViewStateMapper.kt new file mode 100644 index 0000000000..eae22dbbad --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/view/BadgesViewStateMapper.kt @@ -0,0 +1,88 @@ +package org.hyperskill.app.profile.view + +import org.hyperskill.app.SharedResources +import org.hyperskill.app.badges.domain.model.Badge +import org.hyperskill.app.badges.domain.model.BadgeKind +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.profile.presentation.ProfileFeature + +class BadgesViewStateMapper( + private val resourceProvider: ResourceProvider +) { + fun map(state: ProfileFeature.BadgesState): BadgesViewState { + val unlockedBadges = state.badges.sortedBy { it.level }.map(::mapUnlockedBadge) + return if (state.isExpanded) { + val lockedBadgeKinds = getLockedBadgeKinds(state.badges.map { it.kind }) + val lockedBadges = lockedBadgeKinds.map(::mapLockedBadge) + BadgesViewState( + badges = lockedBadges, + isExpanded = state.isExpanded + ) + } else { + BadgesViewState( + badges = unlockedBadges, + isExpanded = state.isExpanded + ) + } + } + + private fun getLockedBadgeKinds(unlockedBadgeKinds: List): Set = + BadgeKind.values().subtract(unlockedBadgeKinds.toSet()) + + private fun mapUnlockedBadge(badge: Badge): BadgesViewState.BadgeViewState = + BadgesViewState.BadgeViewState( + kind = badge.kind, + title = badge.title, + image = BadgesViewState.BadgeImage.Remote( + fullSource = badge.imageFull, + previewSource = badge.imagePreview + ), + formattedCurrentLevel = resourceProvider.getString( + if (badge.isMaxLevel) { + SharedResources.strings.badge_max_level + } else { + SharedResources.strings.badge_level + }, + badge.level + ), + nextLevel = if (badge.isMaxLevel) null else badge.level + 1, + progress = if (badge.nextLevelValue != null && badge.isMaxLevel) { + val totalCount = badge.nextLevelValue - badge.currentLevelValue + val currentCount = badge.value - badge.currentLevelValue + currentCount / totalCount.toFloat() + } else { + 1f + } + ) + + private fun mapLockedBadge(badgeKind: BadgeKind): BadgesViewState.BadgeViewState = + BadgesViewState.BadgeViewState( + kind = badgeKind, + title = getBadgeTitle(badgeKind), + image = BadgesViewState.BadgeImage.Locked, + formattedCurrentLevel = resourceProvider.getString(SharedResources.strings.badge_level, 0), + nextLevel = 1, + progress = 0f + ) + + private fun getBadgeTitle(badgeKind: BadgeKind): String = + when (badgeKind) { + BadgeKind.ProjectMaster -> + resourceProvider.getString(SharedResources.strings.badge_project_mastery_title) + BadgeKind.TopicMaster -> + resourceProvider.getString(SharedResources.strings.badge_project_topic_mastery) + BadgeKind.CommittedLearner -> + resourceProvider.getString(SharedResources.strings.badge_project_committed_learning) + BadgeKind.BrilliantMind -> + resourceProvider.getString(SharedResources.strings.badge_project_brilliant_mind) + BadgeKind.HelpingHand -> + resourceProvider.getString(SharedResources.strings.badge_project_helping_hand) + BadgeKind.Sweetheart -> + resourceProvider.getString(SharedResources.strings.badge_project_sweetheart) + BadgeKind.Benefactor -> + resourceProvider.getString(SharedResources.strings.badge_project_benefactor) + BadgeKind.BountyHunter -> + resourceProvider.getString(SharedResources.strings.badge_project_bounty_hunter) + BadgeKind.UNKNOWN -> "" + } +} \ No newline at end of file diff --git a/shared/src/commonMain/resources/MR/base/strings.xml b/shared/src/commonMain/resources/MR/base/strings.xml index 8cd59eee04..ba13071685 100644 --- a/shared/src/commonMain/resources/MR/base/strings.xml +++ b/shared/src/commonMain/resources/MR/base/strings.xml @@ -466,4 +466,16 @@ Congratulations! Your project is completed! You did a great job! gems for completing the whole project + + + Project Mastery + Topic Mastery + Committed Learning + Brilliant Mind + Helping Hand + Sweetheart + Benefactor + Bounty Hunter + Level %d + Level %d (Max) \ No newline at end of file