diff --git a/.github/workflows/automerge_into_release.yml b/.github/workflows/automerge_into_release.yml index 1cbd4c8fbd..81c6f1e032 100644 --- a/.github/workflows/automerge_into_release.yml +++ b/.github/workflows/automerge_into_release.yml @@ -48,7 +48,7 @@ jobs: set -e echo "Getting latest tag without v* prefix..." - current_version_number=$(git describe --tags --abbrev=0 | cut -c 2-) + current_version_number=$(git tag --sort=committerdate | tail -1 | cut -c 2-) echo "Current version number is $current_version_number" next_version_number=$(echo $current_version_number | awk -F. -v OFS=. '{$NF++;print}') diff --git a/androidHyperskillApp/Gemfile b/androidHyperskillApp/Gemfile index adb9410cdd..c1aa4e5842 100644 --- a/androidHyperskillApp/Gemfile +++ b/androidHyperskillApp/Gemfile @@ -1,6 +1,6 @@ source "https://rubygems.org" ruby "3.1.0" -gem "fastlane", "2.216.0" +gem "fastlane", "2.217.0" eval_gemfile("fastlane/Pluginfile") diff --git a/androidHyperskillApp/Gemfile.lock b/androidHyperskillApp/Gemfile.lock index a13a964d55..a51d73282c 100644 --- a/androidHyperskillApp/Gemfile.lock +++ b/androidHyperskillApp/Gemfile.lock @@ -8,20 +8,20 @@ GEM artifactory (3.0.15) atomos (0.1.3) aws-eventstream (1.2.0) - aws-partitions (1.826.0) - aws-sdk-core (3.183.0) + aws-partitions (1.853.0) + aws-sdk-core (3.187.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.71.0) - aws-sdk-core (~> 3, >= 3.177.0) + aws-sdk-kms (1.72.0) + aws-sdk-core (~> 3, >= 3.184.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.135.0) + aws-sdk-s3 (1.137.0) aws-sdk-core (~> 3, >= 3.181.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.6) - aws-sigv4 (1.6.0) + aws-sigv4 (1.6.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) claide (1.1.0) @@ -32,11 +32,10 @@ GEM declarative (0.0.20) digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20231109) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.103.0) + excon (0.104.0) faraday (1.10.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -66,7 +65,7 @@ GEM faraday_middleware (1.2.0) faraday (~> 1.0) fastimage (2.2.7) - fastlane (2.216.0) + fastlane (2.217.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -106,12 +105,12 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) - fastlane-plugin-firebase_app_distribution (0.7.3) + fastlane-plugin-firebase_app_distribution (0.7.4) google-apis-firebaseappdistribution_v1 (~> 0.3.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.49.0) + google-apis-androidpublisher_v3 (0.52.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.1) + google-apis-core (0.11.2) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -126,19 +125,19 @@ GEM google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.19.0) - google-apis-core (>= 0.9.0, < 2.a) + google-apis-storage_v1 (0.29.0) + google-apis-core (>= 0.11.0, < 2.a) google-cloud-core (1.6.0) google-cloud-env (~> 1.0) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) google-cloud-errors (1.3.1) - google-cloud-storage (1.44.0) + google-cloud-storage (1.45.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.19.0) + google-apis-storage_v1 (~> 0.29.0) google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) @@ -164,8 +163,8 @@ GEM optparse (0.1.1) os (1.1.4) plist (3.7.0) - public_suffix (5.0.3) - rake (13.0.6) + public_suffix (5.0.4) + rake (13.1.0) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -193,13 +192,10 @@ GEM tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) - unicode-display_width (2.4.2) + unicode-display_width (2.5.0) webrick (1.8.1) word_wrap (1.0.0) - xcodeproj (1.22.0) + xcodeproj (1.23.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) @@ -217,7 +213,7 @@ PLATFORMS x86_64-linux DEPENDENCIES - fastlane (= 2.216.0) + fastlane (= 2.217.0) fastlane-plugin-firebase_app_distribution RUBY VERSION diff --git a/androidHyperskillApp/build.gradle.kts b/androidHyperskillApp/build.gradle.kts index 8629e4cdd8..e6afffb9db 100644 --- a/androidHyperskillApp/build.gradle.kts +++ b/androidHyperskillApp/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(libs.android.ui.fragment) implementation(libs.android.ui.fragment.ktx) implementation(libs.android.lifecycle.runtime) + implementation(libs.android.browser) implementation(libs.kotlin.coroutines.core) implementation(libs.kotlin.coroutines.android) diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/delegate/ChallengeCardDelegate.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/delegate/ChallengeCardDelegate.kt new file mode 100644 index 0000000000..8ab818ad51 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/delegate/ChallengeCardDelegate.kt @@ -0,0 +1,78 @@ +package org.hyperskill.app.android.challenge.delegate + +import android.app.Activity +import android.content.Context +import android.net.Uri +import android.widget.Toast +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.MutableStateFlow +import org.hyperskill.app.R +import org.hyperskill.app.android.challenge.ui.ChallengeCard +import org.hyperskill.app.android.core.extensions.openUrl +import org.hyperskill.app.android.core.extensions.setHyperskillColors +import org.hyperskill.app.android.core.view.ui.dialog.CreateMagicLinkLoadingProgressDialogFragment +import org.hyperskill.app.android.core.view.ui.dialog.dismissDialogFragmentIfExists +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState +import org.hyperskill.app.challenges.widget.view.model.isLoadingMagicLink +import ru.nobird.android.view.base.ui.extension.showIfNotExists + +class ChallengeCardDelegate { + private val stateFlow: MutableStateFlow = MutableStateFlow(null) + + fun setup( + composeView: ComposeView, + onNewMessage: (ChallengeWidgetFeature.Message) -> Unit + ) { + composeView.setContent { + HyperskillTheme { + val viewState by stateFlow.collectAsStateWithLifecycle() + viewState?.let { actualViewState -> + ChallengeCard(viewState = actualViewState, onNewMessage = onNewMessage) + } + } + } + } + + fun render( + fragmentManager: FragmentManager, + state: ChallengeWidgetViewState + ) { + stateFlow.value = state + if (state is ChallengeWidgetViewState.Content && state.isLoadingMagicLink) { + CreateMagicLinkLoadingProgressDialogFragment.newInstance() + .showIfNotExists(fragmentManager, CreateMagicLinkLoadingProgressDialogFragment.TAG) + } else { + fragmentManager.dismissDialogFragmentIfExists(CreateMagicLinkLoadingProgressDialogFragment.TAG) + } + } + + fun handleAction( + context: Context, + activity: Activity, + action: ChallengeWidgetFeature.Action.ViewAction + ) { + when (action) { + is ChallengeWidgetFeature.Action.ViewAction.OpenUrl -> { + if (action.shouldOpenInApp) { + val intent = CustomTabsIntent.Builder() + .setHyperskillColors(context) + .build() + intent.launchUrl(activity, Uri.parse(action.url)) + } else { + context.openUrl(action.url) + } + } + ChallengeWidgetFeature.Action.ViewAction.ShowNetworkError -> { + Toast + .makeText(context, R.string.common_error, Toast.LENGTH_SHORT) + .show() + } + } + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/AnnouncementChallengeCard.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/AnnouncementChallengeCard.kt new file mode 100644 index 0000000000..9515fa3e1f --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/AnnouncementChallengeCard.kt @@ -0,0 +1,79 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillButton +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState.Content.Announcement + +@Composable +fun AnnouncementChallengeCard( + state: Announcement, + onReloadClick: () -> Unit, + onDescriptionLinkClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + ChallengeScaffold(modifier) { + ChallengeHeader( + title = state.headerData.title, + dateText = state.headerData.formattedDurationOfTime, + imageRes = org.hyperskill.app.android.R.drawable.img_challenge_announcment, + modifier = Modifier.fillMaxWidth() + ) + ChallengeDescription( + description = state.headerData.description, + onLinkClick = onDescriptionLinkClick + ) + when (val startIn = state.startsInState) { + Announcement.StartsInState.Deadline -> { + HyperskillButton( + onClick = onReloadClick, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource( + id = org.hyperskill.app.R.string.challenge_widget_reload_button + ) + ) + } + } + is Announcement.StartsInState.TimeRemaining -> { + ChallengeTimeText(title = startIn.title, subtitle = startIn.subtitle) + } + } + } +} + +private class AnnouncementChallengeCardPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + Announcement.StartsInState.TimeRemaining( + title = ChallengeCardPreviewValues.TIME_TITLE, + subtitle = ChallengeCardPreviewValues.TIME_SUBTITLE + ), + Announcement.StartsInState.Deadline + ) +} + +@Preview() +@Composable +private fun AnnouncementChallengeCardPreview( + @PreviewParameter(AnnouncementChallengeCardPreviewProvider::class) startsIn: Announcement.StartsInState +) { + HyperskillTheme { + AnnouncementChallengeCard( + state = Announcement( + headerData = ChallengeCardPreviewValues.headerData, + startsInState = startsIn + ), + onReloadClick = {}, + onDescriptionLinkClick = {} + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeCard.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeCard.kt new file mode 100644 index 0000000000..c6c594e31e --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeCard.kt @@ -0,0 +1,83 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.ShimmerLoading +import org.hyperskill.app.android.core.view.ui.widget.compose.WidgetDataLoadingError +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature.Message +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState + +@Composable +fun ChallengeCard( + viewState: ChallengeWidgetViewState, + onNewMessage: (Message) -> Unit +) { + when (viewState) { + ChallengeWidgetViewState.Idle, ChallengeWidgetViewState.Empty -> { + // no op + } + is ChallengeWidgetViewState.Loading -> { + if (viewState.shouldShowSkeleton) { + ShimmerLoading( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(ratio = 0.5f) + ) + } + } + ChallengeWidgetViewState.Error -> { + WidgetDataLoadingError( + onRetryClick = { + onNewMessage(Message.RetryContentLoading) + }, + modifier = Modifier.fillMaxWidth(), + title = stringResource(id = R.string.challenge_widget_network_error_text) + ) + } + is ChallengeWidgetViewState.Content.Announcement -> { + AnnouncementChallengeCard( + state = viewState, + onReloadClick = { + onNewMessage(Message.DeadlineReachedReloadClicked) + }, + onDescriptionLinkClick = { + onNewMessage(Message.LinkInTheDescriptionClicked(it)) + } + ) + } + is ChallengeWidgetViewState.Content.HappeningNow -> { + HappeningNowChallengeCard( + state = viewState, + onReloadClick = { + onNewMessage(Message.DeadlineReachedReloadClicked) + }, + onDescriptionLinkClick = { + onNewMessage(Message.LinkInTheDescriptionClicked(it)) + } + ) + } + is ChallengeWidgetViewState.Content.Completed -> { + CompletedChallengeCard( + state = viewState, + onCollectRewardClick = { + onNewMessage(Message.CollectRewardClicked) + } + ) + } + is ChallengeWidgetViewState.Content.PartiallyCompleted -> { + PartiallyCompletedChallengeCard( + state = viewState, + onCollectRewardClick = { + onNewMessage(Message.CollectRewardClicked) + } + ) + } + is ChallengeWidgetViewState.Content.Ended -> { + NotCompletedChallengeCard(state = viewState) + } + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeCardDefaults.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeCardDefaults.kt new file mode 100644 index 0000000000..c043fd75e5 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeCardDefaults.kt @@ -0,0 +1,13 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp + +object ChallengeCardDefaults { + val verticalSpacing: Dp = 16.dp + val paddingValues: PaddingValues = PaddingValues(20.dp) + const val PROGRESS_ITEMS_IN_ROW: Int = 7 + val progressItemSize: DpSize = DpSize(width = 32.dp, height = 12.dp) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeCardPreviewValues.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeCardPreviewValues.kt new file mode 100644 index 0000000000..2ed17284c2 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeCardPreviewValues.kt @@ -0,0 +1,45 @@ +package org.hyperskill.app.android.challenge.ui + +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState.Content.HappeningNow.ProgressStatus + +object ChallengeCardPreviewValues { + const val TITLE = "Advent Streak Challenge" + const val DATE_TEXT = "6 Oct - 12 Oct" + /*ktlint-disable*/ + const val DESCRIPTION = "Get ready to push your limits! Thrilling daily programming competition designed to test your coding skills and problem-solving abilities." + const val TIME_TITLE = "Start in" + const val TIME_SUBTITLE = DATE_TEXT + val headerData: ChallengeWidgetViewState.Content.HeaderData = + ChallengeWidgetViewState.Content.HeaderData( + title = TITLE, + description = DESCRIPTION, + formattedDurationOfTime = DATE_TEXT + ) + val statusHeaderData: ChallengeWidgetViewState.Content.HeaderData = + ChallengeWidgetViewState.Content.HeaderData( + title = TITLE, + description = "Well done, challenge completed!", + formattedDurationOfTime = DATE_TEXT + ) + + val progress: List = listOf( + ProgressStatus.COMPLETED, + ProgressStatus.COMPLETED, + ProgressStatus.COMPLETED, + ProgressStatus.COMPLETED, + ProgressStatus.COMPLETED, + ProgressStatus.COMPLETED, + ProgressStatus.ACTIVE, + ProgressStatus.ACTIVE, + ProgressStatus.ACTIVE, + ProgressStatus.ACTIVE, + ProgressStatus.ACTIVE, + ProgressStatus.ACTIVE, + ProgressStatus.MISSED, + ProgressStatus.MISSED, + ProgressStatus.MISSED, + ProgressStatus.MISSED, + ProgressStatus.MISSED, + ) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeDateLabel.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeDateLabel.kt new file mode 100644 index 0000000000..7be6a513ff --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeDateLabel.kt @@ -0,0 +1,39 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme + +@Composable +fun ChallengeDateLabel( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .background(colorResource(R.color.color_overlay_blue_alpha_12)) + .padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.caption, + color = colorResource(R.color.color_primary) + ) +} + +@Preview +@Composable +private fun DateLabelPreview() { + HyperskillTheme { + ChallengeDateLabel("6 Oct - 12 Oct") + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeDescription.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeDescription.kt new file mode 100644 index 0000000000..6e9aa7d814 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeDescription.kt @@ -0,0 +1,39 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.tooling.preview.Preview +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HtmlText +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme + +@Composable +fun ChallengeDescription( + description: String, + onLinkClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + HtmlText( + text = description, + modifier = modifier, + baseSpanStyle = SpanStyle(color = colorResource(id = R.color.color_on_surface_alpha_60)), + style = MaterialTheme.typography.body1, + isHighlightLink = true, + linkColor = colorResource(id = R.color.color_overlay_blue), + onUrlClick = onLinkClick + ) +} + +@Preview(showBackground = true) +@Composable +fun ChallengeDescriptionPreview() { + HyperskillTheme { + ChallengeDescription( + description = ChallengeCardPreviewValues.DESCRIPTION, + onLinkClick = {} + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeHeader.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeHeader.kt new file mode 100644 index 0000000000..fb0d8e874c --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeHeader.kt @@ -0,0 +1,82 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.hyperskill.app.android.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme + +@Composable +fun ChallengeHeader( + title: String, + dateText: String, + @DrawableRes imageRes: Int, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.height(IntrinsicSize.Min) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxHeight() + .weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.subtitle2, + fontSize = 18.sp, + color = colorResource(id = org.hyperskill.app.R.color.color_on_surface_alpha_87) + ) + ChallengeDateLabel(dateText) + } + Image( + painter = painterResource(id = imageRes), + contentDescription = null, + modifier = Modifier.align(Alignment.Top) + ) + } +} + +private class ChallengeImagePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + R.drawable.img_challenge_announcment, + R.drawable.img_challenge_progress, + R.drawable.img_challenge_completed, + R.drawable.img_challenge_partly_completed, + R.drawable.img_challenge_not_completed + ) +} + +@Preview +@Composable +fun ChallengeHeaderPreview( + @PreviewParameter(ChallengeImagePreviewProvider::class) imageRes: Int +) { + HyperskillTheme { + ChallengeHeader( + title = ChallengeCardPreviewValues.TITLE, + dateText = ChallengeCardPreviewValues.DATE_TEXT, + imageRes = imageRes + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeProgress.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeProgress.kt new file mode 100644 index 0000000000..2f97c1d6db --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeProgress.kt @@ -0,0 +1,133 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState.Content.HappeningNow.ProgressStatus + +@Composable +fun ChallengeProgress( + progressStatuses: List, + modifier: Modifier = Modifier +) { + val windowedProgresses = remember(progressStatuses) { + progressStatuses.windowed( + size = ChallengeCardDefaults.PROGRESS_ITEMS_IN_ROW, + step = ChallengeCardDefaults.PROGRESS_ITEMS_IN_ROW, + partialWindows = true + ) + } + + /** + * Column of Rows is used instead of LazyVerticalGrid because + * LazyVerticalGrid has a bug of not correct horizontal alignment of items. + * I was fixed in the androidx.compose.foundation:foundation:1.5.0. + * For more details see https://issuetracker.google.com/issues/267942510 + */ + BoxWithConstraints(modifier = modifier) { + val consumedSizeInRow = + ChallengeCardDefaults.progressItemSize.width * ChallengeCardDefaults.PROGRESS_ITEMS_IN_ROW + val horizontalGapSize = (this.maxWidth - consumedSizeInRow) / (ChallengeCardDefaults.PROGRESS_ITEMS_IN_ROW - 1) + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + windowedProgresses.forEachIndexed { lineIndex, progressLine -> + Row( + horizontalArrangement = Arrangement.spacedBy(horizontalGapSize), + modifier = Modifier.fillMaxWidth() + ) { + progressLine.forEachIndexed { itemIndex, progressStatus -> + key(lineIndex, itemIndex) { + ChallengeProgressItem(status = progressStatus) + } + } + } + } + } + } +} + +@Composable +private fun ChallengeProgressItem( + status: ProgressStatus, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .requiredSize(ChallengeCardDefaults.progressItemSize) + .clip(RoundedCornerShape(4.dp)) + .applyStatusModifiers(status) + ) { + if (status == ProgressStatus.MISSED) { + Image( + painter = painterResource(id = org.hyperskill.app.android.R.drawable.ic_missed_challenge_day), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + } + } +} + +private fun Modifier.applyStatusModifiers(status: ProgressStatus): Modifier = + this.composed { + when (status) { + ProgressStatus.COMPLETED -> + background(colorResource(id = R.color.color_primary)) + ProgressStatus.MISSED, ProgressStatus.INACTIVE -> + background(colorResource(id = R.color.color_on_surface_alpha_9)) + ProgressStatus.ACTIVE -> + background(colorResource(id = R.color.color_on_surface_alpha_9)) + .border( + width = 1.dp, + color = colorResource(id = R.color.color_primary), + shape = RoundedCornerShape(4.dp) + ) + } + } + +private class ChallengeProgressItemPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = ProgressStatus.values().asSequence() +} + +@Preview(showBackground = true) +@Composable +fun ChallengeProgressItemPreview( + @PreviewParameter(ChallengeProgressItemPreviewProvider::class) + status: ProgressStatus +) { + HyperskillTheme { + ChallengeProgressItem(status) + } +} + +@Preview(showBackground = true) +@Composable +private fun ChallengeProgressPreview() { + HyperskillTheme { + ChallengeProgress(ChallengeCardPreviewValues.progress) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeScaffold.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeScaffold.kt new file mode 100644 index 0000000000..6609f842df --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeScaffold.kt @@ -0,0 +1,38 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.dp +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillCard + +@Composable +fun ChallengeScaffold( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit +) { + HyperskillCard( + contentPadding = ChallengeCardDefaults.paddingValues, + onClick = onClick, + modifier = modifier.border( + width = 1.dp, + color = colorResource(id = R.color.color_on_surface_alpha_9), + shape = RoundedCornerShape( + dimensionResource(id = org.hyperskill.app.android.R.dimen.corner_radius) + ) + ) + ) { + Column( + content = content, + verticalArrangement = Arrangement.spacedBy(ChallengeCardDefaults.verticalSpacing) + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeStatus.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeStatus.kt new file mode 100644 index 0000000000..6dadb64248 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeStatus.kt @@ -0,0 +1,35 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme + +@Composable +fun ChallengeStatus( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + style = MaterialTheme.typography.subtitle1, + color = colorResource(id = R.color.color_on_surface_alpha_60), + fontWeight = FontWeight.Bold, + modifier = modifier + ) +} + +@Preview +@Composable +private fun ChallengeStatusPreview() { + HyperskillTheme { + ChallengeStatus( + "Well done, challenge completed!" + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeTimeText.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeTimeText.kt new file mode 100644 index 0000000000..122b69c28c --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/ChallengeTimeText.kt @@ -0,0 +1,50 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme + +@Composable +fun ChallengeTimeText( + title: String, + subtitle: String, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + ) { + Text( + text = title, + style = MaterialTheme.typography.body1, + fontSize = 14.sp, + color = colorResource(id = R.color.color_on_surface_alpha_38) + ) + Text( + text = subtitle, + style = MaterialTheme.typography.subtitle2, + fontSize = 14.sp, + color = colorResource(id = R.color.color_on_surface_alpha_38) + ) + } +} + +@Preview +@Composable +fun ChallengeTimeTextPreview() { + HyperskillTheme { + ChallengeTimeText( + title = ChallengeCardPreviewValues.TIME_TITLE, + subtitle = ChallengeCardPreviewValues.TIME_SUBTITLE + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/CompletedChallengeCard.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/CompletedChallengeCard.kt new file mode 100644 index 0000000000..cb0a64ae9c --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/CompletedChallengeCard.kt @@ -0,0 +1,57 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillButton +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState.Content.CollectRewardButtonState + +@Composable +fun CompletedChallengeCard( + state: ChallengeWidgetViewState.Content.Completed, + onCollectRewardClick: () -> Unit, + modifier: Modifier = Modifier +) { + ChallengeScaffold(modifier) { + ChallengeHeader( + title = state.headerData.title, + dateText = state.headerData.formattedDurationOfTime, + imageRes = org.hyperskill.app.android.R.drawable.img_challenge_completed, + modifier = Modifier.fillMaxWidth() + ) + ChallengeStatus(text = state.headerData.description) + + val collectRewardButtonState = state.collectRewardButtonState + if (collectRewardButtonState is CollectRewardButtonState.Visible) { + HyperskillButton( + onClick = onCollectRewardClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = collectRewardButtonState.title) + } + } + } +} + +@Preview +@Composable +private fun CompletedChallengeCardPreview() { + HyperskillTheme { + CompletedChallengeCard( + ChallengeWidgetViewState.Content.Completed( + headerData = ChallengeCardPreviewValues.statusHeaderData, + collectRewardButtonState = CollectRewardButtonState.Visible( + title = stringResource(id = R.string.challenge_widget_collect_reward_button_title) + ), + isLoadingMagicLink = false + ), + onCollectRewardClick = {} + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/HappeningNowChallengeCard.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/HappeningNowChallengeCard.kt new file mode 100644 index 0000000000..00b33008bb --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/HappeningNowChallengeCard.kt @@ -0,0 +1,87 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillButton +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState.Content.HappeningNow.CompleteInState + +@Composable +fun HappeningNowChallengeCard( + state: ChallengeWidgetViewState.Content.HappeningNow, + onReloadClick: () -> Unit, + onDescriptionLinkClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + ChallengeScaffold(modifier) { + ChallengeHeader( + title = state.headerData.title, + dateText = state.headerData.formattedDurationOfTime, + imageRes = org.hyperskill.app.android.R.drawable.img_challenge_progress, + modifier = Modifier.fillMaxWidth() + ) + ChallengeDescription( + description = state.headerData.description, + onLinkClick = onDescriptionLinkClick + ) + ChallengeProgress(state.progressStatuses) + when (val completeIn = state.completeInState) { + CompleteInState.Empty -> { + // no op + } + CompleteInState.Deadline -> { + HyperskillButton( + onClick = onReloadClick, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource( + id = R.string.challenge_widget_reload_button + ) + ) + } + } + is CompleteInState.TimeRemaining -> { + ChallengeTimeText(title = completeIn.title, subtitle = completeIn.subtitle) + } + } + } +} + +private class HappeningNowChallengeCardPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + CompleteInState.Empty, + CompleteInState.TimeRemaining( + title = ChallengeCardPreviewValues.TIME_TITLE, + subtitle = ChallengeCardPreviewValues.TIME_SUBTITLE + ), + CompleteInState.Deadline + ) +} + +@Preview +@Composable +fun HappeningNowChallengeCardPreview( + @PreviewParameter(HappeningNowChallengeCardPreviewProvider::class) completeInState: CompleteInState +) { + HyperskillTheme { + HappeningNowChallengeCard( + state = ChallengeWidgetViewState.Content.HappeningNow( + headerData = ChallengeCardPreviewValues.headerData, + completeInState = completeInState, + progressStatuses = ChallengeCardPreviewValues.progress + ), + onReloadClick = {}, + onDescriptionLinkClick = {} + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/NotCompletedChallengeCard.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/NotCompletedChallengeCard.kt new file mode 100644 index 0000000000..33ae2871e5 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/NotCompletedChallengeCard.kt @@ -0,0 +1,36 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState + +@Composable +fun NotCompletedChallengeCard( + state: ChallengeWidgetViewState.Content.Ended, + modifier: Modifier = Modifier +) { + ChallengeScaffold(modifier) { + ChallengeHeader( + title = state.headerData.title, + dateText = state.headerData.formattedDurationOfTime, + imageRes = org.hyperskill.app.android.R.drawable.img_challenge_not_completed, + modifier = Modifier.fillMaxWidth() + ) + ChallengeStatus(text = state.headerData.description) + } +} + +@Preview +@Composable +private fun NotCompletedChallengeCardPreview() { + HyperskillTheme { + NotCompletedChallengeCard( + ChallengeWidgetViewState.Content.Ended( + headerData = ChallengeCardPreviewValues.statusHeaderData + ) + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/PartiallyCompletedChallengeCard.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/PartiallyCompletedChallengeCard.kt new file mode 100644 index 0000000000..5b1acc9398 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/challenge/ui/PartiallyCompletedChallengeCard.kt @@ -0,0 +1,56 @@ +package org.hyperskill.app.android.challenge.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillButton +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState + +@Composable +fun PartiallyCompletedChallengeCard( + state: ChallengeWidgetViewState.Content.PartiallyCompleted, + onCollectRewardClick: () -> Unit, + modifier: Modifier = Modifier +) { + ChallengeScaffold(modifier) { + ChallengeHeader( + title = state.headerData.title, + dateText = state.headerData.formattedDurationOfTime, + imageRes = org.hyperskill.app.android.R.drawable.img_challenge_partly_completed, + modifier = Modifier.fillMaxWidth() + ) + ChallengeStatus(text = state.headerData.description) + + val collectRewardButtonState = state.collectRewardButtonState + if (collectRewardButtonState is ChallengeWidgetViewState.Content.CollectRewardButtonState.Visible) { + HyperskillButton( + onClick = onCollectRewardClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = collectRewardButtonState.title) + } + } + } +} + +@Preview +@Composable +private fun PartlyCompletedChallengeCardPreview() { + HyperskillTheme { + PartiallyCompletedChallengeCard( + ChallengeWidgetViewState.Content.PartiallyCompleted( + headerData = ChallengeCardPreviewValues.statusHeaderData, + collectRewardButtonState = ChallengeWidgetViewState.Content.CollectRewardButtonState.Visible( + title = stringResource(id = R.string.challenge_widget_collect_reward_button_title) + ), + isLoadingMagicLink = false + ), + onCollectRewardClick = {} + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/CustomTabsExtention.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/CustomTabsExtention.kt new file mode 100644 index 0000000000..7e0cb5746e --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/CustomTabsExtention.kt @@ -0,0 +1,14 @@ +package org.hyperskill.app.android.core.extensions + +import android.content.Context +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.content.ContextCompat +import org.hyperskill.app.R + +fun CustomTabsIntent.Builder.setHyperskillColors(context: Context): CustomTabsIntent.Builder = + setDefaultColorSchemeParams( + CustomTabColorSchemeParams.Builder() + .setToolbarColor(ContextCompat.getColor(context, R.color.color_primary_variant)) + .build() + ) \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/DataLoadingError.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/DataLoadingError.kt index 3d1d44288b..f188393781 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/DataLoadingError.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/DataLoadingError.kt @@ -2,20 +2,33 @@ package org.hyperskill.app.android.core.view.ui.widget.compose import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.viewinterop.AndroidViewBinding +import org.hyperskill.app.R import org.hyperskill.app.android.databinding.WidgetDataLoadingErrorBinding @Composable fun WidgetDataLoadingError( onRetryClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + title: String? = null ) { AndroidViewBinding( WidgetDataLoadingErrorBinding::inflate, modifier ) { - this.reloadButton.setOnClickListener { + reloadButton.setOnClickListener { onRetryClick() } + dataLoadingErrorTitle.text = + title ?: root.context.getString(R.string.study_plan_activities_error_text) + } +} + +@Preview +@Composable +private fun WidgetDataLoadingErrorPreview() { + HyperskillTheme { + WidgetDataLoadingError(onRetryClick = {}) } } \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HtmlText.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HtmlText.kt new file mode 100644 index 0000000000..423bbb28ab --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HtmlText.kt @@ -0,0 +1,109 @@ +package org.hyperskill.app.android.core.view.ui.widget.compose + +import android.graphics.Typeface +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.URLSpan +import android.text.style.UnderlineSpan +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.text.HtmlCompat + +private const val URL_TAG = "url" + +@Composable +fun HtmlText( + text: String, + modifier: Modifier = Modifier, + baseSpanStyle: SpanStyle? = null, + linkColor: Color = Color.Blue, + isHighlightLink: Boolean = false, + style: TextStyle = LocalTextStyle.current, + onUrlClick: ((url: String) -> Unit)? = null +) { + val spannedText = remember(text) { + HtmlCompat.fromHtml(text.replace("\n", "
"), HtmlCompat.FROM_HTML_MODE_COMPACT) + } + val uriHandler = LocalUriHandler.current + val annotatedString = spannedText.toAnnotatedString( + baseSpanStyle = baseSpanStyle, + linkColor = if (isHighlightLink) linkColor else Color.Unspecified, + underlineLinks = false + ) + ClickableText( + modifier = modifier, + text = annotatedString, + style = style, + ) { offset -> + annotatedString.getStringAnnotations(URL_TAG, offset, offset).firstOrNull()?.let { range -> + if (onUrlClick != null) { + onUrlClick(range.item) + } else { + uriHandler.openUri(range.item) + } + } + } +} + +fun Spanned.toAnnotatedString( + baseSpanStyle: SpanStyle?, + underlineLinks: Boolean, + linkColor: Color +): AnnotatedString = + buildAnnotatedString { + val spanned = this@toAnnotatedString + append(spanned.toString()) + baseSpanStyle?.let { addStyle(it, 0, length) } + getSpans(0, spanned.length, Any::class.java).forEach { span -> + val start = getSpanStart(span) + val end = getSpanEnd(span) + when (span) { + is StyleSpan -> when (span.style) { + Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + Typeface.BOLD_ITALIC -> addStyle( + SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), + start, + end + ) + } + is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end) + is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end) + is URLSpan -> { + addStyle( + SpanStyle( + textDecoration = if (underlineLinks) TextDecoration.Underline else null, + color = linkColor + ), + start, + end + ) + addStringAnnotation(URL_TAG, span.url, start, end) + } + } + } + } + +@Preview +@Composable +private fun LinksHtmlTextPreview() { + HtmlText( + /*ktlint-disable*/ + text = "Some text \n" + + "link text, the rest of the text" + ) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillCard.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillCard.kt new file mode 100644 index 0000000000..0d0f85ed2d --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillCard.kt @@ -0,0 +1,46 @@ +package org.hyperskill.app.android.core.view.ui.widget.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.Dp +import org.hyperskill.app.android.R + +@Composable +fun HyperskillCard( + contentPadding: PaddingValues, + modifier: Modifier = Modifier, + cornerRadius: Dp = dimensionResource(id = R.dimen.corner_radius), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onClick: (() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(cornerRadius)) + .background(MaterialTheme.colors.surface) + .apply { + if (onClick != null) { + clickable( + interactionSource = interactionSource, + indication = rememberRipple(), + onClick = onClick + ) + } + } + .padding(contentPadding), + content = content + ) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/ShimmerLoading.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/ShimmerLoading.kt index c461548936..06d4e1e967 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/ShimmerLoading.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/ShimmerLoading.kt @@ -2,16 +2,17 @@ package org.hyperskill.app.android.core.view.ui.widget.compose import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import org.hyperskill.app.android.R import org.hyperskill.app.android.ui.custom.LoadingView import ru.nobird.android.view.base.ui.extension.toPx @Composable fun ShimmerLoading( modifier: Modifier = Modifier, - radius: Dp = 0.dp + radius: Dp = dimensionResource(id = R.dimen.corner_radius) ) { AndroidView( factory = { context -> diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/home/view/ui/fragment/HomeFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/home/view/ui/fragment/HomeFragment.kt index 58ab956f6e..0c9371bf30 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/home/view/ui/fragment/HomeFragment.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/home/view/ui/fragment/HomeFragment.kt @@ -2,7 +2,10 @@ package org.hyperskill.app.android.home.view.ui.fragment import android.os.Bundle import android.view.View +import android.view.ViewGroup.MarginLayoutParams import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updateMargins import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -10,6 +13,7 @@ import androidx.lifecycle.ViewModelProvider import by.kirich1409.viewbindingdelegate.viewBinding import org.hyperskill.app.android.HyperskillApp import org.hyperskill.app.android.R +import org.hyperskill.app.android.challenge.delegate.ChallengeCardDelegate import org.hyperskill.app.android.core.view.ui.navigation.requireRouter import org.hyperskill.app.android.core.view.ui.setHyperskillColors import org.hyperskill.app.android.core.view.ui.updateIsRefreshing @@ -20,7 +24,8 @@ import org.hyperskill.app.android.problem_of_day.view.delegate.ProblemOfDayCardF import org.hyperskill.app.android.step.view.screen.StepScreen import org.hyperskill.app.android.topics_repetitions.view.delegate.TopicsRepetitionCardFormDelegate import org.hyperskill.app.android.topics_repetitions.view.screen.TopicsRepetitionScreen -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.home.presentation.HomeFeature import org.hyperskill.app.home.presentation.HomeViewModel import org.hyperskill.app.step.domain.model.StepRoute @@ -30,7 +35,7 @@ import ru.nobird.app.presentation.redux.container.ReduxView class HomeFragment : Fragment(R.layout.fragment_home), - ReduxView { + ReduxView { companion object { fun newInstance(): Fragment = HomeFragment() @@ -54,6 +59,9 @@ class HomeFragment : private val topicsRepetitionDelegate: TopicsRepetitionCardFormDelegate by lazy(LazyThreadSafetyMode.NONE) { TopicsRepetitionCardFormDelegate() } + private val challengeCardDelegate: ChallengeCardDelegate by lazy(LazyThreadSafetyMode.NONE) { + ChallengeCardDelegate() + } private var gamificationToolbarDelegate: GamificationToolbarDelegate? = null @@ -79,6 +87,12 @@ class HomeFragment : initViewStateDelegate() initGamificationToolbarDelegate() problemOfDayCardFormDelegate.setup(viewBinding.homeScreenProblemOfDayCard) + challengeCardDelegate.setup( + viewBinding.homeScreenChallengeCard, + onNewMessage = { + homeViewModel.onNewMessage(HomeFeature.Message.ChallengeWidgetMessage(it)) + } + ) with(viewBinding) { homeScreenSwipeRefreshLayout.setHyperskillColors() homeScreenSwipeRefreshLayout.setOnRefreshListener { @@ -133,7 +147,8 @@ class HomeFragment : viewBinding.homeScreenContainer, viewBinding.homeScreenKeepPracticingTextView, viewBinding.homeScreenProblemOfDayCard.root, - viewBinding.homeScreenTopicsRepetitionCard.root + viewBinding.homeScreenTopicsRepetitionCard.root, + viewBinding.homeScreenChallengeCard ) } } @@ -166,10 +181,17 @@ class HomeFragment : StepScreen(action.stepRoute) ) } + is HomeFeature.Action.ViewAction.ChallengeWidgetViewAction -> { + challengeCardDelegate.handleAction( + context = requireContext(), + activity = requireActivity(), + action = action.viewAction + ) + } } } - override fun render(state: HomeFeature.State) { + override fun render(state: HomeFeature.ViewState) { homeViewStateDelegate.switchState(state.homeState) renderSwipeRefresh(state) @@ -180,10 +202,25 @@ class HomeFragment : renderTopicsRepetition(homeState.repetitionsState, homeState.isFreemiumEnabled) } + val challengeState = state.challengeWidgetViewState + challengeCardDelegate.render(childFragmentManager, challengeState) + viewBinding.homeScreenChallengeCard.updateLayoutParams { + updateMargins( + top = when (challengeState) { + ChallengeWidgetViewState.Idle, ChallengeWidgetViewState.Empty -> 0 + else -> { + requireContext() + .resources + .getDimensionPixelOffset(R.dimen.home_screen_challenge_card_top_margin) + } + } + ) + } + gamificationToolbarDelegate?.render(state.toolbarState) } - private fun renderSwipeRefresh(state: HomeFeature.State) { + private fun renderSwipeRefresh(state: HomeFeature.ViewState) { with(viewBinding.homeScreenSwipeRefreshLayout) { isEnabled = state.homeState is HomeFeature.HomeState.Content updateIsRefreshing(state.isRefreshing) diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problem_of_day/view/delegate/ProblemOfDayCardFormDelegate.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problem_of_day/view/delegate/ProblemOfDayCardFormDelegate.kt index 516bf14678..166bdfd3c5 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problem_of_day/view/delegate/ProblemOfDayCardFormDelegate.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problem_of_day/view/delegate/ProblemOfDayCardFormDelegate.kt @@ -4,7 +4,7 @@ import android.view.View import androidx.core.view.isVisible import org.hyperskill.app.android.R import org.hyperskill.app.android.databinding.LayoutProblemOfTheDayCardBinding -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.home.presentation.HomeFeature class ProblemOfDayCardFormDelegate( diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/share_streak/fragment/ShareStreakDialogFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/share_streak/fragment/ShareStreakDialogFragment.kt index 4dcaf9675c..b4d42ed3e8 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/share_streak/fragment/ShareStreakDialogFragment.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/share_streak/fragment/ShareStreakDialogFragment.kt @@ -80,6 +80,7 @@ class ShareStreakDialogFragment : BottomSheetDialogFragment() { } shareStreakRefuseButton.setOnClickListener { (parentFragment as? Callback)?.onRefuseStreakSharingClick(streak) + dismiss() } } } diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_theory/view/fragment/StepTheoryFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_theory/view/fragment/StepTheoryFragment.kt index ef8a46e3b9..3b34ee2ac6 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_theory/view/fragment/StepTheoryFragment.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_theory/view/fragment/StepTheoryFragment.kt @@ -27,7 +27,7 @@ import org.hyperskill.app.android.step.view.model.StepCompletionView import org.hyperskill.app.android.step_content_text.view.fragment.TextStepContentFragment import org.hyperskill.app.android.step_theory.view.model.StepTheoryRating import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.step.domain.model.CommentStatisticsEntry import org.hyperskill.app.step.domain.model.Step import org.hyperskill.app.step.domain.model.StepRoute diff --git a/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_announcment.webp b/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_announcment.webp new file mode 100644 index 0000000000..f0331f412d Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_announcment.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_completed.webp b/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_completed.webp new file mode 100644 index 0000000000..a600641a9d Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_not_completed.webp b/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_not_completed.webp new file mode 100644 index 0000000000..4d85282e18 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_not_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_partly_completed.webp b/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_partly_completed.webp new file mode 100644 index 0000000000..be20a8874c Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_partly_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_progress.webp b/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_progress.webp new file mode 100644 index 0000000000..b1bc8596e7 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-hdpi/img_challenge_progress.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_announcment.webp b/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_announcment.webp new file mode 100644 index 0000000000..b48ff6ed1f Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_announcment.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_completed.webp b/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_completed.webp new file mode 100644 index 0000000000..c5cf2e79ce Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_not_completed.webp b/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_not_completed.webp new file mode 100644 index 0000000000..6474f7bd3e Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_not_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_partly_completed.webp b/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_partly_completed.webp new file mode 100644 index 0000000000..f044b4ea6b Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_partly_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_progress.webp b/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_progress.webp new file mode 100644 index 0000000000..a00691c825 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-mdpi/img_challenge_progress.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_announcment.webp b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_announcment.webp new file mode 100644 index 0000000000..11db6e527d Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_announcment.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_completed.webp b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_completed.webp new file mode 100644 index 0000000000..352994867c Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_not_completed.webp b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_not_completed.webp new file mode 100644 index 0000000000..983b484ec0 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_not_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_partly_completed.webp b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_partly_completed.webp new file mode 100644 index 0000000000..a38babf5a9 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_partly_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_progress.webp b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_progress.webp new file mode 100644 index 0000000000..2a34b7a773 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_challenge_progress.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_announcment.webp b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_announcment.webp new file mode 100644 index 0000000000..79c32c921a Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_announcment.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_completed.webp b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_completed.webp new file mode 100644 index 0000000000..72fc416f5e Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_not_completed.webp b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_not_completed.webp new file mode 100644 index 0000000000..b1993b8094 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_not_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_partly_completed.webp b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_partly_completed.webp new file mode 100644 index 0000000000..35ff007a91 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_partly_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_progress.webp b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_progress.webp new file mode 100644 index 0000000000..0249cc3235 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_challenge_progress.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_announcment.webp b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_announcment.webp new file mode 100644 index 0000000000..dace5a407e Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_announcment.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_completed.webp b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_completed.webp new file mode 100644 index 0000000000..daaa5dde6b Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_not_completed.webp b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_not_completed.webp new file mode 100644 index 0000000000..fc89df7807 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_not_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_partly_completed.webp b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_partly_completed.webp new file mode 100644 index 0000000000..e353305457 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_partly_completed.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_progress.webp b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_progress.webp new file mode 100644 index 0000000000..c0ee190b57 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_challenge_progress.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable/ic_missed_challenge_day.xml b/androidHyperskillApp/src/main/res/drawable/ic_missed_challenge_day.xml new file mode 100644 index 0000000000..3b0dde419c --- /dev/null +++ b/androidHyperskillApp/src/main/res/drawable/ic_missed_challenge_day.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidHyperskillApp/src/main/res/layout/fragment_home.xml b/androidHyperskillApp/src/main/res/layout/fragment_home.xml index 1f7d5b6dd9..d42dd45bc1 100644 --- a/androidHyperskillApp/src/main/res/layout/fragment_home.xml +++ b/androidHyperskillApp/src/main/res/layout/fragment_home.xml @@ -46,6 +46,14 @@ android:layout_marginHorizontal="20dp" /> + + 20dp 16dp + 28dp + \ No newline at end of file diff --git a/gradle/app.versions.toml b/gradle/app.versions.toml index b6c0498b5f..6e39d71c20 100644 --- a/gradle/app.versions.toml +++ b/gradle/app.versions.toml @@ -3,4 +3,4 @@ minSdk = '24' targetSdk = '33' compileSdk = '33' versionName = '1.42' -versionCode = '236' \ No newline at end of file +versionCode = '239' \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0e87470862..a6b63f331a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ androidxLifecycle = "2.6.0-alpha01" coil = '2.2.0' lottie = '6.1.0' kermit = '2.0.0-RC4' +androidxBrowser = "1.5.0" kotlinCompilerExtension = "1.4.8" composeBom = "2023.06.01" @@ -70,6 +71,7 @@ android-ui-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.r android-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidxLifecycle" } android-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidxLifecycle" } android-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } +android-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" } android-test-junit = { module = "junit:junit", version = "4.13.2" } android-test-runner = { module = "androidx.test:runner", version = "1.4.0" } diff --git a/iosHyperskillApp/Gemfile b/iosHyperskillApp/Gemfile index 8aef70f928..a836df0538 100644 --- a/iosHyperskillApp/Gemfile +++ b/iosHyperskillApp/Gemfile @@ -1,8 +1,8 @@ source "https://rubygems.org" ruby "3.1.0" -gem "fastlane", "2.216.0" -gem "cocoapods", "1.14.2" +gem "fastlane", "2.217.0" +gem "cocoapods", "1.14.3" gem "generamba", git: "https://github.com/ivan-magda/Generamba.git", branch: "develop" eval_gemfile("fastlane/Pluginfile") \ No newline at end of file diff --git a/iosHyperskillApp/Gemfile.lock b/iosHyperskillApp/Gemfile.lock index 40b70237cd..0bb11d39b6 100644 --- a/iosHyperskillApp/Gemfile.lock +++ b/iosHyperskillApp/Gemfile.lock @@ -16,7 +16,7 @@ GEM specs: CFPropertyList (3.0.6) rexml - activesupport (7.1.1) + activesupport (7.1.2) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -33,32 +33,32 @@ GEM json (>= 1.5.1) artifactory (3.0.15) atomos (0.1.3) - aws-eventstream (1.2.0) - aws-partitions (1.843.0) - aws-sdk-core (3.185.1) + aws-eventstream (1.3.0) + aws-partitions (1.855.0) + aws-sdk-core (3.188.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.72.0) - aws-sdk-core (~> 3, >= 3.184.0) + aws-sdk-kms (1.73.0) + aws-sdk-core (~> 3, >= 3.188.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.136.0) - aws-sdk-core (~> 3, >= 3.181.0) + aws-sdk-s3 (1.139.0) + aws-sdk-core (~> 3, >= 3.188.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.6) - aws-sigv4 (1.6.1) + aws-sigv4 (1.7.0) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.1.1) + base64 (0.2.0) bigdecimal (3.1.4) claide (1.1.0) - cocoapods (1.14.2) + cocoapods (1.14.3) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.14.2) + cocoapods-core (= 1.14.3) cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 2.0) + cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) cocoapods-search (>= 1.0.0, < 2.0) cocoapods-trunk (>= 1.6.0, < 2.0) @@ -71,7 +71,7 @@ GEM nap (~> 1.0) ruby-macho (>= 2.3.0, < 3.0) xcodeproj (>= 1.23.0, < 2.0) - cocoapods-core (1.14.2) + cocoapods-core (1.14.3) activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -82,7 +82,7 @@ GEM public_suffix (~> 4.0) typhoeus (~> 1.0) cocoapods-deintegrate (1.0.5) - cocoapods-downloader (2.0) + cocoapods-downloader (2.1) cocoapods-plugins (1.0.0) nap cocoapods-search (1.0.1) @@ -99,10 +99,9 @@ GEM declarative (0.0.20) digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20231109) dotenv (2.8.1) - drb (2.1.1) + drb (2.2.0) ruby2_keywords emoji_regex (3.2.3) escape (0.0.4) @@ -138,7 +137,7 @@ GEM faraday_middleware (1.2.0) faraday (~> 1.0) fastimage (2.2.7) - fastlane (2.216.0) + fastlane (2.217.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -189,7 +188,7 @@ GEM git (1.13.0) addressable (~> 2.8) rchardet (~> 1.8) - google-apis-androidpublisher_v3 (0.51.0) + google-apis-androidpublisher_v3 (0.53.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-core (0.11.2) addressable (~> 2.5, >= 2.5.1) @@ -206,19 +205,19 @@ GEM google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.19.0) - google-apis-core (>= 0.9.0, < 2.a) + google-apis-storage_v1 (0.29.0) + google-apis-core (>= 0.11.0, < 2.a) google-cloud-core (1.6.0) google-cloud-env (~> 1.0) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) google-cloud-errors (1.3.1) - google-cloud-storage (1.44.0) + google-cloud-storage (1.45.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.19.0) + google-apis-storage_v1 (~> 0.29.0) google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) @@ -244,7 +243,7 @@ GEM molinillo (0.8.0) multi_json (1.15.0) multipart-post (2.3.0) - mutex_m (0.1.2) + mutex_m (0.2.0) nanaimo (0.3.0) nap (1.1.0) naturally (2.2.1) @@ -283,14 +282,11 @@ GEM tty-screen (0.8.1) tty-spinner (0.9.3) tty-cursor (~> 0.7) - typhoeus (1.4.0) + typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) unicode-display_width (2.5.0) webrick (1.8.1) word_wrap (1.0.0) @@ -313,8 +309,8 @@ PLATFORMS x86_64-linux DEPENDENCIES - cocoapods (= 1.14.2) - fastlane (= 2.216.0) + cocoapods (= 1.14.3) + fastlane (= 2.217.0) fastlane-plugin-firebase_app_distribution fastlane-plugin-sentry generamba! diff --git a/iosHyperskillApp/NotificationServiceExtension/Info.plist b/iosHyperskillApp/NotificationServiceExtension/Info.plist index 24c7fa57ab..edc4ec33d3 100644 --- a/iosHyperskillApp/NotificationServiceExtension/Info.plist +++ b/iosHyperskillApp/NotificationServiceExtension/Info.plist @@ -9,7 +9,7 @@ CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleVersion - 236 + 239 CFBundleShortVersionString 1.42 CFBundlePackageType diff --git a/iosHyperskillApp/Podfile.lock b/iosHyperskillApp/Podfile.lock index 3db79320cc..efb3bd0a71 100644 --- a/iosHyperskillApp/Podfile.lock +++ b/iosHyperskillApp/Podfile.lock @@ -225,4 +225,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 5b9e11f6c950f7555e8c01422a68067fdaf449d9 -COCOAPODS: 1.14.2 +COCOAPODS: 1.14.3 diff --git a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj index 3b311db39b..6eb37608ae 100644 --- a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj +++ b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj @@ -360,6 +360,16 @@ 2CAF254C2AB9C2E500595582 /* ShineEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CAF254B2AB9C2E500595582 /* ShineEffect.swift */; }; 2CAFD38F27FC517D00F88B0B /* ColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CAFD38E27FC517D00F88B0B /* ColorPalette.swift */; }; 2CAFD39127FC5D5D00F88B0B /* Color+DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CAFD39027FC5D5D00F88B0B /* Color+DesignSystem.swift */; }; + 2CB0ADEC2B04AD550089D557 /* ChallengeWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB0ADEB2B04AD550089D557 /* ChallengeWidgetView.swift */; }; + 2CB0ADEE2B04AD6D0089D557 /* ChallengeWidgetViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB0ADED2B04AD6D0089D557 /* ChallengeWidgetViewModel.swift */; }; + 2CB0ADF02B04B2E30089D557 /* ChallengeWidgetErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB0ADEF2B04B2E30089D557 /* ChallengeWidgetErrorView.swift */; }; + 2CB0ADF22B04BB310089D557 /* ChallengeWidgetOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB0ADF12B04BB310089D557 /* ChallengeWidgetOutputProtocol.swift */; }; + 2CB0ADF52B04BC8E0089D557 /* ChallengeWidgetAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB0ADF42B04BC8E0089D557 /* ChallengeWidgetAssembly.swift */; }; + 2CB0ADF72B04CCBA0089D557 /* ChallengeWidgetSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB0ADF62B04CCBA0089D557 /* ChallengeWidgetSkeletonView.swift */; }; + 2CB0ADF92B04D0EF0089D557 /* ChallengeWidgetContentStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB0ADF82B04D0EF0089D557 /* ChallengeWidgetContentStateView.swift */; }; + 2CB0ADFC2B04D3180089D557 /* ChallengeWidgetContentStateHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB0ADFB2B04D3180089D557 /* ChallengeWidgetContentStateHeaderView.swift */; }; + 2CB0ADFE2B04DD170089D557 /* ChallengeWidgetViewStateKsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB0ADFD2B04DD170089D557 /* ChallengeWidgetViewStateKsExtensions.swift */; }; + 2CB0AE002B0525020089D557 /* ChallengeWidgetContentStateDescriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB0ADFF2B0525020089D557 /* ChallengeWidgetContentStateDescriptionView.swift */; }; 2CB1962428EF27F30075F7EF /* UIKitViewControllerPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB1962328EF27F30075F7EF /* UIKitViewControllerPreview.swift */; }; 2CB279AD28C72A9500EDDCC8 /* TabBarRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB279AC28C72A9500EDDCC8 /* TabBarRouter.swift */; }; 2CB279AF28C72AA400EDDCC8 /* DeepLinkRouterProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB279AE28C72AA400EDDCC8 /* DeepLinkRouterProtocol.swift */; }; @@ -423,10 +433,15 @@ 2CE31F4827F1BB79008EEE66 /* AuthSocialAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE31F4727F1BB79008EEE66 /* AuthSocialAssembly.swift */; }; 2CE31F4B27F1E070008EEE66 /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE31F4A27F1E070008EEE66 /* AppViewModel.swift */; }; 2CE31F4D27F1E0C8008EEE66 /* AppAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE31F4C27F1E0C8008EEE66 /* AppAssembly.swift */; }; + 2CE58C5A2B07662300E5EBBE /* ChallengeWidgetContentStateProgressGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE58C592B07662300E5EBBE /* ChallengeWidgetContentStateProgressGridView.swift */; }; + 2CE58C5C2B0768F300E5EBBE /* ChallengeWidgetContentStateProgressGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE58C5B2B0768F300E5EBBE /* ChallengeWidgetContentStateProgressGridItemView.swift */; }; 2CE7B4842AB0593F00DCBE4D /* AttributedTextLabelWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE7B4832AB0593F00DCBE4D /* AttributedTextLabelWrapper.swift */; }; 2CE7B4872AB05D0400DCBE4D /* StepQuizParsonsViewDataMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE7B4862AB05D0400DCBE4D /* StepQuizParsonsViewDataMapper.swift */; }; 2CE7B48A2AB0973D00DCBE4D /* HTMLString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE7B4892AB0973D00DCBE4D /* HTMLString.swift */; }; 2CE7B48F2AB0A09100DCBE4D /* HTMLStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE7B48E2AB0A09100DCBE4D /* HTMLStringTests.swift */; }; + 2CE8EE6D2B065F00004EB545 /* ChallengeWidgetContentStateDeadlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE8EE6C2B065F00004EB545 /* ChallengeWidgetContentStateDeadlineView.swift */; }; + 2CE8EE6F2B066C2F004EB545 /* ChallengeWidgetContentStateCollectRewardButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE8EE6E2B066C2F004EB545 /* ChallengeWidgetContentStateCollectRewardButton.swift */; }; + 2CE8EE712B066D05004EB545 /* ChallengeWidgetViewStateContentCollectRewardButtonStateKsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE8EE702B066D05004EB545 /* ChallengeWidgetViewStateContentCollectRewardButtonStateKsExtensions.swift */; }; 2CEB33772949930B00B9E437 /* StepQuizSQLSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEB33762949930B00B9E437 /* StepQuizSQLSkeletonView.swift */; }; 2CEB50C6288A92820044F9AB /* StepExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEB50C5288A92820044F9AB /* StepExtensions.swift */; }; 2CEB50C8288A94050044F9AB /* BlockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEB50C7288A94050044F9AB /* BlockExtensions.swift */; }; @@ -1000,6 +1015,16 @@ 2CAF254B2AB9C2E500595582 /* ShineEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShineEffect.swift; sourceTree = ""; }; 2CAFD38E27FC517D00F88B0B /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = ""; }; 2CAFD39027FC5D5D00F88B0B /* Color+DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+DesignSystem.swift"; sourceTree = ""; }; + 2CB0ADEB2B04AD550089D557 /* ChallengeWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetView.swift; sourceTree = ""; }; + 2CB0ADED2B04AD6D0089D557 /* ChallengeWidgetViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetViewModel.swift; sourceTree = ""; }; + 2CB0ADEF2B04B2E30089D557 /* ChallengeWidgetErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetErrorView.swift; sourceTree = ""; }; + 2CB0ADF12B04BB310089D557 /* ChallengeWidgetOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetOutputProtocol.swift; sourceTree = ""; }; + 2CB0ADF42B04BC8E0089D557 /* ChallengeWidgetAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetAssembly.swift; sourceTree = ""; }; + 2CB0ADF62B04CCBA0089D557 /* ChallengeWidgetSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetSkeletonView.swift; sourceTree = ""; }; + 2CB0ADF82B04D0EF0089D557 /* ChallengeWidgetContentStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetContentStateView.swift; sourceTree = ""; }; + 2CB0ADFB2B04D3180089D557 /* ChallengeWidgetContentStateHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetContentStateHeaderView.swift; sourceTree = ""; }; + 2CB0ADFD2B04DD170089D557 /* ChallengeWidgetViewStateKsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetViewStateKsExtensions.swift; sourceTree = ""; }; + 2CB0ADFF2B0525020089D557 /* ChallengeWidgetContentStateDescriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetContentStateDescriptionView.swift; sourceTree = ""; }; 2CB1962328EF27F30075F7EF /* UIKitViewControllerPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitViewControllerPreview.swift; sourceTree = ""; }; 2CB279AC28C72A9500EDDCC8 /* TabBarRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarRouter.swift; sourceTree = ""; }; 2CB279AE28C72AA400EDDCC8 /* DeepLinkRouterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkRouterProtocol.swift; sourceTree = ""; }; @@ -1063,10 +1088,15 @@ 2CE31F4727F1BB79008EEE66 /* AuthSocialAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSocialAssembly.swift; sourceTree = ""; }; 2CE31F4A27F1E070008EEE66 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = ""; }; 2CE31F4C27F1E0C8008EEE66 /* AppAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAssembly.swift; sourceTree = ""; }; + 2CE58C592B07662300E5EBBE /* ChallengeWidgetContentStateProgressGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetContentStateProgressGridView.swift; sourceTree = ""; }; + 2CE58C5B2B0768F300E5EBBE /* ChallengeWidgetContentStateProgressGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetContentStateProgressGridItemView.swift; sourceTree = ""; }; 2CE7B4832AB0593F00DCBE4D /* AttributedTextLabelWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedTextLabelWrapper.swift; sourceTree = ""; }; 2CE7B4862AB05D0400DCBE4D /* StepQuizParsonsViewDataMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizParsonsViewDataMapper.swift; sourceTree = ""; }; 2CE7B4892AB0973D00DCBE4D /* HTMLString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLString.swift; sourceTree = ""; }; 2CE7B48E2AB0A09100DCBE4D /* HTMLStringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLStringTests.swift; sourceTree = ""; }; + 2CE8EE6C2B065F00004EB545 /* ChallengeWidgetContentStateDeadlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetContentStateDeadlineView.swift; sourceTree = ""; }; + 2CE8EE6E2B066C2F004EB545 /* ChallengeWidgetContentStateCollectRewardButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetContentStateCollectRewardButton.swift; sourceTree = ""; }; + 2CE8EE702B066D05004EB545 /* ChallengeWidgetViewStateContentCollectRewardButtonStateKsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetViewStateContentCollectRewardButtonStateKsExtensions.swift; sourceTree = ""; }; 2CEB33762949930B00B9E437 /* StepQuizSQLSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizSQLSkeletonView.swift; sourceTree = ""; }; 2CEB50C5288A92820044F9AB /* StepExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepExtensions.swift; sourceTree = ""; }; 2CEB50C7288A94050044F9AB /* BlockExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockExtensions.swift; sourceTree = ""; }; @@ -1495,6 +1525,8 @@ children = ( 2C1860FB2923C540007D4EBF /* AppFeatureStateKsExtensions.swift */, 2C93C2D7292EBBB5004D1861 /* AuthSocialFeatureStateKsExtensions.swift */, + 2CE8EE702B066D05004EB545 /* ChallengeWidgetViewStateContentCollectRewardButtonStateKsExtensions.swift */, + 2CB0ADFD2B04DD170089D557 /* ChallengeWidgetViewStateKsExtensions.swift */, 2C8CD9AD2994EFC5008DC09D /* DebugFeatureViewStateKsExtensions.swift */, E9A1DA6F2ACFF86B006A9D4B /* FirstProblemOnboardingFeatureViewStateKsExtensions.swift */, 2CC7833D295DAE3E00A867CD /* OnboardingFeatureStateKsExtensions.swift */, @@ -1568,9 +1600,9 @@ E9A1DA642ACFF10D006A9D4B /* FirstProblemOnboarding */, 2C5F4A582971C6C500677530 /* GamificationToolbar */, 2C963BC32812D16C0036DD53 /* Home */, + 2CB0ADE92B04AC9E0089D557 /* HomeSubmodules */, B58361EACE24BF4B761F10BA /* NotificationsOnboarding */, E9F923F428A2632800C065A7 /* Onboarding */, - E9F655CF2875B31B00291143 /* ProblemOfDay */, E9B55A5329C89FFF0066900E /* ProblemsLimit */, 2C9EB95B2861BAAE007DDE44 /* Profile */, 2C963BC82812D3410036DD53 /* ProfileSettings */, @@ -1583,7 +1615,6 @@ E9F655CE2875B30B00291143 /* Streak */, E9F0A2A729D416AC00C4A61E /* StudyPlan */, E9A022AB291D0E1C004317DB /* TopicsRepetitions */, - E9A5B98A2924FACD00EF0F39 /* TopicsRepetitionsCard */, 2C2600862A2001E600BD3D39 /* TrackSelection */, ); path = Modules; @@ -2849,6 +2880,51 @@ path = Theme; sourceTree = ""; }; + 2CB0ADE92B04AC9E0089D557 /* HomeSubmodules */ = { + isa = PBXGroup; + children = ( + 2CB0ADEA2B04ACDB0089D557 /* ChallengeWidget */, + E9F655CF2875B31B00291143 /* ProblemOfDay */, + E9A5B98A2924FACD00EF0F39 /* TopicsRepetitions */, + ); + path = HomeSubmodules; + sourceTree = ""; + }; + 2CB0ADEA2B04ACDB0089D557 /* ChallengeWidget */ = { + isa = PBXGroup; + children = ( + 2CB0ADF42B04BC8E0089D557 /* ChallengeWidgetAssembly.swift */, + 2CB0ADF12B04BB310089D557 /* ChallengeWidgetOutputProtocol.swift */, + 2CB0ADED2B04AD6D0089D557 /* ChallengeWidgetViewModel.swift */, + 2CB0ADF32B04BBA20089D557 /* Views */, + ); + path = ChallengeWidget; + sourceTree = ""; + }; + 2CB0ADF32B04BBA20089D557 /* Views */ = { + isa = PBXGroup; + children = ( + 2CB0ADEF2B04B2E30089D557 /* ChallengeWidgetErrorView.swift */, + 2CB0ADF62B04CCBA0089D557 /* ChallengeWidgetSkeletonView.swift */, + 2CB0ADEB2B04AD550089D557 /* ChallengeWidgetView.swift */, + 2CB0ADFA2B04D3010089D557 /* Content */, + ); + path = Views; + sourceTree = ""; + }; + 2CB0ADFA2B04D3010089D557 /* Content */ = { + isa = PBXGroup; + children = ( + 2CE8EE6E2B066C2F004EB545 /* ChallengeWidgetContentStateCollectRewardButton.swift */, + 2CE8EE6C2B065F00004EB545 /* ChallengeWidgetContentStateDeadlineView.swift */, + 2CB0ADFF2B0525020089D557 /* ChallengeWidgetContentStateDescriptionView.swift */, + 2CB0ADFB2B04D3180089D557 /* ChallengeWidgetContentStateHeaderView.swift */, + 2CB0ADF82B04D0EF0089D557 /* ChallengeWidgetContentStateView.swift */, + 2CE58C5D2B07690700E5EBBE /* ProgressGrid */, + ); + path = Content; + sourceTree = ""; + }; 2CB279AB28C7279800EDDCC8 /* Routers */ = { isa = PBXGroup; children = ( @@ -3136,6 +3212,15 @@ path = App; sourceTree = ""; }; + 2CE58C5D2B07690700E5EBBE /* ProgressGrid */ = { + isa = PBXGroup; + children = ( + 2CE58C5B2B0768F300E5EBBE /* ChallengeWidgetContentStateProgressGridItemView.swift */, + 2CE58C592B07662300E5EBBE /* ChallengeWidgetContentStateProgressGridView.swift */, + ); + path = ProgressGrid; + sourceTree = ""; + }; 2CE7B4852AB05AA300DCBE4D /* StepQuizSubmodules */ = { isa = PBXGroup; children = ( @@ -3604,14 +3689,14 @@ path = Views; sourceTree = ""; }; - E9A5B98A2924FACD00EF0F39 /* TopicsRepetitionsCard */ = { + E9A5B98A2924FACD00EF0F39 /* TopicsRepetitions */ = { isa = PBXGroup; children = ( 2C7036E92943A34000775E87 /* TopicsRepetitionsCardSkeletonView.swift */, E9A022AD291D0E3F004317DB /* TopicsRepetitionsCardView.swift */, E94BC941291E89D6000B18D3 /* TopicsRepetitionsCountView.swift */, ); - path = TopicsRepetitionsCard; + path = TopicsRepetitions; sourceTree = ""; }; E9A5B98C2924FB1F00EF0F39 /* Chart */ = { @@ -4135,6 +4220,7 @@ 2C2D492E281151E100753F16 /* AuthCredentialsAssembly.swift in Sources */, E94BB04C2A9DFCCF00736B7C /* StepQuizParsonsViewData.swift in Sources */, 2C3100532AB194A200C09BFB /* StepQuizParsonsViewDataMapperCodeContentCache.swift in Sources */, + 2CE8EE712B066D05004EB545 /* ChallengeWidgetViewStateContentCollectRewardButtonStateKsExtensions.swift in Sources */, 2CDA984929445C0A00ADE539 /* ProfileStatisticsItemView.swift in Sources */, E9F655D12875B32700291143 /* ProblemOfDayCardView.swift in Sources */, E99CD0BC292B9A2D00620259 /* TopicsRepetitionsViewModel.swift in Sources */, @@ -4264,6 +4350,7 @@ 2CEB50D0288AADA40044F9AB /* StepQuizCodeFullScreenDetailsView.swift in Sources */, E9A1DA702ACFF86B006A9D4B /* FirstProblemOnboardingFeatureViewStateKsExtensions.swift in Sources */, 2C8E4FB42848CB980011ADFA /* StepQuizTableSelectColumnsColumnView.swift in Sources */, + 2CB0ADF72B04CCBA0089D557 /* ChallengeWidgetSkeletonView.swift in Sources */, 2CEB33772949930B00B9E437 /* StepQuizSQLSkeletonView.swift in Sources */, 2CB45762288EC29D007C2D77 /* StepQuizActionButtons.swift in Sources */, E9859B91292E414300857679 /* RepeatButtonInfo.swift in Sources */, @@ -4357,6 +4444,7 @@ 2CA7B88F2893295A00A789EF /* CodeEditorSuggestionsPresentationContextProviding.swift in Sources */, 2C8E4F9A284897360011ADFA /* PanModalSwiftUIViewController.swift in Sources */, 2C198DFE2AEA444100DCD35A /* FillBlanksSelectContainerView.swift in Sources */, + 2CB0ADFE2B04DD170089D557 /* ChallengeWidgetViewStateKsExtensions.swift in Sources */, 2C20FBA6284F1924006D879E /* ContentProcessingInjection.swift in Sources */, 2C1061A8285C3A2D00EBD614 /* StepQuizChildQuizType.swift in Sources */, 2CE31F4D27F1E0C8008EEE66 /* AppAssembly.swift in Sources */, @@ -4407,6 +4495,7 @@ 2CBC97D02A555BE60078E445 /* HypercoinsAwardView.swift in Sources */, 2CA8E094281039EB00154088 /* RoundedRectangleButtonStyle.swift in Sources */, E9D537D02A71056100F21828 /* ProfileBadgesGridItemView.swift in Sources */, + 2CB0ADEE2B04AD6D0089D557 /* ChallengeWidgetViewModel.swift in Sources */, E9CC6C0729893F2200D8D070 /* StepQuizInputProtocol.swift in Sources */, 2C96743728882A0C0091B6C9 /* StepQuizCodeDetailsView.swift in Sources */, 2C20FBC7284F6928006D879E /* ProgrammaticallyInitializableViewProtocol.swift in Sources */, @@ -4416,6 +4505,7 @@ E9F0A2B029D4428C00C4A61E /* StudyPlanView.swift in Sources */, 2CDBE6F528C10DCE00033679 /* NotificationPermissionStatusSettingsObserver.swift in Sources */, 2C5B2A21286596030097B270 /* Reusable.swift in Sources */, + 2CB0ADF22B04BB310089D557 /* ChallengeWidgetOutputProtocol.swift in Sources */, 2C4605B12ABD75FC003C17E9 /* View+ScrollBounceBehavior.swift in Sources */, 2C198E012AEA835F00DCD35A /* StepQuizFillBlanksSelectOptionsView.swift in Sources */, E96D493B2A9CCF3600BD78FE /* StepQuizParsonsItemView.swift in Sources */, @@ -4470,15 +4560,19 @@ 2CA8E095281039EB00154088 /* BounceButtonStyle.swift in Sources */, 2CEEE03328916A3D00282849 /* ProblemOfDayViewModel.swift in Sources */, 2C7CB6762ADFCFCC006F78DA /* StepQuizFillBlanksViewData.swift in Sources */, + 2CB0ADF02B04B2E30089D557 /* ChallengeWidgetErrorView.swift in Sources */, 2C05AC5F2A0ED9710039C7EF /* BadgeView+ConcreateTypes.swift in Sources */, 2C1061AC285C3C4300EBD614 /* StepQuizChoiceViewModel.swift in Sources */, 2C0EB9502A151B56006DC84B /* TrackSelectionListViewModel.swift in Sources */, E9ACD3412937342F0005E05B /* ProblemOfDaySolvedModalViewController.swift in Sources */, 2C0EB9562A15296D006DC84B /* TrackSelectionListFeatureViewStateContent+Placeholder.swift in Sources */, + 2CB0AE002B0525020089D557 /* ChallengeWidgetContentStateDescriptionView.swift in Sources */, + 2CE58C5A2B07662300E5EBBE /* ChallengeWidgetContentStateProgressGridView.swift in Sources */, E9D537D22A71330A00F21828 /* LinearGradientProgressView.swift in Sources */, 2C4F63A12A102D3300D4EE39 /* SharedProjectLevelWrapper.swift in Sources */, E97EDB002A8F595E00CABF8E /* BadgeEarnedModalViewController.swift in Sources */, 2CD48D8B2858684100CFCC4A /* StepQuizViewData.swift in Sources */, + 2CB0ADF52B04BC8E0089D557 /* ChallengeWidgetAssembly.swift in Sources */, 2C05AC572A0EC9E50039C7EF /* ProjectSelectionListHeaderSkeletonView.swift in Sources */, E9FB89AC2893EA580011EFFB /* NotificationPermissionStatus.swift in Sources */, 2C4FBD8C2876C39C00ACA5C8 /* ProfileAboutView.swift in Sources */, @@ -4487,9 +4581,11 @@ E98BE36D2A374394000B430F /* StreakRecoveryModalView.swift in Sources */, 2CAE8D0C2805829A00E6C83D /* StepViewData.swift in Sources */, 2C079681285CEEFE00EE0487 /* StepQuizMatchingViewModel.swift in Sources */, + 2CB0ADFC2B04D3180089D557 /* ChallengeWidgetContentStateHeaderView.swift in Sources */, E9AD65AA292DC0BE00E574F0 /* TopicsRepetitionsFeatureStateKsExtensions.swift in Sources */, 2C23C00D2879F2290083709F /* StreakView.swift in Sources */, 2C20FBCB284F7A45006D879E /* ProcessedContentView.swift in Sources */, + 2CE58C5C2B0768F300E5EBBE /* ChallengeWidgetContentStateProgressGridItemView.swift in Sources */, E9A1DA6E2ACFF428006A9D4B /* FirstProblemOnboardingViewModel.swift in Sources */, 2C1F587B280D2E5100372A37 /* URLExtensions.swift in Sources */, 2C7036E82943A2A800775E87 /* ProblemOfDaySkeletonView.swift in Sources */, @@ -4555,6 +4651,7 @@ 2CC78D0E28C75A3D0006EF91 /* UIViewControllerExtensions.swift in Sources */, E9F7A5E028AFC2000063913F /* NotificationDescriptionPlainObject.swift in Sources */, 2C336D222865E37800C91342 /* CodeInputAccessoryButtonData.swift in Sources */, + 2CB0ADF92B04D0EF0089D557 /* ChallengeWidgetContentStateView.swift in Sources */, 2C7822612942F9CF0067200F /* StreakBarButtonItem.swift in Sources */, 2C7802C5285C93F900082547 /* StepQuizActionButtonState+SubmissionStatus.swift in Sources */, 2C93AF1F29B34A88004639E0 /* StepQuizPyCharmViewModel.swift in Sources */, @@ -4581,6 +4678,7 @@ 2C9CC1BD280920B5006604D7 /* KeyboardManager.swift in Sources */, 2CD4148329A8C98000ACA855 /* OpaqueUIPasteControl.swift in Sources */, 2CF7C1B12A8355D3006B07ED /* BadgeLockedImageView.swift in Sources */, + 2CE8EE6F2B066C2F004EB545 /* ChallengeWidgetContentStateCollectRewardButton.swift in Sources */, 2C32375328380C340062CAF6 /* NavigationToolbarInfoItem.swift in Sources */, 2C406C372A440E8200FA838E /* BuildVariant+Current.swift in Sources */, 2CAA3C6A2AA9C7B6004F6CE6 /* LottieAnimations.swift in Sources */, @@ -4624,11 +4722,13 @@ D9B929495D696A140BA3D150 /* TrackSelectionDetailsViewModel.swift in Sources */, ECD10958C8BA7D758D3D1F66 /* ProjectSelectionDetailsAssembly.swift in Sources */, 018CAC44EED7A992000ECF87 /* ProjectSelectionDetailsView.swift in Sources */, + 2CB0ADEC2B04AD550089D557 /* ChallengeWidgetView.swift in Sources */, AE0B2D1D267B8904498FA371 /* ProjectSelectionDetailsViewModel.swift in Sources */, 2C8DD4092AFB7DFD00FD5359 /* ShareStreakModalViewController.swift in Sources */, 0809817CFCC9D4C45457B3C8 /* ProgressScreenAssembly.swift in Sources */, 59B66CD4D1508049555D35AE /* ProgressScreenView.swift in Sources */, 2CE7B4842AB0593F00DCBE4D /* AttributedTextLabelWrapper.swift in Sources */, + 2CE8EE6D2B065F00004EB545 /* ChallengeWidgetContentStateDeadlineView.swift in Sources */, F759010A5FC990E99AAF0D76 /* ProgressScreenViewModel.swift in Sources */, DA48146596C2AB3F4E68208E /* NotificationsOnboardingAssembly.swift in Sources */, B2B30D0486FC13DCC80F4263 /* NotificationsOnboardingView.swift in Sources */, @@ -4673,7 +4773,7 @@ buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 236; + CURRENT_PROJECT_VERSION = 239; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 3DWS674B2M; GENERATE_INFOPLIST_FILE = NO; @@ -4694,7 +4794,7 @@ buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 236; + CURRENT_PROJECT_VERSION = 239; DEVELOPMENT_TEAM = 3DWS674B2M; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = iosHyperskillAppUITests/Info.plist; @@ -4715,7 +4815,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 236; + CURRENT_PROJECT_VERSION = 239; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 3DWS674B2M; INFOPLIST_FILE = iosHyperskillAppTests/Info.plist; @@ -4736,7 +4836,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 236; + CURRENT_PROJECT_VERSION = 239; DEVELOPMENT_TEAM = 3DWS674B2M; INFOPLIST_FILE = iosHyperskillAppTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -4757,7 +4857,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 236; + CURRENT_PROJECT_VERSION = 239; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 3DWS674B2M; INFOPLIST_FILE = NotificationServiceExtension/Info.plist; @@ -4785,7 +4885,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 236; + CURRENT_PROJECT_VERSION = 239; DEVELOPMENT_TEAM = 3DWS674B2M; INFOPLIST_FILE = NotificationServiceExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -4930,7 +5030,7 @@ CODE_SIGN_ENTITLEMENTS = iosHyperskillApp/iosHyperskillApp.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 236; + CURRENT_PROJECT_VERSION = 239; DEVELOPMENT_ASSET_PATHS = "\"iosHyperskillApp/Preview Content\""; DEVELOPMENT_TEAM = 3DWS674B2M; ENABLE_PREVIEWS = YES; @@ -4966,7 +5066,7 @@ CODE_SIGN_ENTITLEMENTS = iosHyperskillApp/iosHyperskillApp.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 236; + CURRENT_PROJECT_VERSION = 239; DEVELOPMENT_ASSET_PATHS = "\"iosHyperskillApp/Preview Content\""; DEVELOPMENT_TEAM = 3DWS674B2M; ENABLE_PREVIEWS = YES; diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/Contents.json b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-announcement.imageset/Contents.json b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-announcement.imageset/Contents.json new file mode 100644 index 0000000000..4eafc16e26 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-announcement.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "challenge-widget-status-announcement.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-announcement.imageset/challenge-widget-status-announcement.pdf b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-announcement.imageset/challenge-widget-status-announcement.pdf new file mode 100644 index 0000000000..1cb8891012 Binary files /dev/null and b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-announcement.imageset/challenge-widget-status-announcement.pdf differ diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-completed.imageset/Contents.json b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-completed.imageset/Contents.json new file mode 100644 index 0000000000..972cdc5e37 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-completed.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "challenge-widget-status-completed.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-completed.imageset/challenge-widget-status-completed.pdf b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-completed.imageset/challenge-widget-status-completed.pdf new file mode 100644 index 0000000000..995f197bb0 Binary files /dev/null and b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-completed.imageset/challenge-widget-status-completed.pdf differ diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-ended.imageset/Contents.json b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-ended.imageset/Contents.json new file mode 100644 index 0000000000..415a213daf --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-ended.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "challenge-widget-status-ended.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-ended.imageset/challenge-widget-status-ended.pdf b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-ended.imageset/challenge-widget-status-ended.pdf new file mode 100644 index 0000000000..998e8f9a70 Binary files /dev/null and b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-ended.imageset/challenge-widget-status-ended.pdf differ diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-happening-now.imageset/Contents.json b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-happening-now.imageset/Contents.json new file mode 100644 index 0000000000..c9762397e1 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-happening-now.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "challenge-widget-status-happening-now.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-happening-now.imageset/challenge-widget-status-happening-now.pdf b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-happening-now.imageset/challenge-widget-status-happening-now.pdf new file mode 100644 index 0000000000..15fd25c008 Binary files /dev/null and b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-happening-now.imageset/challenge-widget-status-happening-now.pdf differ diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-partially-completed.imageset/Contents.json b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-partially-completed.imageset/Contents.json new file mode 100644 index 0000000000..2ec36cd0be --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-partially-completed.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "challenge-widget-status-partially-completed.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-partially-completed.imageset/challenge-widget-status-partially-completed.pdf b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-partially-completed.imageset/challenge-widget-status-partially-completed.pdf new file mode 100644 index 0000000000..09f4e41b7c Binary files /dev/null and b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Home/ChallengeWidget/challenge-widget-status-partially-completed.imageset/challenge-widget-status-partially-completed.pdf differ diff --git a/iosHyperskillApp/iosHyperskillApp/Info.plist b/iosHyperskillApp/iosHyperskillApp/Info.plist index 459bb92a4c..e0d3a57fa6 100644 --- a/iosHyperskillApp/iosHyperskillApp/Info.plist +++ b/iosHyperskillApp/iosHyperskillApp/Info.plist @@ -36,7 +36,7 @@ CFBundleVersion - 236 + 239 FirebaseAppDelegateProxyEnabled FirebaseMessagingAutoInitEnabled diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Collections/LinkedList.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Collections/LinkedList.swift index d3d40e9af2..666aea4cb0 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Collections/LinkedList.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Collections/LinkedList.swift @@ -1,12 +1,12 @@ import Foundation final class LinkedList { - class Node { - var value: T + class Node { + var value: NodeValueType var next: Node? weak var previous: Node? - init(value: T) { + init(value: NodeValueType) { self.value = value } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/ContentProcessor/View/UIKit/ProcessedContentView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/ContentProcessor/View/UIKit/ProcessedContentView.swift index e9fa72d061..78b2d835c7 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/ContentProcessor/View/UIKit/ProcessedContentView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/ContentProcessor/View/UIKit/ProcessedContentView.swift @@ -300,7 +300,7 @@ extension ProcessedContentView: ProgrammaticallyInitializableViewProtocol { self.contentView.translatesAutoresizingMaskIntoConstraints = false self.contentView.snp.makeConstraints { make in make.edges.equalToSuperview() - self.contentViewHeightConstraint = make.height.equalTo(0).constraint + self.contentViewHeightConstraint = make.height.equalTo(0).priority(.low).constraint } self.activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/ChallengeWidgetViewStateContentCollectRewardButtonStateKsExtensions.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/ChallengeWidgetViewStateContentCollectRewardButtonStateKsExtensions.swift new file mode 100644 index 0000000000..f3f0df22ff --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/ChallengeWidgetViewStateContentCollectRewardButtonStateKsExtensions.swift @@ -0,0 +1,20 @@ +import Foundation +import shared + +extension ChallengeWidgetViewStateContentCollectRewardButtonStateKs: Equatable { + public static func == ( + lhs: ChallengeWidgetViewStateContentCollectRewardButtonStateKs, + rhs: ChallengeWidgetViewStateContentCollectRewardButtonStateKs + ) -> Bool { + switch (lhs, rhs) { + case (.hidden, .hidden): + true + case (.visible(let lhsData), .visible(let rhsData)): + lhsData.isEqual(rhsData) + case (.visible, .hidden): + false + case (.hidden, .visible): + false + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/ChallengeWidgetViewStateKsExtensions.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/ChallengeWidgetViewStateKsExtensions.swift new file mode 100644 index 0000000000..6fd7763ee4 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/ChallengeWidgetViewStateKsExtensions.swift @@ -0,0 +1,116 @@ +import Foundation +import shared + +extension ChallengeWidgetViewStateKs: Equatable { + public static func == (lhs: ChallengeWidgetViewStateKs, rhs: ChallengeWidgetViewStateKs) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle): + true + case (.empty, .empty): + true + case (.error, .error): + true + case (.loading(let lhsData), .loading(let rhsData)): + lhsData.isEqual(rhsData) + case (.content(let lhsSealedState), .content(let rhsSealedState)): + ChallengeWidgetViewStateContentKs(lhsSealedState) == ChallengeWidgetViewStateContentKs(rhsSealedState) + case (.content, .empty): + false + case (.content, .error): + false + case (.content, .idle): + false + case (.content, .loading): + false + case (.loading, .content): + false + case (.loading, .empty): + false + case (.loading, .error): + false + case (.loading, .idle): + false + case (.error, .content): + false + case (.error, .empty): + false + case (.error, .idle): + false + case (.error, .loading): + false + case (.empty, .content): + false + case (.empty, .error): + false + case (.empty, .idle): + false + case (.empty, .loading): + false + case (.idle, .content): + false + case (.idle, .empty): + false + case (.idle, .error): + false + case (.idle, .loading): + false + } + } +} + +extension ChallengeWidgetViewStateContentKs: Equatable { + public static func == (lhs: ChallengeWidgetViewStateContentKs, rhs: ChallengeWidgetViewStateContentKs) -> Bool { + switch (lhs, rhs) { + case (.announcement(let lhsData), .announcement(let rhsData)): + lhsData.isEqual(rhsData) + case (.completed(let lhsData), .completed(let rhsData)): + lhsData.isEqual(rhsData) + case (.ended(let lhsData), .ended(let rhsData)): + lhsData.isEqual(rhsData) + case (.happeningNow(let lhsData), .happeningNow(let rhsData)): + lhsData.isEqual(rhsData) + case (.partiallyCompleted(let lhsData), .partiallyCompleted(let rhsData)): + lhsData.isEqual(rhsData) + case (.partiallyCompleted, .announcement): + false + case (.partiallyCompleted, .completed): + false + case (.partiallyCompleted, .ended): + false + case (.partiallyCompleted, .happeningNow): + false + case (.happeningNow, .announcement): + false + case (.happeningNow, .completed): + false + case (.happeningNow, .ended): + false + case (.happeningNow, .partiallyCompleted): + false + case (.ended, .announcement): + false + case (.ended, .completed): + false + case (.ended, .happeningNow): + false + case (.ended, .partiallyCompleted): + false + case (.completed, .announcement): + false + case (.completed, .ended): + false + case (.completed, .happeningNow): + false + case (.completed, .partiallyCompleted): + false + case (.announcement, .completed): + false + case (.announcement, .ended): + false + case (.announcement, .happeningNow): + false + case (.announcement, .partiallyCompleted): + false + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift index f34d15d224..afa125dc80 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift @@ -255,6 +255,12 @@ enum Strings { static let repeatUnlimited = sharedStrings.home_repeat_unlimited.localized() } + // MARK: - Challenge widget - + + enum ChallengeWidget { + static let networkError = sharedStrings.challenge_widget_network_error_text.localized() + } + // MARK: - Topics widget - enum TopicsWidget { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/GemsBarButtonItem.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/GemsBarButtonItem.swift index 11d6190a49..b2c8a4a00d 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/GemsBarButtonItem.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/GemsBarButtonItem.swift @@ -17,6 +17,7 @@ struct GemsBarButtonItem: View { Text("\(hypercoinsBalance)") .foregroundColor(.primaryText) + .animation(.default, value: hypercoinsBalance) } } ) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/StreakBarButtonItem.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/StreakBarButtonItem.swift index a30df7b89e..cb96531714 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/StreakBarButtonItem.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/StreakBarButtonItem.swift @@ -23,6 +23,7 @@ struct StreakBarButtonItem: View { Text("\(currentStreak)") .foregroundColor(.primaryText) + .animation(.default, value: currentStreak) } } ) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeViewModel.swift index 27aad01049..fe552e5655 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeViewModel.swift @@ -1,12 +1,13 @@ import shared import UIKit -final class HomeViewModel: FeatureViewModel { +final class HomeViewModel: FeatureViewModel { private var applicationWasInBackground = false private var shouldReloadContent = false var homeStateKs: HomeFeatureHomeStateKs { .init(state.homeState) } var gamificationToolbarStateKs: GamificationToolbarFeatureStateKs { .init(state.toolbarState) } + var challengeWidgetViewStateKs: ChallengeWidgetViewStateKs { .init(state.challengeWidgetViewState) } init(feature: Presentation_reduxFeature) { super.init(feature: feature) @@ -25,7 +26,10 @@ final class HomeViewModel: FeatureViewModel Bool { + override func shouldNotifyStateDidChange( + oldState: HomeFeature.ViewState, + newState: HomeFeature.ViewState + ) -> Bool { !oldState.isEqual(newState) } @@ -120,3 +124,39 @@ extension HomeViewModel: ProblemOfDayOutputProtocol { } } } + +// MAKR: - HomeViewModel: ChallengeWidgetOutputProtocol - + +extension HomeViewModel: ChallengeWidgetOutputProtocol { + func handleChallengeWidgetRetryContentLoading() { + onNewMessage( + HomeFeatureMessageChallengeWidgetMessage( + message: ChallengeWidgetFeatureMessageRetryContentLoading() + ) + ) + } + + func handleChallengeWidgetOpenDescriptionLink(url: URL) { + onNewMessage( + HomeFeatureMessageChallengeWidgetMessage( + message: ChallengeWidgetFeatureMessageLinkInTheDescriptionClicked(url: url.absoluteString) + ) + ) + } + + func handleChallengeWidgetDeadlineReachedReload() { + onNewMessage( + HomeFeatureMessageChallengeWidgetMessage( + message: ChallengeWidgetFeatureMessageDeadlineReachedReloadClicked() + ) + ) + } + + func handleChallengeWidgetCollectReward() { + onNewMessage( + HomeFeatureMessageChallengeWidgetMessage( + message: ChallengeWidgetFeatureMessageCollectRewardClicked() + ) + ) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/Views/HomeView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/Views/HomeView.swift index 873b93ee9c..3de5159cd1 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/Views/HomeView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/Views/HomeView.swift @@ -74,6 +74,16 @@ struct HomeView: View { VStack(alignment: .leading, spacing: appearance.spacingBetweenContainers) { HomeSubheadlineView() + let challengeWidgetViewStateKs = viewModel.challengeWidgetViewStateKs + if challengeWidgetViewStateKs != .empty { + ChallengeWidgetAssembly( + challengeWidgetViewStateKs: challengeWidgetViewStateKs, + moduleOutput: viewModel + ) + .makeModule() + .equatable() + } + ProblemOfDayAssembly( problemOfDayState: data.problemOfDayState, isFreemiumEnabled: data.isFreemiumEnabled, @@ -101,8 +111,12 @@ struct HomeView: View { .frame(maxWidth: .infinity) } } +} + +// MARK: - HomeView (ViewAction) - - private func handleViewAction(_ viewAction: HomeFeatureActionViewAction) { +private extension HomeView { + func handleViewAction(_ viewAction: HomeFeatureActionViewAction) { switch HomeFeatureActionViewActionKs(viewAction) { case .navigateTo(let navigateToViewAction): switch HomeFeatureActionViewActionNavigateToKs(navigateToViewAction) { @@ -121,12 +135,28 @@ struct HomeView: View { let assembly = ProgressScreenAssembly() stackRouter.pushViewController(assembly.makeModule()) } + case .challengeWidgetViewAction(let challengeWidgetViewAction): + handleChallengeWidgetViewAction( + viewAction: challengeWidgetViewAction.viewAction + ) + } + } + + func handleChallengeWidgetViewAction(viewAction: ChallengeWidgetFeatureActionViewAction) { + switch ChallengeWidgetFeatureActionViewActionKs(viewAction) { + case .openUrl(let openUrlViewAction): + WebControllerManager.shared.presentWebControllerWithURLString( + openUrlViewAction.url, + withKey: .externalLink, + controllerType: openUrlViewAction.shouldOpenInApp ? .inAppSafari : .safari + ) + case .showNetworkError: + ProgressHUD.showError() } } } +@available(iOS 17, *) #Preview { - UIKitViewControllerPreview { - HomeAssembly().makeModule() - } + HomeAssembly().makeModule() } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/ChallengeWidgetAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/ChallengeWidgetAssembly.swift new file mode 100644 index 0000000000..fe7883e06f --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/ChallengeWidgetAssembly.swift @@ -0,0 +1,23 @@ +import shared +import SwiftUI + +final class ChallengeWidgetAssembly: Assembly { + weak var moduleOutput: ChallengeWidgetOutputProtocol? + + private let challengeWidgetViewStateKs: ChallengeWidgetViewStateKs + + init(challengeWidgetViewStateKs: ChallengeWidgetViewStateKs, moduleOutput: ChallengeWidgetOutputProtocol?) { + self.moduleOutput = moduleOutput + self.challengeWidgetViewStateKs = challengeWidgetViewStateKs + } + + func makeModule() -> ChallengeWidgetView { + let viewModel = ChallengeWidgetViewModel() + viewModel.moduleOutput = moduleOutput + + return ChallengeWidgetView( + viewStateKs: challengeWidgetViewStateKs, + viewModel: viewModel + ) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/ChallengeWidgetOutputProtocol.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/ChallengeWidgetOutputProtocol.swift new file mode 100644 index 0000000000..6ab94dfa8f --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/ChallengeWidgetOutputProtocol.swift @@ -0,0 +1,8 @@ +import Foundation + +protocol ChallengeWidgetOutputProtocol: AnyObject { + func handleChallengeWidgetRetryContentLoading() + func handleChallengeWidgetOpenDescriptionLink(url: URL) + func handleChallengeWidgetDeadlineReachedReload() + func handleChallengeWidgetCollectReward() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/ChallengeWidgetViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/ChallengeWidgetViewModel.swift new file mode 100644 index 0000000000..ad27cf5138 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/ChallengeWidgetViewModel.swift @@ -0,0 +1,21 @@ +import Foundation + +final class ChallengeWidgetViewModel { + weak var moduleOutput: ChallengeWidgetOutputProtocol? + + func doRetryContentLoading() { + moduleOutput?.handleChallengeWidgetRetryContentLoading() + } + + func doOpenDescriptionLink(_ url: URL) { + moduleOutput?.handleChallengeWidgetOpenDescriptionLink(url: url) + } + + func doDeadlineReloadAction() { + moduleOutput?.handleChallengeWidgetDeadlineReachedReload() + } + + func doCollectReward() { + moduleOutput?.handleChallengeWidgetCollectReward() + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/ChallengeWidgetErrorView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/ChallengeWidgetErrorView.swift new file mode 100644 index 0000000000..0ce4de0388 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/ChallengeWidgetErrorView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct ChallengeWidgetErrorView: View { + let backgroundColor: Color + + let action: () -> Void + + var body: some View { + PlaceholderView( + configuration: .init( + presentationMode: .local, + image: .reload, + title: .init(text: Strings.ChallengeWidget.networkError), + button: .init(text: Strings.Placeholder.networkErrorButtonText, action: action, style: .outline()), + primaryContentAlignment: .horizontal(), + interItemSpacing: LayoutInsets.defaultInset, + backgroundColor: .clear + ) + ) + .padding() + .background(backgroundColor) + .addBorder() + } +} + +#Preview { + ChallengeWidgetErrorView( + backgroundColor: .systemBackground, + action: {} + ) + .padding() + .background(Color.systemGroupedBackground) +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/ChallengeWidgetSkeletonView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/ChallengeWidgetSkeletonView.swift new file mode 100644 index 0000000000..4410eebc7b --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/ChallengeWidgetSkeletonView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct ChallengeWidgetSkeletonView: View { + var body: some View { + SkeletonRoundedView() + .frame(height: 128) + } +} + +#Preview { + ChallengeWidgetSkeletonView() + .padding() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/ChallengeWidgetView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/ChallengeWidgetView.swift new file mode 100644 index 0000000000..c6e1aca24d --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/ChallengeWidgetView.swift @@ -0,0 +1,65 @@ +import shared +import SwiftUI + +extension ChallengeWidgetView { + struct Appearance { + let backgroundColor = Color(ColorPalette.surface) + } +} + +struct ChallengeWidgetView: View { + private(set) var appearance = Appearance() + + let viewStateKs: ChallengeWidgetViewStateKs + + let viewModel: ChallengeWidgetViewModel + + var body: some View { + buildBody() + } + + @ViewBuilder + private func buildBody() -> some View { + switch viewStateKs { + case .idle: + EmptyView() + case .loading(let data): + if data.shouldShowSkeleton { + ChallengeWidgetSkeletonView() + } else { + EmptyView() + } + case .error: + ChallengeWidgetErrorView( + backgroundColor: appearance.backgroundColor, + action: viewModel.doRetryContentLoading + ) + case .empty: + EmptyView() + case .content(let contentState): + ChallengeWidgetContentStateView( + appearance: .init(backgroundColor: appearance.backgroundColor), + stateKs: .init(contentState), + onOpenDescriptionLink: viewModel.doOpenDescriptionLink(_:), + onDeadlineReloadTap: viewModel.doDeadlineReloadAction, + onCollectRewardTap: viewModel.doCollectReward + ) + .equatable() + } + } +} + +extension ChallengeWidgetView: Equatable { + static func == (lhs: ChallengeWidgetView, rhs: ChallengeWidgetView) -> Bool { + lhs.viewStateKs == rhs.viewStateKs + } +} + +#Preview("Error") { + ChallengeWidgetView( + viewStateKs: .error, + viewModel: ChallengeWidgetViewModel() + ) + .padding() + .background(Color.systemGroupedBackground) +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateCollectRewardButton.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateCollectRewardButton.swift new file mode 100644 index 0000000000..9ea6474b99 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateCollectRewardButton.swift @@ -0,0 +1,50 @@ +import shared +import SwiftUI + +struct ChallengeWidgetContentStateCollectRewardButton: View { + let collectRewardButtonStateKs: ChallengeWidgetViewStateContentCollectRewardButtonStateKs + + let action: () -> Void + + var body: some View { + switch collectRewardButtonStateKs { + case .hidden: + EmptyView() + case .visible(let data): + Button( + data.title, + action: action + ) + .buttonStyle(RoundedRectangleButtonStyle(style: .violet)) + } + } +} + +extension ChallengeWidgetContentStateCollectRewardButton { + init( + collectRewardButtonState: ChallengeWidgetViewStateContentCollectRewardButtonState, + action: @escaping () -> Void + ) { + self.init( + collectRewardButtonStateKs: ChallengeWidgetViewStateContentCollectRewardButtonStateKs( + collectRewardButtonState + ), + action: action + ) + } +} + +#Preview { + VStack { + ChallengeWidgetContentStateCollectRewardButton( + collectRewardButtonStateKs: .hidden, + action: {} + ) + + ChallengeWidgetContentStateCollectRewardButton( + collectRewardButtonStateKs: .visible(.init(title: "Collect Reward")), + action: {} + ) + } + .padding() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateDeadlineView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateDeadlineView.swift new file mode 100644 index 0000000000..4f58798a60 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateDeadlineView.swift @@ -0,0 +1,95 @@ +import shared +import SwiftUI + +extension ChallengeWidgetContentStateDeadlineView { + enum Appearance { + static let countDownStackSpacing: CGFloat = 4 + } +} + +struct ChallengeWidgetContentStateDeadlineView: View { + let presentationContext: PresentationContext + + var body: some View { + switch presentationContext { + case .empty: + EmptyView() + case .reloadButton(let action): + Button( + Strings.Placeholder.networkErrorButtonText, + action: action + ) + .buttonStyle(RoundedRectangleButtonStyle(style: .violet)) + case .text(let title, let subtitle): + HStack(spacing: Appearance.countDownStackSpacing) { + Text(title) + .font(.body) + + Text(subtitle) + .font(.headline) + .animation(.default, value: subtitle) + } + .foregroundColor(.primaryText) + } + } + + enum PresentationContext: Equatable { + case empty + case reloadButton(action: () -> Void) + case text(title: String, subtitle: String) + + static func == (lhs: PresentationContext, rhs: PresentationContext) -> Bool { + switch (lhs, rhs) { + case (.empty, .empty): + true + case (.reloadButton, .reloadButton): + true + case (.text(let lhsTitle, let lhsSubtitle), .text(let rhsTitle, let rhsSubtitle)): + lhsTitle == rhsTitle && lhsSubtitle == rhsSubtitle + default: + false + } + } + } +} + +extension ChallengeWidgetContentStateDeadlineView { + init( + startsInState: ChallengeWidgetViewStateContentAnnouncementStartsInState, + reloadAction: @escaping () -> Void + ) { + switch ChallengeWidgetViewStateContentAnnouncementStartsInStateKs(startsInState) { + case .deadline: + self.init(presentationContext: .reloadButton(action: reloadAction)) + case .timeRemaining(let data): + self.init(presentationContext: .text(title: data.title, subtitle: data.subtitle)) + } + } +} + +extension ChallengeWidgetContentStateDeadlineView { + init( + completeInState: ChallengeWidgetViewStateContentHappeningNowCompleteInState, + reloadAction: @escaping () -> Void + ) { + switch ChallengeWidgetViewStateContentHappeningNowCompleteInStateKs(completeInState) { + case .deadline: + self.init(presentationContext: .reloadButton(action: reloadAction)) + case .timeRemaining(let data): + self.init(presentationContext: .text(title: data.title, subtitle: data.subtitle)) + case .empty: + self.init(presentationContext: .empty) + } + } +} + +#Preview { + VStack { + ChallengeWidgetContentStateDeadlineView(presentationContext: .reloadButton(action: {})) + + ChallengeWidgetContentStateDeadlineView( + presentationContext: .text(title: "Starts in", subtitle: "6 hrs 27 mins") + ) + } + .padding() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateDescriptionView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateDescriptionView.swift new file mode 100644 index 0000000000..8483538d02 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateDescriptionView.swift @@ -0,0 +1,50 @@ +import Atributika +import shared +import SwiftUI + +struct ChallengeWidgetContentStateDescriptionView: View { + let stateKs: ChallengeWidgetViewStateContentKs + + let onOpenLink: (URL) -> Void + + var body: some View { + let description = stateKs.sealed.headerData.description_ + + switch stateKs { + case .announcement, .happeningNow: + LatexView( + text: description, + configuration: .init( + appearance: .init( + labelFont: .preferredFont(forTextStyle: .subheadline), + labelTextColor: .secondaryText, + backgroundColor: .clear + ), + htmlToAttributedStringConverter: HTMLToAttributedStringConverter( + font: .preferredFont(forTextStyle: .subheadline), + tagStyles: [ + Style("a") + .foregroundColor(ColorPalette.primary, .normal) + .foregroundColor(ColorPalette.primary.withAlphaComponent(0.5), .highlighted) + ] + ) + ), + onOpenLink: onOpenLink + ) + case .completed, .partiallyCompleted, .ended: + Text(description) + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.secondaryText) + } + } +} + +extension ChallengeWidgetContentStateDescriptionView: Equatable { + static func == ( + lhs: ChallengeWidgetContentStateDescriptionView, + rhs: ChallengeWidgetContentStateDescriptionView + ) -> Bool { + lhs.stateKs.sealed.headerData.description_ == rhs.stateKs.sealed.headerData.description_ + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateHeaderView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateHeaderView.swift new file mode 100644 index 0000000000..c5fd78393b --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateHeaderView.swift @@ -0,0 +1,91 @@ +import shared +import SwiftUI + +struct ChallengeWidgetContentStateHeaderView: View { + let title: String + let badgeText: String + let imageResource: ImageResource + + var body: some View { + HStack(alignment: .center, spacing: 0) { + VStack(alignment: .leading, spacing: LayoutInsets.smallInset) { + Text(title) + .font(.headline) + .foregroundColor(.primaryText) + + BadgeView(text: badgeText, style: .blue) + } + + Spacer(minLength: LayoutInsets.defaultInset) + + Image(imageResource) + .renderingMode(.original) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 56, height: 48) + } + } +} + +extension ChallengeWidgetContentStateHeaderView { + init(stateKs: ChallengeWidgetViewStateContentKs) { + let headerData = stateKs.sealed.headerData + + let imageResource: ImageResource = switch stateKs { + case .announcement: + .challengeWidgetStatusAnnouncement + case .completed: + .challengeWidgetStatusCompleted + case .ended: + .challengeWidgetStatusEnded + case .happeningNow: + .challengeWidgetStatusHappeningNow + case .partiallyCompleted: + .challengeWidgetStatusPartiallyCompleted + } + + self.init( + title: headerData.title, + badgeText: headerData.formattedDurationOfTime, + imageResource: imageResource + ) + } +} + +#Preview { + let title = "Advent Streak Challenge" + let badgeText = "6 Oct - 12 Oct" + + return VStack { + ChallengeWidgetContentStateHeaderView( + title: title, + badgeText: badgeText, + imageResource: .challengeWidgetStatusAnnouncement + ) + + ChallengeWidgetContentStateHeaderView( + title: title, + badgeText: badgeText, + imageResource: .challengeWidgetStatusCompleted + ) + + ChallengeWidgetContentStateHeaderView( + title: title, + badgeText: badgeText, + imageResource: .challengeWidgetStatusEnded + ) + + ChallengeWidgetContentStateHeaderView( + title: title, + badgeText: badgeText, + imageResource: .challengeWidgetStatusHappeningNow + ) + + ChallengeWidgetContentStateHeaderView( + title: title, + badgeText: badgeText, + imageResource: .challengeWidgetStatusPartiallyCompleted + ) + } + .padding() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateView.swift new file mode 100644 index 0000000000..9dd4bdfdeb --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ChallengeWidgetContentStateView.swift @@ -0,0 +1,302 @@ +import shared +import SwiftUI + +extension ChallengeWidgetContentStateView { + struct Appearance { + let spacing = LayoutInsets.defaultInset + + var backgroundColor = Color(ColorPalette.surface) + } +} + +struct ChallengeWidgetContentStateView: View { + private(set) var appearance = Appearance() + + let stateKs: ChallengeWidgetViewStateContentKs + + let onOpenDescriptionLink: (URL) -> Void + let onDeadlineReloadTap: () -> Void + let onCollectRewardTap: () -> Void + + var body: some View { + buildBody() + } + + @ViewBuilder + @MainActor + private func buildBody() -> some View { + let _ = handleMagicLinkLoadingIndicatorVisibility() + + VStack(alignment: .leading, spacing: appearance.spacing) { + ChallengeWidgetContentStateHeaderView(stateKs: stateKs) + + ChallengeWidgetContentStateDescriptionView( + stateKs: stateKs, + onOpenLink: onOpenDescriptionLink + ) + .equatable() + + switch stateKs { + case .announcement(let announcementData): + ChallengeWidgetContentStateDeadlineView( + startsInState: announcementData.startsInState, + reloadAction: onDeadlineReloadTap + ) + case .happeningNow(let happeningNowData): + ChallengeWidgetContentStateProgressGridView( + progressStatuses: happeningNowData.progressStatuses + ) + + ChallengeWidgetContentStateDeadlineView( + completeInState: happeningNowData.completeInState, + reloadAction: onDeadlineReloadTap + ) + case .completed(let completedData): + ChallengeWidgetContentStateCollectRewardButton( + collectRewardButtonState: completedData.collectRewardButtonState, + action: onCollectRewardTap + ) + case .partiallyCompleted(let partiallyCompletedData): + ChallengeWidgetContentStateCollectRewardButton( + collectRewardButtonState: partiallyCompletedData.collectRewardButtonState, + action: onCollectRewardTap + ) + case .ended: + EmptyView() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(appearance.backgroundColor) + .addBorder() + } + + @MainActor + private func handleMagicLinkLoadingIndicatorVisibility() { + if ChallengeWidgetViewStateKt.isLoadingMagicLink(stateKs.sealed) { + ProgressHUD.show() + } else { + ProgressHUD.dismissWithDelay() + } + } +} + +extension ChallengeWidgetContentStateView: Equatable { + static func == (lhs: ChallengeWidgetContentStateView, rhs: ChallengeWidgetContentStateView) -> Bool { + lhs.stateKs == rhs.stateKs + } +} + +// MARK: - Preview - + +#if DEBUG +#Preview("Announcement") { + let headerData = ChallengeWidgetViewStateContentHeaderData( + title: "Advent Streak Challenge", + description: """ +Get ready to push your limits! Thrilling daily programming competition What to Expect. +""", + formattedDurationOfTime: "6 Oct - 12 Oct" + ) + return ScrollView { + VStack { + ChallengeWidgetContentStateView( + stateKs: .announcement( + .init( + headerData: headerData, + startsInState: ChallengeWidgetViewStateContentAnnouncementStartsInStateTimeRemaining( + title: "Starts in", + subtitle: "6 hrs 27 mins" + ) + ) + ), + onOpenDescriptionLink: { _ in }, + onDeadlineReloadTap: {}, + onCollectRewardTap: {} + ) + ChallengeWidgetContentStateView( + stateKs: .announcement( + .init( + headerData: headerData, + startsInState: ChallengeWidgetViewStateContentAnnouncementStartsInStateDeadline() + ) + ), + onOpenDescriptionLink: { _ in }, + onDeadlineReloadTap: {}, + onCollectRewardTap: {} + ) + } + .padding() + .background(Color.systemGroupedBackground) + } +} + +#Preview("Happening Now") { + let headerData = ChallengeWidgetViewStateContentHeaderData( + title: "Advent Streak Challenge", + description: """ +The challenge awaits! Thrilling daily programming competition designed Read the rules +""", + formattedDurationOfTime: "6 Oct - 12 Oct" + ) + return ScrollView { + VStack { + ChallengeWidgetContentStateView( + stateKs: .happeningNow( + .init( + headerData: headerData, + completeInState: ChallengeWidgetViewStateContentHappeningNowCompleteInStateTimeRemaining( + title: "Complete in", + subtitle: "6 hrs 27 mins" + ), + progressStatuses: ChallengeWidgetViewStateContentHappeningNow.ProgressStatus.placeholder + ) + ), + onOpenDescriptionLink: { _ in }, + onDeadlineReloadTap: {}, + onCollectRewardTap: {} + ) + ChallengeWidgetContentStateView( + stateKs: .happeningNow( + .init( + headerData: headerData, + completeInState: ChallengeWidgetViewStateContentHappeningNowCompleteInStateDeadline(), + progressStatuses: ChallengeWidgetViewStateContentHappeningNow.ProgressStatus.placeholder + ) + ), + onOpenDescriptionLink: { _ in }, + onDeadlineReloadTap: {}, + onCollectRewardTap: {} + ) + ChallengeWidgetContentStateView( + stateKs: .happeningNow( + .init( + headerData: headerData, + completeInState: ChallengeWidgetViewStateContentHappeningNowCompleteInStateEmpty(), + progressStatuses: ChallengeWidgetViewStateContentHappeningNow.ProgressStatus.placeholder + ) + ), + onOpenDescriptionLink: { _ in }, + onDeadlineReloadTap: {}, + onCollectRewardTap: {} + ) + } + .padding() + .background(Color.systemGroupedBackground) + } +} + +#Preview("Completed") { + let headerData = ChallengeWidgetViewStateContentHeaderData( + title: "Advent Streak Challenge", + description: "Well done, challenge completed!", + formattedDurationOfTime: "6 Oct - 12 Oct" + ) + return ScrollView { + VStack { + ChallengeWidgetContentStateView( + stateKs: .completed( + .init( + headerData: headerData, + collectRewardButtonState: ChallengeWidgetViewStateContentCollectRewardButtonStateVisible( + title: "Collect Reward" + ), + isLoadingMagicLink: false + ) + ), + onOpenDescriptionLink: { _ in }, + onDeadlineReloadTap: {}, + onCollectRewardTap: {} + ) + ChallengeWidgetContentStateView( + stateKs: .completed( + .init( + headerData: headerData, + collectRewardButtonState: ChallengeWidgetViewStateContentCollectRewardButtonStateHidden(), + isLoadingMagicLink: false + ) + ), + onOpenDescriptionLink: { _ in }, + onDeadlineReloadTap: {}, + onCollectRewardTap: {} + ) + } + .padding() + .background(Color.systemGroupedBackground) + } +} + +#Preview("Partially Completed") { + let headerData = ChallengeWidgetViewStateContentHeaderData( + title: "Advent Streak Challenge", + description: "Close to victory, bonus bounty!", + formattedDurationOfTime: "6 Oct - 12 Oct" + ) + return ScrollView { + VStack { + ChallengeWidgetContentStateView( + stateKs: .partiallyCompleted( + .init( + headerData: headerData, + collectRewardButtonState: ChallengeWidgetViewStateContentCollectRewardButtonStateVisible( + title: "Collect Reward" + ), + isLoadingMagicLink: false + ) + ), + onOpenDescriptionLink: { _ in }, + onDeadlineReloadTap: {}, + onCollectRewardTap: {} + ) + ChallengeWidgetContentStateView( + stateKs: .partiallyCompleted( + .init( + headerData: headerData, + collectRewardButtonState: ChallengeWidgetViewStateContentCollectRewardButtonStateHidden(), + isLoadingMagicLink: false + ) + ), + onOpenDescriptionLink: { _ in }, + onDeadlineReloadTap: {}, + onCollectRewardTap: {} + ) + } + .padding() + .background(Color.systemGroupedBackground) + } +} + +#Preview("Ended") { + ScrollView { + VStack { + ChallengeWidgetContentStateView( + stateKs: .ended( + .init( + headerData: ChallengeWidgetViewStateContentHeaderData( + title: "Advent Streak Challenge", + description: "Give it another shot next time!", + formattedDurationOfTime: "6 Oct - 12 Oct" + ) + ) + ), + onOpenDescriptionLink: { _ in }, + onDeadlineReloadTap: {}, + onCollectRewardTap: {} + ) + } + .padding() + .background(Color.systemGroupedBackground) + } +} + +extension ChallengeWidgetViewStateContentHappeningNow.ProgressStatus { + static var placeholder: [ChallengeWidgetViewStateContentHappeningNow.ProgressStatus] { + [ + .completed, .missed, .active, .inactive, .inactive, .inactive, .inactive, + .inactive, .inactive, .inactive, .inactive, .inactive, .inactive, .inactive, + .inactive, .inactive, .inactive, .inactive, .inactive, .inactive, .inactive, + .inactive, .inactive, .inactive + ] + } +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ProgressGrid/ChallengeWidgetContentStateProgressGridItemView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ProgressGrid/ChallengeWidgetContentStateProgressGridItemView.swift new file mode 100644 index 0000000000..2cc19c7e8e --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ProgressGrid/ChallengeWidgetContentStateProgressGridItemView.swift @@ -0,0 +1,36 @@ +import shared +import SwiftUI + +struct ChallengeWidgetContentStateProgressGridItemView: View { + let progressStatus: ChallengeWidgetViewStateContentHappeningNow.ProgressStatus + + var body: some View { + ZStack(alignment: .center) { + if progressStatus == .missed { + Image(systemName: "xmark") + .resizable() + .renderingMode(.template) + .aspectRatio(contentMode: .fit) + .frame(widthHeight: 10) + .foregroundColor(.secondaryText) + } + } + .frame(minWidth: 32, minHeight: 16) + .background(progressStatus == .completed ? Color(ColorPalette.primary) : Color(ColorPalette.onSurfaceAlpha9)) + .addBorder( + color: progressStatus == .active ? Color(ColorPalette.primary) : .clear, + width: progressStatus == .active ? 1 : 0, + cornerRadius: 4 + ) + } +} + +#Preview { + VStack { + ChallengeWidgetContentStateProgressGridItemView(progressStatus: .inactive) + ChallengeWidgetContentStateProgressGridItemView(progressStatus: .active) + ChallengeWidgetContentStateProgressGridItemView(progressStatus: .completed) + ChallengeWidgetContentStateProgressGridItemView(progressStatus: .missed) + } + .padding() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ProgressGrid/ChallengeWidgetContentStateProgressGridView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ProgressGrid/ChallengeWidgetContentStateProgressGridView.swift new file mode 100644 index 0000000000..c4e4af68df --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ChallengeWidget/Views/Content/ProgressGrid/ChallengeWidgetContentStateProgressGridView.swift @@ -0,0 +1,50 @@ +import shared +import SwiftUI + +extension ChallengeWidgetContentStateProgressGridView { + enum Appearance { + static let columnsCount = 7 + } +} + +struct ChallengeWidgetContentStateProgressGridView: View { + let progressStatuses: [ChallengeWidgetViewStateContentHappeningNow.ProgressStatus] + + var body: some View { + if progressStatuses.isEmpty { + EmptyView() + } else { + gridView + } + } + + private var gridView: some View { + LazyVGrid( + columns: Array( + repeating: GridItem( + .flexible(), + spacing: LayoutInsets.smallInset, + alignment: .top + ), + count: Appearance.columnsCount + ), + alignment: .leading, + spacing: LayoutInsets.smallInset + ) { + ForEach(Array(progressStatuses.enumerated()), id: \.offset) { _, progressStatus in + ChallengeWidgetContentStateProgressGridItemView( + progressStatus: progressStatus + ) + } + } + } +} + +#if DEBUG +#Preview { + ChallengeWidgetContentStateProgressGridView( + progressStatuses: ChallengeWidgetViewStateContentHappeningNow.ProgressStatus.placeholder + ) + .padding() +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/ProblemOfDayAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/ProblemOfDayAssembly.swift similarity index 100% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/ProblemOfDayAssembly.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/ProblemOfDayAssembly.swift diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/ProblemOfDayOutputProtocol.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/ProblemOfDayOutputProtocol.swift similarity index 100% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/ProblemOfDayOutputProtocol.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/ProblemOfDayOutputProtocol.swift diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/ProblemOfDayViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/ProblemOfDayViewModel.swift similarity index 100% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/ProblemOfDayViewModel.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/ProblemOfDayViewModel.swift diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/ViewData/ProblemOfDayViewData.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/ViewData/ProblemOfDayViewData.swift similarity index 100% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/ViewData/ProblemOfDayViewData.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/ViewData/ProblemOfDayViewData.swift diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/ViewData/ProblemOfDayViewDataMapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/ViewData/ProblemOfDayViewDataMapper.swift similarity index 100% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/ViewData/ProblemOfDayViewDataMapper.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/ViewData/ProblemOfDayViewDataMapper.swift diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/Views/ProblemOfDayCardView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/Views/ProblemOfDayCardView.swift similarity index 98% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/Views/ProblemOfDayCardView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/Views/ProblemOfDayCardView.swift index d1244cb600..fe8f76ecc1 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/Views/ProblemOfDayCardView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/Views/ProblemOfDayCardView.swift @@ -112,12 +112,12 @@ struct ProblemOfDayCardView: View { HStack(spacing: appearance.nextProblemInTextSpacing) { Text(Strings.ProblemOfDay.nextProblemIn) .font(.body) - .foregroundColor(.primaryText) Text(nextProblemIn) .font(.headline) - .foregroundColor(.primaryText) + .animation(.default, value: nextProblemIn) } + .foregroundColor(.primaryText) } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/Views/ProblemOfDaySkeletonView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/Views/ProblemOfDaySkeletonView.swift similarity index 100% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/Views/ProblemOfDaySkeletonView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/Views/ProblemOfDaySkeletonView.swift diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/Views/ProblemOfDayTitle.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/Views/ProblemOfDayTitle.swift similarity index 97% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/Views/ProblemOfDayTitle.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/Views/ProblemOfDayTitle.swift index 8ecc142e2b..005af34e66 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProblemOfDay/Views/ProblemOfDayTitle.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/ProblemOfDay/Views/ProblemOfDayTitle.swift @@ -23,7 +23,7 @@ struct ProblemOfDayTitle: View { .frame(widthHeight: appearance.titleIconSize) Text(titleText) - .font(.title3) + .font(.headline) .foregroundColor(.primaryText) Spacer() diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/TopicsRepetitionsCard/TopicsRepetitionsCardSkeletonView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/TopicsRepetitions/TopicsRepetitionsCardSkeletonView.swift similarity index 100% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/TopicsRepetitionsCard/TopicsRepetitionsCardSkeletonView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/TopicsRepetitions/TopicsRepetitionsCardSkeletonView.swift diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/TopicsRepetitionsCard/TopicsRepetitionsCardView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/TopicsRepetitions/TopicsRepetitionsCardView.swift similarity index 98% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/TopicsRepetitionsCard/TopicsRepetitionsCardView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/TopicsRepetitions/TopicsRepetitionsCardView.swift index 616e686269..13f0dc2f08 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/TopicsRepetitionsCard/TopicsRepetitionsCardView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/TopicsRepetitions/TopicsRepetitionsCardView.swift @@ -28,7 +28,7 @@ struct TopicsRepetitionsCardView: View { VStack(alignment: .leading, spacing: appearance.spacing) { HStack(spacing: LayoutInsets.smallInset) { Text(state.titleText) - .font(.title3) + .font(.headline) .foregroundColor(.primaryText) Spacer() diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/TopicsRepetitionsCard/TopicsRepetitionsCountView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/TopicsRepetitions/TopicsRepetitionsCountView.swift similarity index 100% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/TopicsRepetitionsCard/TopicsRepetitionsCountView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/HomeSubmodules/TopicsRepetitions/TopicsRepetitionsCountView.swift diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/StepView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/StepView.swift index dce894c036..4fb31bf177 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/StepView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/StepView.swift @@ -38,6 +38,7 @@ struct StepView: View { case .error: PlaceholderView( configuration: .networkError( + presentationMode: viewModel.isStageImplement ? .local : .fullscreen, backgroundColor: .clear, action: viewModel.doRetryLoadStep ) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizFeedbackView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizFeedbackView.swift index a272f2a6f2..8a95030946 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizFeedbackView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizFeedbackView.swift @@ -1,193 +1,37 @@ -import SnapKit import SwiftUI -import UIKit -struct StepQuizFeedbackView: UIViewRepresentable { - typealias UIViewType = StepQuizFeedbackUIKitView - - var text: String - - func makeUIView(context: Context) -> StepQuizFeedbackUIKitView { - StepQuizFeedbackUIKitView() - } - - func updateUIView(_ uiView: StepQuizFeedbackUIKitView, context: Context) { - uiView.setText(text) +struct StepQuizFeedbackView: View { + let text: String + + var body: some View { + VStack(alignment: .leading, spacing: LayoutInsets.smallInset) { + Text(Strings.StepQuiz.feedbackTitle) + .font(.caption) + .foregroundColor(.tertiaryText) + .frame(maxWidth: .infinity, alignment: .leading) + + LatexView( + text: text, + configuration: .quizContent( + textFont: .monospacedSystemFont(ofSize: 14, weight: .regular), + textColor: .primaryText, + backgroundColor: .clear + ) + ) + } + .padding() + .background(Color.background) + .addBorder() } } -struct StepQuizFeedbackView_Previews: PreviewProvider { - static var previews: some View { +#Preview { + ScrollView { StepQuizFeedbackView( text: """ That's right! Since any comparison results in a boolean value, there is no need to write everything twice. """ ) - .padding() - } -} - -// MARK: - StepQuizFeedbackUIKitView - - -extension StepQuizFeedbackUIKitView { - struct Appearance { - let titleLabelTextFont = UIFont.preferredFont(forTextStyle: .caption1) - let titleLabelTextColor = UIColor.tertiaryText - - let processedContentTextFont = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) - let processedContentTextColor = UIColor.primaryText - - let padding = LayoutInsets.defaultInset - let spacing = LayoutInsets.smallInset - - let borderColor = ColorPalette.onSurfaceAlpha12 - let borderWidth: CGFloat = 1 - let borderCornerRadius: CGFloat = 8 - - let backgroundColor = ColorPalette.background - } -} - -final class StepQuizFeedbackUIKitView: UIView { - let appearance: Appearance - - private lazy var titleLabel: UILabel = { - let label = UILabel() - label.font = appearance.titleLabelTextFont - label.textColor = appearance.titleLabelTextColor - label.numberOfLines = 1 - label.text = Strings.StepQuiz.feedbackTitle - return label - }() - - private lazy var processedContentView: ProcessedContentView = { - let processedContentViewAppearance = ProcessedContentView.Appearance( - labelFont: appearance.processedContentTextFont, - backgroundColor: .clear - ) - - let contentProcessor = ContentProcessor( - injections: ContentProcessor.defaultInjections + [ - FontInjection(font: appearance.processedContentTextFont), - TextColorInjection(dynamicColor: appearance.processedContentTextColor) - ] - ) - - let processedContentView = ProcessedContentView( - frame: .zero, - appearance: processedContentViewAppearance, - contentProcessor: contentProcessor, - htmlToAttributedStringConverter: HTMLToAttributedStringConverter( - font: appearance.processedContentTextFont - ) - ) - processedContentView.delegate = self - - return processedContentView - }() - - override var intrinsicContentSize: CGSize { - let titleLabelHeight = titleLabel.intrinsicContentSize.height - let processedContentViewHeight = processedContentView.intrinsicContentSize.height - - let height = - appearance.padding + titleLabelHeight + appearance.spacing + processedContentViewHeight + appearance.padding - - return CGSize(width: UIView.noIntrinsicMetric, height: height) - } - - init( - frame: CGRect = .zero, - appearance: Appearance = Appearance() - ) { - self.appearance = appearance - super.init(frame: frame) - - setupView() - addSubviews() - makeConstraints() - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - clipsToBounds = true - layer.cornerRadius = appearance.borderCornerRadius - layer.borderColor = appearance.borderColor.cgColor - layer.borderWidth = appearance.borderWidth - } - - func setText(_ text: String) { - processedContentView.setText(text) - } -} - -// MARK: - StepQuizFeedbackUIKitView: ProgrammaticallyInitializableViewProtocol - - -extension StepQuizFeedbackUIKitView: ProgrammaticallyInitializableViewProtocol { - func setupView() { - backgroundColor = appearance.backgroundColor - } - - func addSubviews() { - addSubview(titleLabel) - addSubview(processedContentView) - } - - func makeConstraints() { - titleLabel.translatesAutoresizingMaskIntoConstraints = false - titleLabel.snp.makeConstraints { make in - make.top.equalToSuperview().offset(appearance.padding) - make.leading.equalToSuperview().offset(appearance.padding) - make.trailing.lessThanOrEqualToSuperview().offset(-appearance.padding) - } - - processedContentView.translatesAutoresizingMaskIntoConstraints = false - processedContentView.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(appearance.spacing) - make.leading.equalToSuperview().offset(appearance.padding) - make.bottom.equalToSuperview().offset(-appearance.padding) - make.trailing.equalToSuperview().offset(-appearance.padding) - } - } -} - -// MARK: - StepQuizFeedbackUIKitView: ProcessedContentViewDelegate - - -extension StepQuizFeedbackUIKitView: ProcessedContentViewDelegate { - func processedContentViewDidLoadContent(_ view: ProcessedContentView) { - invalidateLayout() - } - - func processedContentView(_ view: ProcessedContentView, didReportNewHeight height: Int) { - invalidateLayout() - } - - func processedContentView(_ view: ProcessedContentView, didOpenImageURL url: URL) { - openURLInTheWeb(url) - } - - func processedContentView(_ view: ProcessedContentView, didOpenLink url: URL) { - openURLInTheWeb(url) - } - - // MARK: Private Helpers - - private func invalidateLayout() { - DispatchQueue.main.async { - self.layoutIfNeeded() - self.invalidateIntrinsicContentSize() - } - } - - private func openURLInTheWeb(_ url: URL) { - WebControllerManager.shared.presentWebControllerWithURL( - url, - withKey: .externalLink, - controllerType: .inAppSafari - ) } + .padding() } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/PlaceholderView/PlaceholderView+Configurations.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/PlaceholderView/PlaceholderView+Configurations.swift index 8ab01bd3ac..5f0daa2388 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/PlaceholderView/PlaceholderView+Configurations.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/PlaceholderView/PlaceholderView+Configurations.swift @@ -4,12 +4,14 @@ extension PlaceholderView.Configuration { static let defaultImageSize = CGSize(width: 122, height: 122) static func networkError( + presentationMode: PresentationMode = .fullscreen, titleText: String = Strings.Placeholder.networkErrorTitle, buttonText: String = Strings.Placeholder.networkErrorButtonText, backgroundColor: Color = Color(ColorPalette.surface), action: @escaping () -> Void ) -> Self { .init( + presentationMode: presentationMode, image: .init(name: Images.Placeholder.networkError, frame: .size(defaultImageSize)), title: .init(text: titleText), button: .init(text: buttonText, action: action), @@ -26,7 +28,7 @@ extension PlaceholderView.Configuration { ) -> Self { .init( presentationMode: presentationMode, - image: .init(name: Images.Placeholder.reload, frame: .size(CGSize(width: 66, height: 66))), + image: .reload, title: .init(text: Strings.StudyPlan.activitiesError), button: .init(text: Strings.Placeholder.networkErrorButtonText, action: action, style: buttonStyle), primaryContentAlignment: primaryContentAlignment, diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/PlaceholderView/PlaceholderView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/PlaceholderView/PlaceholderView.swift index 53ca57c3c7..ec49940032 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/PlaceholderView/PlaceholderView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/PlaceholderView/PlaceholderView.swift @@ -114,6 +114,10 @@ struct PlaceholderView: View { case size(CGSize) case scale(CGFloat) } + + static var reload: Image { + .init(name: Images.Placeholder.reload, frame: .size(CGSize(width: 66, height: 66))) + } } struct Title { diff --git a/iosHyperskillApp/iosHyperskillAppTests/Info.plist b/iosHyperskillApp/iosHyperskillAppTests/Info.plist index e7e159dc56..7e9f8c7d7b 100644 --- a/iosHyperskillApp/iosHyperskillAppTests/Info.plist +++ b/iosHyperskillApp/iosHyperskillAppTests/Info.plist @@ -15,6 +15,6 @@ CFBundleShortVersionString 1.42 CFBundleVersion - 236 + 239 diff --git a/iosHyperskillApp/iosHyperskillAppUITests/Info.plist b/iosHyperskillApp/iosHyperskillAppUITests/Info.plist index f20229aae3..ed9018b980 100644 --- a/iosHyperskillApp/iosHyperskillAppUITests/Info.plist +++ b/iosHyperskillApp/iosHyperskillAppUITests/Info.plist @@ -15,6 +15,6 @@ CFBundleShortVersionString 1.42 CFBundleVersion - 236 + 239 diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/core/injection/CommonComponentImpl.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/core/injection/CommonComponentImpl.kt index 542c40bec8..4774d81c01 100644 --- a/shared/src/androidMain/kotlin/org/hyperskill/app/core/injection/CommonComponentImpl.kt +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/core/injection/CommonComponentImpl.kt @@ -16,7 +16,7 @@ import org.hyperskill.app.core.remote.UserAgentInfo import org.hyperskill.app.core.view.mapper.NumbersFormatter import org.hyperskill.app.core.view.mapper.ResourceProvider import org.hyperskill.app.core.view.mapper.ResourceProviderImpl -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.network.injection.NetworkModule class CommonComponentImpl( diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/home/presentation/HomeViewModel.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/home/presentation/HomeViewModel.kt index f47f88d4a8..89c37026df 100644 --- a/shared/src/androidMain/kotlin/org/hyperskill/app/home/presentation/HomeViewModel.kt +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/home/presentation/HomeViewModel.kt @@ -4,5 +4,5 @@ import ru.nobird.android.view.redux.viewmodel.ReduxViewModel import ru.nobird.app.presentation.redux.container.ReduxViewContainer class HomeViewModel( - reduxViewContainer: ReduxViewContainer -) : ReduxViewModel(reduxViewContainer) \ No newline at end of file + reduxViewContainer: ReduxViewContainer +) : ReduxViewModel(reduxViewContainer) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt index b3f539f9cb..da51a910df 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt @@ -14,6 +14,7 @@ enum class HyperskillAnalyticPart(val partName: String) { NOTIFICATIONS_SYSTEM_NOTICE("notifications_system_notice"), PROBLEM_OF_THE_DAY_CARD("problem_of_the_day_card"), TOPICS_REPETITIONS_CARD("topics_repetitions_card"), + CHALLENGE_CARD("challenge_card"), REPEAT_NEXT_TOPIC("repeat_next_topic"), REPEAT_TOPIC("repeat_topic"), DAILY_NOTIFICATIONS_NOTICE("daily_notifications_notice"), 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 ef31bb987c..6e96f035a4 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 @@ -38,6 +38,7 @@ enum class HyperskillAnalyticTarget(val targetName: String) { CONTINUE_TO_HYPERSKILL("continue_to_hyperskill"), CONTINUE("continue"), RELOAD("reload"), + DEADLINE_RELOAD("deadline_reload"), START_PRACTICING("start_practicing"), SIGN_IN("sign_in"), SIGN_UP("sign_up"), @@ -101,5 +102,7 @@ enum class HyperskillAnalyticTarget(val targetName: String) { CODE_INPUT_ACCESSORY_BUTTON("code_input_accessory_button"), SHARE_YOUR_STREAK("share_your_streak"), SHARE_STREAK_MODAL("share_streak_modal"), - SHARE("share") + SHARE("share"), + LINK("link"), + COLLECT_REWARD("collect_reward") } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/data/repository/ChallengesRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/data/repository/ChallengesRepositoryImpl.kt new file mode 100644 index 0000000000..dc2128dad2 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/data/repository/ChallengesRepositoryImpl.kt @@ -0,0 +1,12 @@ +package org.hyperskill.app.challenges.data.repository + +import org.hyperskill.app.challenges.data.source.ChallengesRemoteDataSource +import org.hyperskill.app.challenges.domain.model.Challenge +import org.hyperskill.app.challenges.domain.repository.ChallengesRepository + +internal class ChallengesRepositoryImpl( + private val challengesRemoteDataSource: ChallengesRemoteDataSource +) : ChallengesRepository { + override suspend fun getChallenges(): Result> = + challengesRemoteDataSource.getChallenges() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/data/source/ChallengesRemoteDataSource.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/data/source/ChallengesRemoteDataSource.kt new file mode 100644 index 0000000000..0daae2bc14 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/data/source/ChallengesRemoteDataSource.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.challenges.data.source + +import org.hyperskill.app.challenges.domain.model.Challenge + +interface ChallengesRemoteDataSource { + suspend fun getChallenges(): Result> +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/domain/model/Challenge.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/domain/model/Challenge.kt new file mode 100644 index 0000000000..0eb38896e3 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/domain/model/Challenge.kt @@ -0,0 +1,39 @@ +package org.hyperskill.app.challenges.domain.model + +import kotlinx.datetime.LocalDate +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Challenge( + @SerialName("id") + val id: Long, + @SerialName("title") + val title: String, + @SerialName("description") + val description: String, + @SerialName("target_type") + internal val targetTypeValue: String, + @SerialName("starting_date") + val startingDate: LocalDate, + @SerialName("interval_duration_days") + val intervalDurationDays: Int, + @SerialName("intervals_count") + val intervalsCount: Int, + @SerialName("status") + internal val statusValue: String, + @SerialName("reward_link") + val rewardLink: String?, + @SerialName("progress") + val progress: List, + @SerialName("finish_date") + val finishDate: LocalDate, + @SerialName("current_interval") + val currentInterval: Int? +) { + val targetType: ChallengeTargetType? + get() = ChallengeTargetType.getByValue(targetTypeValue) + + val status: ChallengeStatus? + get() = ChallengeStatus.getByValue(statusValue) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/domain/model/ChallengeStatus.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/domain/model/ChallengeStatus.kt new file mode 100644 index 0000000000..6dc0722b04 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/domain/model/ChallengeStatus.kt @@ -0,0 +1,16 @@ +package org.hyperskill.app.challenges.domain.model + +enum class ChallengeStatus(val value: String) { + NOT_STARTED("not started"), + STARTED("started"), + NOT_COMPLETED("not completed"), + PARTIAL_COMPLETED("partial completed"), + COMPLETED("completed"); + + companion object { + private val VALUES: Array = values() + + fun getByValue(value: String): ChallengeStatus? = + VALUES.firstOrNull { it.value == value } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/domain/model/ChallengeTargetType.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/domain/model/ChallengeTargetType.kt new file mode 100644 index 0000000000..c6d22e52d0 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/domain/model/ChallengeTargetType.kt @@ -0,0 +1,16 @@ +package org.hyperskill.app.challenges.domain.model + +enum class ChallengeTargetType(val value: String) { + DAILY_STEP("dailystep"), + PROJECT("project"), + STAGE("stage"), + STEP("step"), + TOPIC("topic"); + + companion object { + private val VALUES: Array = values() + + fun getByValue(value: String): ChallengeTargetType? = + VALUES.firstOrNull { it.value == value } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/domain/repository/ChallengesRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/domain/repository/ChallengesRepository.kt new file mode 100644 index 0000000000..469fc5b195 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/domain/repository/ChallengesRepository.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.challenges.domain.repository + +import org.hyperskill.app.challenges.domain.model.Challenge + +interface ChallengesRepository { + suspend fun getChallenges(): Result> +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/injection/ChallengesDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/injection/ChallengesDataComponent.kt new file mode 100644 index 0000000000..258aa93a4c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/injection/ChallengesDataComponent.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.challenges.injection + +import org.hyperskill.app.challenges.domain.repository.ChallengesRepository + +interface ChallengesDataComponent { + val challengesRepository: ChallengesRepository +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/injection/ChallengesDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/injection/ChallengesDataComponentImpl.kt new file mode 100644 index 0000000000..e9d995c212 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/injection/ChallengesDataComponentImpl.kt @@ -0,0 +1,15 @@ +package org.hyperskill.app.challenges.injection + +import org.hyperskill.app.challenges.data.repository.ChallengesRepositoryImpl +import org.hyperskill.app.challenges.data.source.ChallengesRemoteDataSource +import org.hyperskill.app.challenges.domain.repository.ChallengesRepository +import org.hyperskill.app.challenges.remote.ChallengesRemoteDataSourceImpl +import org.hyperskill.app.core.injection.AppGraph + +internal class ChallengesDataComponentImpl(appGraph: AppGraph) : ChallengesDataComponent { + private val challengesRemoteDataSource: ChallengesRemoteDataSource = + ChallengesRemoteDataSourceImpl(appGraph.networkComponent.authorizedHttpClient) + + override val challengesRepository: ChallengesRepository + get() = ChallengesRepositoryImpl(challengesRemoteDataSource) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/remote/ChallengesRemoteDataSourceImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/remote/ChallengesRemoteDataSourceImpl.kt new file mode 100644 index 0000000000..81f4a87f69 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/remote/ChallengesRemoteDataSourceImpl.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.challenges.remote + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import org.hyperskill.app.challenges.data.source.ChallengesRemoteDataSource +import org.hyperskill.app.challenges.domain.model.Challenge +import org.hyperskill.app.challenges.remote.model.ChallengesResponse + +internal class ChallengesRemoteDataSourceImpl( + private val httpClient: HttpClient +) : ChallengesRemoteDataSource { + override suspend fun getChallenges(): Result> = + kotlin.runCatching { + httpClient + .get("/api/challenges") + .body() + .challenges + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/remote/model/ChallengesResponse.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/remote/model/ChallengesResponse.kt new file mode 100644 index 0000000000..98acc9e490 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/remote/model/ChallengesResponse.kt @@ -0,0 +1,16 @@ +package org.hyperskill.app.challenges.remote.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.hyperskill.app.challenges.domain.model.Challenge +import org.hyperskill.app.core.remote.Meta +import org.hyperskill.app.core.remote.MetaResponse + +@Serializable +class ChallengesResponse( + @SerialName("meta") + override val meta: Meta, + + @SerialName("challenges") + val challenges: List +) : MetaResponse \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetAnalyticParams.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetAnalyticParams.kt new file mode 100644 index 0000000000..820700d660 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetAnalyticParams.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.challenges.widget.domain.analytic + +internal object ChallengeWidgetAnalyticParams { + const val PARAM_URL = "url" + const val PARAM_CHALLENGE_ID = "challenge_id" +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetClickedCollectRewardHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetClickedCollectRewardHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..25907102f6 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetClickedCollectRewardHyperskillAnalyticEvent.kt @@ -0,0 +1,41 @@ +package org.hyperskill.app.challenges.widget.domain.analytic + +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 ru.nobird.app.core.model.mapOfNotNull + +/** + * Represents a click analytic event on the "Collect Reward" button. + * + * JSON payload: + * ``` + * { + * "route": "/home", + * "action": "click", + * "part": "challenge_card", + * "target": "collect_reward", + * "context": + * { + * "challenge_id": 1 + * } + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class ChallengeWidgetClickedCollectRewardHyperskillAnalyticEvent( + val challengeId: Long? +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Home(), + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.CHALLENGE_CARD, + HyperskillAnalyticTarget.COLLECT_REWARD +) { + override val params: Map + get() = super.params + mapOf( + PARAM_CONTEXT to mapOfNotNull(ChallengeWidgetAnalyticParams.PARAM_CHALLENGE_ID to challengeId) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetClickedDeadlineReloadHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetClickedDeadlineReloadHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..63bf13b681 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetClickedDeadlineReloadHyperskillAnalyticEvent.kt @@ -0,0 +1,41 @@ +package org.hyperskill.app.challenges.widget.domain.analytic + +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 ru.nobird.app.core.model.mapOfNotNull + +/** + * Represents a click analytic event on the "Reload" button. + * + * JSON payload: + * ``` + * { + * "route": "/home", + * "action": "click", + * "part": "challenge_card", + * "target": "deadline_reload", + * "context": + * { + * "challenge_id": 1 + * } + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class ChallengeWidgetClickedDeadlineReloadHyperskillAnalyticEvent( + val challengeId: Long? +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Home(), + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.CHALLENGE_CARD, + HyperskillAnalyticTarget.DEADLINE_RELOAD +) { + override val params: Map + get() = super.params + mapOf( + PARAM_CONTEXT to mapOfNotNull(ChallengeWidgetAnalyticParams.PARAM_CHALLENGE_ID to challengeId) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetClickedLinkInTheDescriptionHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetClickedLinkInTheDescriptionHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..b00882c2bb --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetClickedLinkInTheDescriptionHyperskillAnalyticEvent.kt @@ -0,0 +1,46 @@ +package org.hyperskill.app.challenges.widget.domain.analytic + +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 ru.nobird.app.core.model.mapOfNotNull + +/** + * Represents a click analytic event on a link in the description block. + * + * JSON payload: + * ``` + * { + * "route": "/home", + * "action": "click", + * "part": "challenge_card", + * "target": "link" + * "context": + * { + * "url": "https://sample.com/", + * "challenge_id": 1 + * } + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class ChallengeWidgetClickedLinkInTheDescriptionHyperskillAnalyticEvent( + val challengeId: Long?, + val url: String +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Home(), + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.CHALLENGE_CARD, + HyperskillAnalyticTarget.LINK +) { + override val params: Map + get() = super.params + mapOf( + PARAM_CONTEXT to mapOfNotNull( + ChallengeWidgetAnalyticParams.PARAM_CHALLENGE_ID to challengeId, + ChallengeWidgetAnalyticParams.PARAM_URL to url + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetClickedRetryContentLoadingHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetClickedRetryContentLoadingHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..e803de4b30 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/domain/analytic/ChallengeWidgetClickedRetryContentLoadingHyperskillAnalyticEvent.kt @@ -0,0 +1,28 @@ +package org.hyperskill.app.challenges.widget.domain.analytic + +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 + +/** + * Represents a click analytic event of the error state placeholder retry button. + * + * JSON payload: + * ``` + * { + * "route": "/home", + * "action": "click", + * "part": "challenge_card", + * "target": "retry" + * } + * ``` + * @see HyperskillAnalyticEvent + */ +object ChallengeWidgetClickedRetryContentLoadingHyperskillAnalyticEvent : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Home(), + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.CHALLENGE_CARD, + HyperskillAnalyticTarget.RETRY +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/injection/ChallengeWidgetComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/injection/ChallengeWidgetComponent.kt new file mode 100644 index 0000000000..993898016f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/injection/ChallengeWidgetComponent.kt @@ -0,0 +1,11 @@ +package org.hyperskill.app.challenges.widget.injection + +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetActionDispatcher +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetReducer +import org.hyperskill.app.challenges.widget.view.mapper.ChallengeWidgetViewStateMapper + +interface ChallengeWidgetComponent { + val challengeWidgetReducer: ChallengeWidgetReducer + val challengeWidgetActionDispatcher: ChallengeWidgetActionDispatcher + val challengeWidgetViewStateMapper: ChallengeWidgetViewStateMapper +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/injection/ChallengeWidgetComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/injection/ChallengeWidgetComponentImpl.kt new file mode 100644 index 0000000000..4dfd1bdceb --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/injection/ChallengeWidgetComponentImpl.kt @@ -0,0 +1,30 @@ +package org.hyperskill.app.challenges.widget.injection + +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetActionDispatcher +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetReducer +import org.hyperskill.app.challenges.widget.view.mapper.ChallengeWidgetViewStateMapper +import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.core.presentation.ActionDispatcherOptions + +internal class ChallengeWidgetComponentImpl(private val appGraph: AppGraph) : ChallengeWidgetComponent { + override val challengeWidgetReducer: ChallengeWidgetReducer + get() = ChallengeWidgetReducer() + + override val challengeWidgetActionDispatcher: ChallengeWidgetActionDispatcher + get() = ChallengeWidgetActionDispatcher( + config = ActionDispatcherOptions(), + solvedStepsSharedFlow = appGraph.submissionDataComponent.submissionRepository.solvedStepsSharedFlow, + topicCompletedFlow = appGraph.stepCompletionFlowDataComponent.topicCompletedFlow, + dailyStepCompletedFlow = appGraph.stepCompletionFlowDataComponent.dailyStepCompletedFlow, + challengesRepository = appGraph.buildChallengesDataComponent().challengesRepository, + magicLinksInteractor = appGraph.buildMagicLinksDataComponent().magicLinksInteractor, + sentryInteractor = appGraph.sentryComponent.sentryInteractor, + analyticInteractor = appGraph.analyticComponent.analyticInteractor + ) + + override val challengeWidgetViewStateMapper: ChallengeWidgetViewStateMapper + get() = ChallengeWidgetViewStateMapper( + dateFormatter = appGraph.commonComponent.dateFormatter, + resourceProvider = appGraph.commonComponent.resourceProvider + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetActionDispatcher.kt new file mode 100644 index 0000000000..8ff0afd4f6 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetActionDispatcher.kt @@ -0,0 +1,132 @@ +package org.hyperskill.app.challenges.widget.presentation + +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.challenges.domain.repository.ChallengesRepository +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature.Action +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature.InternalAction +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature.InternalMessage +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature.Message +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.magic_links.domain.interactor.MagicLinksInteractor +import org.hyperskill.app.sentry.domain.interactor.SentryInteractor +import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder +import org.hyperskill.app.sentry.domain.withTransaction +import org.hyperskill.app.step_completion.domain.flow.DailyStepCompletedFlow +import org.hyperskill.app.step_completion.domain.flow.TopicCompletedFlow +import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher + +class ChallengeWidgetActionDispatcher( + config: ActionDispatcherOptions, + solvedStepsSharedFlow: SharedFlow, + topicCompletedFlow: TopicCompletedFlow, + dailyStepCompletedFlow: DailyStepCompletedFlow, + private val challengesRepository: ChallengesRepository, + private val magicLinksInteractor: MagicLinksInteractor, + private val sentryInteractor: SentryInteractor, + private val analyticInteractor: AnalyticInteractor +) : CoroutineActionDispatcher(config.createConfig()) { + private var timerJob: Job? = null + + companion object { + private val TIMER_TICK_INTERVAL = 1.toDuration(DurationUnit.MINUTES) + } + + init { + solvedStepsSharedFlow + .distinctUntilChanged() + .onEach { + onNewMessage(InternalMessage.StepSolved) + } + .launchIn(actionScope) + + dailyStepCompletedFlow.observe() + .distinctUntilChanged() + .onEach { + onNewMessage(InternalMessage.DailyStepCompleted) + } + .launchIn(actionScope) + + topicCompletedFlow.observe() + .distinctUntilChanged() + .onEach { + onNewMessage(InternalMessage.TopicCompleted) + } + .launchIn(actionScope) + } + + override suspend fun doSuspendableAction(action: Action) { + when (action) { + InternalAction.FetchChallenges -> + handleFetchChallengesAction(::onNewMessage) + is InternalAction.CreateMagicLink -> + handleCreateMagicLinkAction(action, ::onNewMessage) + is InternalAction.LogAnalyticEvent -> + analyticInteractor.logEvent(action.analyticEvent) + InternalAction.LaunchTimer -> + handleLaunchTimerAction(::onNewMessage) + InternalAction.StopTimer -> + handleStopTimerAction() + else -> { + // no op + } + } + } + + private suspend fun handleFetchChallengesAction(onNewMessage: (Message) -> Unit) { + sentryInteractor.withTransaction( + HyperskillSentryTransactionBuilder.buildChallengeWidgetFeatureFetchChallenges(), + onError = { InternalMessage.FetchChallengesError } + ) { + challengesRepository + .getChallenges() + .getOrThrow() + .let(InternalMessage::FetchChallengesSuccess) + }.let(onNewMessage) + } + + private suspend fun handleCreateMagicLinkAction( + action: InternalAction.CreateMagicLink, + onNewMessage: (Message) -> Unit + ) { + magicLinksInteractor + .createMagicLink(nextUrl = action.nextUrl) + .fold( + onSuccess = { magicLink -> + InternalMessage.CreateMagicLinkSuccess(url = magicLink.url) + }, + onFailure = { + InternalMessage.CreateMagicLinkError + } + ) + .let(onNewMessage) + } + + private fun handleLaunchTimerAction(onNewMessage: (Message) -> Unit) { + if (timerJob != null) { + return + } + + timerJob = flow { + while (true) { + delay(TIMER_TICK_INTERVAL) + emit(Unit) + } + }.onEach { + onNewMessage(InternalMessage.TimerTick) + }.launchIn(actionScope) + } + + private fun handleStopTimerAction() { + timerJob?.cancel() + timerJob = null + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetFeature.kt new file mode 100644 index 0000000000..99255c9e83 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetFeature.kt @@ -0,0 +1,118 @@ +package org.hyperskill.app.challenges.widget.presentation + +import org.hyperskill.app.analytic.domain.model.AnalyticEvent +import org.hyperskill.app.challenges.domain.model.Challenge +import org.hyperskill.app.challenges.domain.model.ChallengeStatus +import org.hyperskill.app.challenges.widget.view.mapper.ChallengeWidgetViewStateMapper +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState + +object ChallengeWidgetFeature { + sealed interface State { + object Idle : State + data class Loading(val isLoadingSilently: Boolean) : State + object Error : State + data class Content( + val challenge: Challenge?, + val isLoadingMagicLink: Boolean = false, + internal val isRefreshing: Boolean = false + ) : State + } + + internal val State.isRefreshing: Boolean + get() = this is State.Content && isRefreshing + + sealed interface Message { + object RetryContentLoading : Message + + /** + * When view state is [ChallengeWidgetViewState.Content.Announcement] or + * [ChallengeWidgetViewState.Content.HappeningNow] description text can contain links. + * + * Send this message when user clicks on a link in the description text. + * + * @property url URL of the clicked link. + * + * @see ChallengeWidgetViewState.Content.Announcement + * @see ChallengeWidgetViewState.Content.HappeningNow + */ + data class LinkInTheDescriptionClicked(val url: String) : Message + + /** + * When view state is [ChallengeWidgetViewState.Content.Announcement] or + * [ChallengeWidgetViewState.Content.HappeningNow] deadline can be reached (starts in and complete in). + * + * Send this message when user clicks on the "Reload" button. + * + * @see ChallengeWidgetViewState.Content.Announcement + * @see ChallengeWidgetViewState.Content.HappeningNow + */ + object DeadlineReachedReloadClicked : Message + + /** + * When view state is [ChallengeWidgetViewState.Content.Completed] or + * [ChallengeWidgetViewState.Content.PartiallyCompleted] and [Challenge.rewardLink] is not null + * the user can collect the reward. + * + * Send this message when user clicks on the "Collect Reward" button. + * + * @see ChallengeWidgetViewState.Content.CollectRewardButtonState + */ + object CollectRewardClicked : Message + } + + internal sealed interface InternalMessage : Message { + data class Initialize(val forceUpdate: Boolean = false) : InternalMessage + object FetchChallengesError : InternalMessage + data class FetchChallengesSuccess(val challenges: List) : InternalMessage + + object PullToRefresh : InternalMessage + + object CreateMagicLinkError : InternalMessage + data class CreateMagicLinkSuccess(val url: String) : InternalMessage + + /** + * In general this message is sent when the timer is started and when challenge status is + * [ChallengeStatus.NOT_STARTED] or [ChallengeStatus.STARTED]. + * + * [ChallengeWidgetViewState.Content.Announcement] and [ChallengeWidgetViewState.Content.HappeningNow] + * view states has a formatted time remaining text ("Starts in" and "Complete in") + * and those texts should be updated every minute. + * + * When challenge status is [ChallengeStatus.NOT_STARTED] or [ChallengeStatus.STARTED] reducer handler function + * [ChallengeWidgetReducer.handleTimerTickMessage] don't changes state and don't produces any actions because + * formatting is done in the view state mapper [ChallengeWidgetViewStateMapper]. + * + * @see InternalAction.LaunchTimer + * @see InternalAction.StopTimer + * @see ChallengeWidgetReducer.handleTimerTickMessage + */ + object TimerTick : InternalMessage + + // Observe target types changes + object StepSolved : InternalMessage + object DailyStepCompleted : InternalMessage + object TopicCompleted : InternalMessage + } + + sealed interface Action { + sealed interface ViewAction : Action { + object ShowNetworkError : ViewAction + + data class OpenUrl( + val url: String, + val shouldOpenInApp: Boolean + ) : ViewAction + } + } + + internal sealed interface InternalAction : Action { + object FetchChallenges : InternalAction + + object LaunchTimer : InternalAction + object StopTimer : InternalAction + + data class CreateMagicLink(val nextUrl: String) : InternalAction + + data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetReducer.kt new file mode 100644 index 0000000000..8181a023f7 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetReducer.kt @@ -0,0 +1,256 @@ +package org.hyperskill.app.challenges.widget.presentation + +import org.hyperskill.app.challenges.domain.model.ChallengeStatus +import org.hyperskill.app.challenges.domain.model.ChallengeTargetType +import org.hyperskill.app.challenges.widget.domain.analytic.ChallengeWidgetClickedCollectRewardHyperskillAnalyticEvent +import org.hyperskill.app.challenges.widget.domain.analytic.ChallengeWidgetClickedDeadlineReloadHyperskillAnalyticEvent +import org.hyperskill.app.challenges.widget.domain.analytic.ChallengeWidgetClickedLinkInTheDescriptionHyperskillAnalyticEvent +import org.hyperskill.app.challenges.widget.domain.analytic.ChallengeWidgetClickedRetryContentLoadingHyperskillAnalyticEvent +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature.Action +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature.InternalAction +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature.InternalMessage +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature.Message +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature.State +import ru.nobird.app.presentation.redux.reducer.StateReducer + +private typealias ChallengeWidgetReducerResult = Pair> + +class ChallengeWidgetReducer : StateReducer { + override fun reduce(state: State, message: Message): ChallengeWidgetReducerResult = + when (message) { + is InternalMessage.Initialize -> + handleInitializeMessage(state, message) + InternalMessage.FetchChallengesError -> + State.Error to emptySet() + is InternalMessage.FetchChallengesSuccess -> + handleFetchChallengesSuccessMessage(message) + Message.RetryContentLoading -> + handleRetryContentLoadingMessage(state) + InternalMessage.PullToRefresh -> + handlePullToRefreshMessage(state) + is Message.LinkInTheDescriptionClicked -> + handleLinkInTheDescriptionClickedMessage(state, message) + Message.DeadlineReachedReloadClicked -> + handleDeadlineReachedReloadClickedMessage(state) + Message.CollectRewardClicked -> + handleCollectRewardClickedMessage(state) + InternalMessage.CreateMagicLinkError -> + handleCreateMagicLinkFailureMessage(state) + is InternalMessage.CreateMagicLinkSuccess -> + handleCreateMagicLinkSuccessMessage(state, message) + InternalMessage.StepSolved -> + handleStepSolvedMessage(state) + InternalMessage.DailyStepCompleted -> + handleDailyStepCompletedMessage(state) + InternalMessage.TopicCompleted -> + handleTopicCompletedMessage(state) + InternalMessage.TimerTick -> + handleTimerTickMessage(state) + } ?: (state to emptySet()) + + private fun handleInitializeMessage( + state: State, + message: InternalMessage.Initialize + ): ChallengeWidgetReducerResult? = + when (state) { + State.Idle -> + State.Loading(isLoadingSilently = false) to setOf(InternalAction.FetchChallenges) + State.Error -> + if (message.forceUpdate) { + State.Loading(isLoadingSilently = false) to setOf(InternalAction.FetchChallenges) + } else { + null + } + is State.Content -> + if (message.forceUpdate) { + State.Loading( + isLoadingSilently = state.challenge == null + ) to setOf(InternalAction.FetchChallenges) + } else { + null + } + is State.Loading -> null + } + + private fun handleFetchChallengesSuccessMessage( + message: InternalMessage.FetchChallengesSuccess + ): ChallengeWidgetReducerResult { + val newState = State.Content( + challenge = message.challenges.firstOrNull() + ) + + val actions = when (newState.challenge?.status) { + ChallengeStatus.NOT_STARTED, + ChallengeStatus.STARTED -> + setOf(InternalAction.LaunchTimer) + else -> + emptySet() + } + + return newState to actions + } + + private fun handleRetryContentLoadingMessage(state: State): ChallengeWidgetReducerResult? = + if (state is State.Error) { + State.Loading(isLoadingSilently = false) to setOf( + InternalAction.FetchChallenges, + InternalAction.LogAnalyticEvent(ChallengeWidgetClickedRetryContentLoadingHyperskillAnalyticEvent) + ) + } else { + null + } + + private fun handlePullToRefreshMessage(state: State): ChallengeWidgetReducerResult? = + when (state) { + is State.Content -> + if (state.isRefreshing) { + null + } else { + state.copy(isRefreshing = true) to setOf(InternalAction.FetchChallenges) + } + State.Error -> + State.Loading(isLoadingSilently = false) to setOf(InternalAction.FetchChallenges) + else -> + null + } + + private fun handleLinkInTheDescriptionClickedMessage( + state: State, + message: Message.LinkInTheDescriptionClicked + ): ChallengeWidgetReducerResult? = + if (state is State.Content) { + state to setOf( + Action.ViewAction.OpenUrl(url = message.url, shouldOpenInApp = true), + InternalAction.LogAnalyticEvent( + ChallengeWidgetClickedLinkInTheDescriptionHyperskillAnalyticEvent( + challengeId = state.challenge?.id, + url = message.url + ) + ) + ) + } else { + null + } + + private fun handleDeadlineReachedReloadClickedMessage(state: State): ChallengeWidgetReducerResult? = + if (state is State.Content) { + val newState = if (state.isRefreshing) { + state + } else { + State.Loading(isLoadingSilently = false) + } + + newState to setOf( + InternalAction.LogAnalyticEvent( + ChallengeWidgetClickedDeadlineReloadHyperskillAnalyticEvent(challengeId = state.challenge?.id) + ) + ) + } else { + null + } + + private fun handleCollectRewardClickedMessage(state: State): ChallengeWidgetReducerResult? = + if (state is State.Content) { + state.copy(isLoadingMagicLink = true) to buildSet { + state.challenge?.rewardLink?.let { + add(InternalAction.CreateMagicLink(nextUrl = it)) + } + add( + InternalAction.LogAnalyticEvent( + ChallengeWidgetClickedCollectRewardHyperskillAnalyticEvent( + challengeId = state.challenge?.id + ) + ) + ) + } + } else { + null + } + + private fun handleCreateMagicLinkFailureMessage(state: State): ChallengeWidgetReducerResult? = + if (state is State.Content) { + state.copy(isLoadingMagicLink = false) to setOf(Action.ViewAction.ShowNetworkError) + } else { + null + } + + private fun handleCreateMagicLinkSuccessMessage( + state: State, + message: InternalMessage.CreateMagicLinkSuccess + ): ChallengeWidgetReducerResult? = + if (state is State.Content) { + state.copy(isLoadingMagicLink = false) to setOf( + Action.ViewAction.OpenUrl(url = message.url, shouldOpenInApp = false) + ) + } else { + null + } + + private fun handleStepSolvedMessage(state: State): ChallengeWidgetReducerResult? { + if (state is State.Content) { + val currentChallenge = state.challenge ?: return null + val currentChallengeTargetType = currentChallenge.targetType + + if (currentChallengeTargetType != null) { + return when (currentChallengeTargetType) { + ChallengeTargetType.DAILY_STEP, + ChallengeTargetType.TOPIC -> + null + ChallengeTargetType.PROJECT, + ChallengeTargetType.STAGE -> + state to setOf(InternalAction.FetchChallenges) + ChallengeTargetType.STEP -> + state.copy( + challenge = state.setCurrentChallengeIntervalProgressAsCompleted() ?: state.challenge + ) to emptySet() + } + } else { + return state to setOf(InternalAction.FetchChallenges) + } + } else { + return null + } + } + + private fun handleDailyStepCompletedMessage(state: State): ChallengeWidgetReducerResult? = + if (state is State.Content && + state.challenge?.targetType == ChallengeTargetType.DAILY_STEP + ) { + state.copy( + challenge = state.setCurrentChallengeIntervalProgressAsCompleted() ?: state.challenge + ) to emptySet() + } else { + null + } + + private fun handleTopicCompletedMessage(state: State): ChallengeWidgetReducerResult? = + if (state is State.Content && + state.challenge?.targetType == ChallengeTargetType.TOPIC + ) { + state.copy( + challenge = state.setCurrentChallengeIntervalProgressAsCompleted() ?: state.challenge + ) to emptySet() + } else { + null + } + + private fun handleTimerTickMessage(state: State): ChallengeWidgetReducerResult = + when (state) { + State.Idle, + State.Error, + is State.Loading -> + state to setOf(InternalAction.StopTimer) + is State.Content -> { + when (state.challenge?.status) { + null, + ChallengeStatus.COMPLETED, + ChallengeStatus.PARTIAL_COMPLETED, + ChallengeStatus.NOT_COMPLETED -> + state to setOf(InternalAction.StopTimer) + ChallengeStatus.NOT_STARTED, + ChallengeStatus.STARTED -> + state to emptySet() + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/StateExtentions.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/StateExtentions.kt new file mode 100644 index 0000000000..76662b6968 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/StateExtentions.kt @@ -0,0 +1,22 @@ +package org.hyperskill.app.challenges.widget.presentation + +import org.hyperskill.app.challenges.domain.model.Challenge +import ru.nobird.app.core.model.mutate + +internal fun ChallengeWidgetFeature.State.Content.setCurrentChallengeIntervalProgressAsCompleted(): Challenge? { + val currentChallenge = challenge ?: return null + + val currentIntervalIndex = currentChallenge.currentInterval + ?.minus(1) + ?.takeIf { it > 0 && currentChallenge.progress.size > it } + ?: return null + + val newProgress = currentChallenge.progress.mutate { + set( + index = currentIntervalIndex, + element = true + ) + } + + return currentChallenge.copy(progress = newProgress) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/view/mapper/ChallengeWidgetViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/view/mapper/ChallengeWidgetViewStateMapper.kt new file mode 100644 index 0000000000..2652fd56c5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/view/mapper/ChallengeWidgetViewStateMapper.kt @@ -0,0 +1,210 @@ +package org.hyperskill.app.challenges.widget.view.mapper + +import kotlinx.datetime.Clock +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import org.hyperskill.app.SharedResources +import org.hyperskill.app.challenges.domain.model.Challenge +import org.hyperskill.app.challenges.domain.model.ChallengeStatus +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter + +class ChallengeWidgetViewStateMapper( + private val dateFormatter: SharedDateFormatter, + private val resourceProvider: ResourceProvider +) { + companion object { + private val TIME_ZONE_NYC = TimeZone.of("America/New_York") + } + + fun map(state: ChallengeWidgetFeature.State): ChallengeWidgetViewState = + when (state) { + ChallengeWidgetFeature.State.Idle -> ChallengeWidgetViewState.Idle + ChallengeWidgetFeature.State.Error -> ChallengeWidgetViewState.Error + is ChallengeWidgetFeature.State.Loading -> + ChallengeWidgetViewState.Loading(shouldShowSkeleton = !state.isLoadingSilently) + is ChallengeWidgetFeature.State.Content -> getLoadedWidgetContent(state) + } + + private fun getLoadedWidgetContent(state: ChallengeWidgetFeature.State.Content): ChallengeWidgetViewState { + val challenge = state.challenge ?: return ChallengeWidgetViewState.Empty + val challengeStatus = challenge.status ?: return ChallengeWidgetViewState.Empty + + val headerData = getHeaderData( + challenge = challenge, + challengeStatus = challengeStatus + ) + + return when (challengeStatus) { + ChallengeStatus.NOT_STARTED -> { + ChallengeWidgetViewState.Content.Announcement( + headerData = headerData, + startsInState = getStartsInState(challenge) + ) + } + ChallengeStatus.STARTED -> { + ChallengeWidgetViewState.Content.HappeningNow( + headerData = headerData, + completeInState = getCompleteInState(challenge), + progressStatuses = getProgressStatuses(challenge) + ) + } + ChallengeStatus.COMPLETED -> { + ChallengeWidgetViewState.Content.Completed( + headerData = headerData, + collectRewardButtonState = getCollectRewardButtonState( + challengeStatus = challengeStatus, + rewardLink = challenge.rewardLink + ), + isLoadingMagicLink = state.isLoadingMagicLink + ) + } + ChallengeStatus.PARTIAL_COMPLETED -> { + ChallengeWidgetViewState.Content.PartiallyCompleted( + headerData = headerData, + collectRewardButtonState = getCollectRewardButtonState( + challengeStatus = challengeStatus, + rewardLink = challenge.rewardLink + ), + isLoadingMagicLink = state.isLoadingMagicLink + ) + } + ChallengeStatus.NOT_COMPLETED -> { + ChallengeWidgetViewState.Content.Ended(headerData = headerData) + } + } + } + + private fun getHeaderData( + challenge: Challenge, + challengeStatus: ChallengeStatus + ): ChallengeWidgetViewState.Content.HeaderData { + val description = when (challengeStatus) { + ChallengeStatus.NOT_STARTED, + ChallengeStatus.STARTED -> + challenge.description + ChallengeStatus.COMPLETED -> + resourceProvider.getString(SharedResources.strings.challenge_widget_status_completed_title) + ChallengeStatus.PARTIAL_COMPLETED -> + resourceProvider.getString(SharedResources.strings.challenge_widget_status_partial_completed_title) + ChallengeStatus.NOT_COMPLETED -> + resourceProvider.getString(SharedResources.strings.challenge_widget_status_not_completed_title) + } + + return ChallengeWidgetViewState.Content.HeaderData( + title = challenge.title, + description = description, + formattedDurationOfTime = getFormattedDurationOfTime( + startingDate = challenge.startingDate, + finishDate = challenge.finishDate + ) + ) + } + + private fun getFormattedDurationOfTime(startingDate: LocalDate, finishDate: LocalDate): String { + if (startingDate == finishDate) { + return dateFormatter.formatDayNumericAndMonthShort(startingDate) + } + + val formattedStartingDate = dateFormatter.formatDayNumericAndMonthShort(startingDate) + val formattedFinishDate = dateFormatter.formatDayNumericAndMonthShort(finishDate) + + return "$formattedStartingDate - $formattedFinishDate" + } + + private fun calculateTimeRemaining(deadline: LocalDate): Long { + val deadlineInstant = LocalDateTime( + date = deadline, + time = LocalTime(0, 0, 0, 0) + ).toInstant(TIME_ZONE_NYC) + + val nowInNewYork = Clock.System.now() + .toLocalDateTime(TIME_ZONE_NYC) + .toInstant(TIME_ZONE_NYC) + + return (deadlineInstant - nowInNewYork).inWholeSeconds + } + + private fun getStartsInState(challenge: Challenge): ChallengeWidgetViewState.Content.Announcement.StartsInState { + val timeRemaining = calculateTimeRemaining(deadline = challenge.startingDate) + return if (timeRemaining > 0) { + ChallengeWidgetViewState.Content.Announcement.StartsInState.TimeRemaining( + title = resourceProvider.getString(SharedResources.strings.challenge_widget_starts_in_text), + subtitle = dateFormatter.formatDaysWithHoursAndMinutesCount(seconds = timeRemaining) + ) + } else { + ChallengeWidgetViewState.Content.Announcement.StartsInState.Deadline + } + } + + private fun getCollectRewardButtonState( + challengeStatus: ChallengeStatus, + rewardLink: String? + ): ChallengeWidgetViewState.Content.CollectRewardButtonState = + if ( + (challengeStatus == ChallengeStatus.COMPLETED || challengeStatus == ChallengeStatus.PARTIAL_COMPLETED) && + !rewardLink.isNullOrEmpty() + ) { + ChallengeWidgetViewState.Content.CollectRewardButtonState.Visible( + title = resourceProvider.getString(SharedResources.strings.challenge_widget_collect_reward_button_title) + ) + } else { + ChallengeWidgetViewState.Content.CollectRewardButtonState.Hidden + } + + private fun getCompleteInState( + challenge: Challenge + ): ChallengeWidgetViewState.Content.HappeningNow.CompleteInState { + if (challenge.currentInterval == null) { + return ChallengeWidgetViewState.Content.HappeningNow.CompleteInState.Empty + } + + val nextDeadline = + challenge.startingDate.plus(DatePeriod(days = challenge.currentInterval * challenge.intervalDurationDays)) + val timeRemaining = calculateTimeRemaining(deadline = nextDeadline) + + return if (timeRemaining > 0) { + ChallengeWidgetViewState.Content.HappeningNow.CompleteInState.TimeRemaining( + title = resourceProvider.getString(SharedResources.strings.challenge_widget_complete_in_text), + subtitle = dateFormatter.formatDaysWithHoursAndMinutesCount(seconds = timeRemaining) + ) + } else { + ChallengeWidgetViewState.Content.HappeningNow.CompleteInState.Deadline + } + } + + private fun getProgressStatuses( + challenge: Challenge + ): List { + if (challenge.currentInterval == null || challenge.intervalsCount != challenge.progress.size) { + return emptyList() + } + + return challenge.progress.mapIndexed { index, progressBoolValue -> + val isPreviousInterval = index + 1 < challenge.currentInterval + val isCurrentInterval = index + 1 == challenge.currentInterval + + when { + isPreviousInterval -> if (progressBoolValue) { + ChallengeWidgetViewState.Content.HappeningNow.ProgressStatus.COMPLETED + } else { + ChallengeWidgetViewState.Content.HappeningNow.ProgressStatus.MISSED + } + isCurrentInterval -> if (progressBoolValue) { + ChallengeWidgetViewState.Content.HappeningNow.ProgressStatus.COMPLETED + } else { + ChallengeWidgetViewState.Content.HappeningNow.ProgressStatus.ACTIVE + } + else -> ChallengeWidgetViewState.Content.HappeningNow.ProgressStatus.INACTIVE + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/view/model/ChallengeWidgetViewState.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/view/model/ChallengeWidgetViewState.kt new file mode 100644 index 0000000000..38a14cb63b --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/view/model/ChallengeWidgetViewState.kt @@ -0,0 +1,83 @@ +package org.hyperskill.app.challenges.widget.view.model + +sealed interface ChallengeWidgetViewState { + object Idle : ChallengeWidgetViewState + data class Loading(val shouldShowSkeleton: Boolean) : ChallengeWidgetViewState + object Error : ChallengeWidgetViewState + object Empty : ChallengeWidgetViewState + + interface WithHeaderData { + val headerData: Content.HeaderData + } + + sealed interface Content : ChallengeWidgetViewState, WithHeaderData { + data class Announcement( + override val headerData: HeaderData, + val startsInState: StartsInState + ) : Content { + sealed interface StartsInState { + object Deadline : StartsInState + data class TimeRemaining( + val title: String, + val subtitle: String + ) : StartsInState + } + } + + data class HappeningNow( + override val headerData: HeaderData, + val completeInState: CompleteInState, + val progressStatuses: List + ) : Content { + sealed interface CompleteInState { + object Empty : CompleteInState + object Deadline : CompleteInState + data class TimeRemaining( + val title: String, + val subtitle: String + ) : CompleteInState + } + + enum class ProgressStatus { + INACTIVE, + ACTIVE, + COMPLETED, + MISSED + } + } + + data class Completed( + override val headerData: HeaderData, + val collectRewardButtonState: CollectRewardButtonState, + val isLoadingMagicLink: Boolean + ) : Content + + data class PartiallyCompleted( + override val headerData: HeaderData, + val collectRewardButtonState: CollectRewardButtonState, + val isLoadingMagicLink: Boolean + ) : Content + + data class Ended( + override val headerData: HeaderData + ) : Content + + data class HeaderData( + val title: String, + val description: String, + val formattedDurationOfTime: String + ) + + sealed interface CollectRewardButtonState { + object Hidden : CollectRewardButtonState + data class Visible(val title: String) : CollectRewardButtonState + } + } +} + +val ChallengeWidgetViewState.Content.isLoadingMagicLink: Boolean + get() = when (this) { + is ChallengeWidgetViewState.Content.Completed -> isLoadingMagicLink + is ChallengeWidgetViewState.Content.PartiallyCompleted -> isLoadingMagicLink + else -> false + } \ 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 950261f7c1..de3095826f 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 @@ -6,6 +6,8 @@ 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.challenges.injection.ChallengesDataComponent +import org.hyperskill.app.challenges.widget.injection.ChallengeWidgetComponent import org.hyperskill.app.comments.injection.CommentsDataComponent import org.hyperskill.app.debug.injection.DebugComponent import org.hyperskill.app.devices.injection.DevicesDataComponent @@ -59,7 +61,6 @@ import org.hyperskill.app.step_quiz_hints.injection.StepQuizHintsComponent import org.hyperskill.app.streak_recovery.injection.StreakRecoveryComponent import org.hyperskill.app.streaks.injection.StreakFlowDataComponent import org.hyperskill.app.streaks.injection.StreaksDataComponent -import org.hyperskill.app.study_plan.injection.StudyPlanDataComponent import org.hyperskill.app.study_plan.screen.injection.StudyPlanScreenComponent import org.hyperskill.app.study_plan.widget.injection.StudyPlanWidgetComponent import org.hyperskill.app.topics.injection.TopicsDataComponent @@ -135,7 +136,6 @@ interface AppGraph { fun buildItemsDataComponent(): ItemsDataComponent fun buildDebugComponent(): DebugComponent fun buildGamificationToolbarComponent(screen: GamificationToolbarScreen): GamificationToolbarComponent - fun buildStudyPlanDataComponent(): StudyPlanDataComponent fun buildProjectsDataComponent(): ProjectsDataComponent fun buildProjectSelectionListComponent(): ProjectSelectionListComponent fun buildProjectSelectionDetailsComponent(): ProjectSelectionDetailsComponent @@ -153,4 +153,6 @@ interface AppGraph { fun buildNotificationsOnboardingComponent(): NotificationsOnboardingComponent fun buildFirstProblemOnboardingComponent(): FirstProblemOnboardingComponent fun buildShareStreakDataComponent(): ShareStreakDataComponent + fun buildChallengesDataComponent(): ChallengesDataComponent + fun buildChallengeWidgetComponent(): ChallengeWidgetComponent } \ 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 b904435b4d..5804bd9f0b 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 @@ -10,6 +10,10 @@ 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.challenges.injection.ChallengesDataComponent +import org.hyperskill.app.challenges.injection.ChallengesDataComponentImpl +import org.hyperskill.app.challenges.widget.injection.ChallengeWidgetComponent +import org.hyperskill.app.challenges.widget.injection.ChallengeWidgetComponentImpl import org.hyperskill.app.comments.injection.CommentsDataComponent import org.hyperskill.app.comments.injection.CommentsDataComponentImpl import org.hyperskill.app.debug.injection.DebugComponent @@ -109,8 +113,6 @@ import org.hyperskill.app.streaks.injection.StreakFlowDataComponent import org.hyperskill.app.streaks.injection.StreakFlowDataComponentImpl import org.hyperskill.app.streaks.injection.StreaksDataComponent import org.hyperskill.app.streaks.injection.StreaksDataComponentImpl -import org.hyperskill.app.study_plan.injection.StudyPlanDataComponent -import org.hyperskill.app.study_plan.injection.StudyPlanDataComponentImpl import org.hyperskill.app.study_plan.screen.injection.StudyPlanScreenComponent import org.hyperskill.app.study_plan.screen.injection.StudyPlanScreenComponentImpl import org.hyperskill.app.study_plan.widget.injection.StudyPlanWidgetComponent @@ -336,9 +338,6 @@ abstract class BaseAppGraph : AppGraph { override fun buildStudyPlanScreenComponent(): StudyPlanScreenComponent = StudyPlanScreenComponentImpl(this) - override fun buildStudyPlanDataComponent(): StudyPlanDataComponent = - StudyPlanDataComponentImpl(this) - override fun buildUserStorageComponent(): UserStorageComponent = UserStorageComponentImpl(this) @@ -416,4 +415,10 @@ abstract class BaseAppGraph : AppGraph { override fun buildShareStreakDataComponent(): ShareStreakDataComponent = ShareStreakDataComponentImpl(this) + + override fun buildChallengesDataComponent(): ChallengesDataComponent = + ChallengesDataComponentImpl(this) + + override fun buildChallengeWidgetComponent(): ChallengeWidgetComponent = + ChallengeWidgetComponentImpl(this) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/CommonComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/CommonComponent.kt index f46fa08de9..e86851ebcd 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/CommonComponent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/CommonComponent.kt @@ -7,7 +7,7 @@ import org.hyperskill.app.core.domain.platform.Platform import org.hyperskill.app.core.remote.UserAgentInfo import org.hyperskill.app.core.view.mapper.NumbersFormatter import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter interface CommonComponent { val json: Json diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/MonthFormatter.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/MonthFormatter.kt new file mode 100644 index 0000000000..50989299c3 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/MonthFormatter.kt @@ -0,0 +1,28 @@ +package org.hyperskill.app.core.view.mapper.date + +import kotlinx.datetime.Month + +internal object MonthFormatter { + /** + * Format month to 3 letters abbreviation + * + * @param month month to format + * @return 3 letters abbreviation of month + */ + fun formatMonthToShort(month: Month): String = + when (month) { + Month.JANUARY -> "Jan" + Month.FEBRUARY -> "Feb" + Month.MARCH -> "Mar" + Month.APRIL -> "Apr" + Month.MAY -> "May" + Month.JUNE -> "Jun" + Month.JULY -> "Jul" + Month.AUGUST -> "Aug" + Month.SEPTEMBER -> "Sep" + Month.OCTOBER -> "Oct" + Month.NOVEMBER -> "Nov" + Month.DECEMBER -> "Dec" + else -> throw IllegalArgumentException("MonthFormatter: unknown month $month") + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/SharedDateFormatter.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/SharedDateFormatter.kt similarity index 78% rename from shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/SharedDateFormatter.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/SharedDateFormatter.kt index 392998abfd..9bc4e39891 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/SharedDateFormatter.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/SharedDateFormatter.kt @@ -1,10 +1,12 @@ -package org.hyperskill.app.core.view.mapper +package org.hyperskill.app.core.view.mapper.date import kotlin.math.max import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.toDuration +import kotlinx.datetime.LocalDate import org.hyperskill.app.SharedResources +import org.hyperskill.app.core.view.mapper.ResourceProvider class SharedDateFormatter(private val resourceProvider: ResourceProvider) { companion object { @@ -23,6 +25,9 @@ class SharedDateFormatter(private val resourceProvider: ResourceProvider) { private const val DAYS_IN_YEAR = 365 private const val MONTHS_IN_YEAR = 12 private val DURATION_ONE_YEAR = DAYS_IN_YEAR.toDuration(DurationUnit.DAYS) + + private const val HOURS_IN_DAY = 24 + private const val MINUTES_IN_HOUR = 60 } fun formatTimeDistance(millis: Long): String { @@ -74,11 +79,41 @@ class SharedDateFormatter(private val resourceProvider: ResourceProvider) { ) } + /** + * Format days, hours and minutes count with localized and pluralized suffix; + * 86400 -> "1 day", 86460 -> "1 day 1 minute", 90000 -> "1 day 1 hour", 59 -> "1 minute" + * + * @param seconds Seconds to format + * @return formatted days, hours and minutes count + */ + fun formatDaysWithHoursAndMinutesCount(seconds: Long): String { + val duration = seconds.toDuration(DurationUnit.SECONDS) + + val days = duration.inWholeDays.toInt() + val hours = duration.inWholeHours.toInt() % HOURS_IN_DAY + val minutes = duration.inWholeMinutes.toInt() % MINUTES_IN_HOUR + + if (days == 0 && hours == 0 && minutes == 0) { + return resourceProvider.getQuantityString(SharedResources.plurals.minutes, 1, 1) + } + + return buildString { + if (days > 0) { + append("${resourceProvider.getQuantityString(SharedResources.plurals.days, days, days)} ") + } + if (hours > 0) { + append("${resourceProvider.getQuantityString(SharedResources.plurals.hours, hours, hours)} ") + } + if (minutes > 0) { + append(resourceProvider.getQuantityString(SharedResources.plurals.minutes, minutes, minutes)) + } + } + } + /** * Format hours and minutes count with localized and pluralized suffix; * 7260 -> "2 hours 1 minute", 7320 -> "2 hours 2 minute", 21600 -> "6 hours" * @param seconds Seconds to format - * */ fun formatHoursWithMinutesCount(seconds: Long): String { val duration = seconds.toDuration(DurationUnit.SECONDS) @@ -183,4 +218,15 @@ class SharedDateFormatter(private val resourceProvider: ResourceProvider) { resourceProvider.getQuantityString(SharedResources.plurals.seconds, seconds, seconds) } } + + /** + * Format month and day of local date; + * + * 2023-11-02 -> "2 Nov" + * + * @param localDate local date to format + * @return formatted month and day + */ + fun formatDayNumericAndMonthShort(localDate: LocalDate): String = + "${localDate.dayOfMonth} ${MonthFormatter.formatMonthToShort(localDate.month)}" } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeComponent.kt index d617a0c76d..06547a67dc 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeComponent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeComponent.kt @@ -4,5 +4,5 @@ import org.hyperskill.app.home.presentation.HomeFeature import ru.nobird.app.presentation.redux.feature.Feature interface HomeComponent { - val homeFeature: Feature + val homeFeature: Feature } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeComponentImpl.kt index 18ab7003ea..fcd00a9209 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeComponentImpl.kt @@ -1,5 +1,6 @@ package org.hyperskill.app.home.injection +import org.hyperskill.app.challenges.widget.injection.ChallengeWidgetComponent import org.hyperskill.app.core.injection.AppGraph import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarScreen import org.hyperskill.app.gamification_toolbar.injection.GamificationToolbarComponent @@ -7,14 +8,17 @@ import org.hyperskill.app.home.domain.interactor.HomeInteractor import org.hyperskill.app.home.presentation.HomeFeature import ru.nobird.app.presentation.redux.feature.Feature -class HomeComponentImpl(private val appGraph: AppGraph) : HomeComponent { +internal class HomeComponentImpl(private val appGraph: AppGraph) : HomeComponent { private val homeInteractor: HomeInteractor = HomeInteractor(appGraph.submissionDataComponent.submissionRepository) private val gamificationToolbarComponent: GamificationToolbarComponent = appGraph.buildGamificationToolbarComponent(GamificationToolbarScreen.HOME) - override val homeFeature: Feature + private val challengeWidgetComponent: ChallengeWidgetComponent = + appGraph.buildChallengeWidgetComponent() + + override val homeFeature: Feature get() = HomeFeatureBuilder.build( homeInteractor, appGraph.profileDataComponent.currentProfileStateRepository, @@ -27,6 +31,9 @@ class HomeComponentImpl(private val appGraph: AppGraph) : HomeComponent { appGraph.topicsRepetitionsFlowDataComponent.topicRepeatedFlow, gamificationToolbarComponent.gamificationToolbarReducer, gamificationToolbarComponent.gamificationToolbarActionDispatcher, + challengeWidgetComponent.challengeWidgetReducer, + challengeWidgetComponent.challengeWidgetActionDispatcher, + challengeWidgetComponent.challengeWidgetViewStateMapper, appGraph.loggerComponent.logger, appGraph.commonComponent.buildKonfig.buildVariant ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeFeatureBuilder.kt index d4e837275c..eec88e0c38 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeFeatureBuilder.kt @@ -2,9 +2,14 @@ package org.hyperskill.app.home.injection import co.touchlab.kermit.Logger import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetActionDispatcher +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetReducer +import org.hyperskill.app.challenges.widget.view.mapper.ChallengeWidgetViewStateMapper import org.hyperskill.app.core.domain.BuildVariant import org.hyperskill.app.core.presentation.ActionDispatcherOptions -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.presentation.transformState +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.freemium.domain.interactor.FreemiumInteractor import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarActionDispatcher import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature @@ -13,6 +18,7 @@ import org.hyperskill.app.home.domain.interactor.HomeInteractor import org.hyperskill.app.home.presentation.HomeActionDispatcher import org.hyperskill.app.home.presentation.HomeFeature import org.hyperskill.app.home.presentation.HomeReducer +import org.hyperskill.app.home.view.mapper.HomeViewStateMapper import org.hyperskill.app.logging.presentation.wrapWithLogger import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.sentry.domain.interactor.SentryInteractor @@ -40,11 +46,15 @@ internal object HomeFeatureBuilder { topicRepeatedFlow: TopicRepeatedFlow, gamificationToolbarReducer: GamificationToolbarReducer, gamificationToolbarActionDispatcher: GamificationToolbarActionDispatcher, + challengeWidgetReducer: ChallengeWidgetReducer, + challengeWidgetActionDispatcher: ChallengeWidgetActionDispatcher, + challengeWidgetViewStateMapper: ChallengeWidgetViewStateMapper, logger: Logger, buildVariant: BuildVariant - ): Feature { + ): Feature { val homeReducer = HomeReducer( - gamificationToolbarReducer + gamificationToolbarReducer = gamificationToolbarReducer, + challengeWidgetReducer = challengeWidgetReducer ).wrapWithLogger(buildVariant, logger, LOG_TAG) val homeActionDispatcher = HomeActionDispatcher( ActionDispatcherOptions(), @@ -58,20 +68,31 @@ internal object HomeFeatureBuilder { dateFormatter, topicRepeatedFlow ) + val homeViewStateMapper = HomeViewStateMapper( + challengeWidgetViewStateMapper = challengeWidgetViewStateMapper + ) return ReduxFeature( HomeFeature.State( homeState = HomeFeature.HomeState.Idle, - toolbarState = GamificationToolbarFeature.State.Idle + toolbarState = GamificationToolbarFeature.State.Idle, + challengeWidgetState = ChallengeWidgetFeature.State.Idle ), homeReducer ) + .transformState(homeViewStateMapper::map) .wrapWithActionDispatcher(homeActionDispatcher) .wrapWithActionDispatcher( gamificationToolbarActionDispatcher.transform( - transformAction = { it.safeCast()?.action }, + transformAction = { it.safeCast()?.action }, transformMessage = HomeFeature.Message::GamificationToolbarMessage ) ) + .wrapWithActionDispatcher( + challengeWidgetActionDispatcher.transform( + transformAction = { it.safeCast()?.action }, + transformMessage = HomeFeature.Message::ChallengeWidgetMessage + ) + ) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeActionDispatcher.kt index 5d4e5d748e..3876fcc64d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeActionDispatcher.kt @@ -18,10 +18,11 @@ import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.core.presentation.ActionDispatcherOptions -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.freemium.domain.interactor.FreemiumInteractor import org.hyperskill.app.home.domain.interactor.HomeInteractor import org.hyperskill.app.home.presentation.HomeFeature.Action +import org.hyperskill.app.home.presentation.HomeFeature.InternalAction import org.hyperskill.app.home.presentation.HomeFeature.Message import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.sentry.domain.interactor.SentryInteractor @@ -32,7 +33,7 @@ import org.hyperskill.app.topics_repetitions.domain.flow.TopicRepeatedFlow import org.hyperskill.app.topics_repetitions.domain.interactor.TopicsRepetitionsInteractor import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher -class HomeActionDispatcher( +internal class HomeActionDispatcher( config: ActionDispatcherOptions, homeInteractor: HomeInteractor, private val currentProfileStateRepository: CurrentProfileStateRepository, @@ -71,8 +72,9 @@ class HomeActionDispatcher( override suspend fun doSuspendableAction(action: Action) { when (action) { - is Action.FetchHomeScreenData -> handleFetchHomeScreenData(::onNewMessage) - is Action.LaunchTimer -> { + is InternalAction.FetchHomeScreenData -> + handleFetchHomeScreenData(::onNewMessage) + is InternalAction.LaunchTimer -> { if (isTimerLaunched) { return } @@ -99,7 +101,7 @@ class HomeActionDispatcher( } .launchIn(actionScope) } - is Action.LogAnalyticEvent -> + is InternalAction.LogAnalyticEvent -> analyticInteractor.logEvent(action.analyticEvent) else -> { // no op diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeFeature.kt index 9f6d6650e5..b40a39cadd 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeFeature.kt @@ -1,22 +1,34 @@ package org.hyperskill.app.home.presentation import org.hyperskill.app.analytic.domain.model.AnalyticEvent +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature.isRefreshing +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature.isRefreshing import org.hyperskill.app.step.domain.model.Step import org.hyperskill.app.step.domain.model.StepRoute import org.hyperskill.app.streaks.domain.model.Streak -interface HomeFeature { - data class State( +object HomeFeature { + internal data class State( val homeState: HomeState, - val toolbarState: GamificationToolbarFeature.State + val toolbarState: GamificationToolbarFeature.State, + val challengeWidgetState: ChallengeWidgetFeature.State ) { val isRefreshing: Boolean get() = homeState is HomeState.Content && homeState.isRefreshing || - toolbarState.isRefreshing + toolbarState.isRefreshing || + challengeWidgetState.isRefreshing } + data class ViewState( + val homeState: HomeState, + val toolbarState: GamificationToolbarFeature.State, + val challengeWidgetViewState: ChallengeWidgetViewState, + val isRefreshing: Boolean + ) + sealed interface HomeState { /** * Represents initial state. @@ -118,30 +130,50 @@ interface HomeFeature { /** * Message Wrappers */ - data class GamificationToolbarMessage(val message: GamificationToolbarFeature.Message) : Message + data class GamificationToolbarMessage( + val message: GamificationToolbarFeature.Message + ) : Message + + data class ChallengeWidgetMessage( + val message: ChallengeWidgetFeature.Message + ) : Message } sealed interface Action { - object FetchHomeScreenData : Action - object LaunchTimer : Action - - data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : Action - - /** - * Action Wrappers - */ - data class GamificationToolbarAction(val action: GamificationToolbarFeature.Action) : Action - sealed interface ViewAction : Action { + sealed interface NavigateTo : ViewAction { + data class StepScreen(val stepRoute: StepRoute) : NavigateTo + object TopicsRepetitionsScreen : NavigateTo + } + /** + * ViewAction Wrappers + */ data class GamificationToolbarViewAction( val viewAction: GamificationToolbarFeature.Action.ViewAction ) : ViewAction - sealed interface NavigateTo : ViewAction { - data class StepScreen(val stepRoute: StepRoute) : NavigateTo - object TopicsRepetitionsScreen : NavigateTo - } + data class ChallengeWidgetViewAction( + val viewAction: ChallengeWidgetFeature.Action.ViewAction + ) : ViewAction } } + + internal sealed interface InternalAction : Action { + object FetchHomeScreenData : InternalAction + object LaunchTimer : InternalAction + + data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction + + /** + * Action Wrappers + */ + data class GamificationToolbarAction( + val action: GamificationToolbarFeature.Action + ) : InternalAction + + data class ChallengeWidgetAction( + val action: ChallengeWidgetFeature.Action + ) : InternalAction + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeReducer.kt index 3ad66fd080..5fd5bf8f83 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeReducer.kt @@ -1,6 +1,8 @@ package org.hyperskill.app.home.presentation import kotlin.math.max +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetReducer import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarReducer import org.hyperskill.app.home.domain.analytic.HomeClickedProblemOfDayCardHyperskillAnalyticEvent @@ -10,14 +12,16 @@ import org.hyperskill.app.home.domain.analytic.HomeClickedTopicsRepetitionsCardH import org.hyperskill.app.home.domain.analytic.HomeViewedHyperskillAnalyticEvent import org.hyperskill.app.home.presentation.HomeFeature.Action import org.hyperskill.app.home.presentation.HomeFeature.HomeState +import org.hyperskill.app.home.presentation.HomeFeature.InternalAction import org.hyperskill.app.home.presentation.HomeFeature.Message import org.hyperskill.app.home.presentation.HomeFeature.State import ru.nobird.app.presentation.redux.reducer.StateReducer private typealias HomeReducerResult = Pair> -class HomeReducer( - private val gamificationToolbarReducer: GamificationToolbarReducer +internal class HomeReducer( + private val gamificationToolbarReducer: GamificationToolbarReducer, + private val challengeWidgetReducer: ChallengeWidgetReducer ) : StateReducer { override fun reduce(state: State, message: Message): HomeReducerResult = when (message) { @@ -41,7 +45,7 @@ class HomeReducer( // Timer Messages is Message.ReadyToLaunchNextProblemInTimer -> if (state.homeState is HomeState.Content) { - state to setOf(Action.LaunchTimer) + state to setOf(InternalAction.LaunchTimer) } else { null } @@ -140,7 +144,7 @@ class HomeReducer( state.homeState.repetitionsState.recommendedRepetitionsCount == 0 state to setOf( Action.ViewAction.NavigateTo.TopicsRepetitionsScreen, - Action.LogAnalyticEvent( + InternalAction.LogAnalyticEvent( HomeClickedTopicsRepetitionsCardHyperskillAnalyticEvent(isCompleted = isCompleted) ) ) @@ -164,7 +168,7 @@ class HomeReducer( } } val logEventAction = if (analyticsEvent != null) { - setOf(Action.LogAnalyticEvent(analyticsEvent)) + setOf(InternalAction.LogAnalyticEvent(analyticsEvent)) } else { emptySet() } @@ -175,13 +179,13 @@ class HomeReducer( } // Analytic Messages is Message.ViewedEventMessage -> - state to setOf(Action.LogAnalyticEvent(HomeViewedHyperskillAnalyticEvent())) + state to setOf(InternalAction.LogAnalyticEvent(HomeViewedHyperskillAnalyticEvent())) is Message.ClickedProblemOfDayCardEventMessage -> { if (state.homeState is HomeState.Content) { when (state.homeState.problemOfDayState) { is HomeFeature.ProblemOfDayState.NeedToSolve -> { state to setOf( - Action.LogAnalyticEvent( + InternalAction.LogAnalyticEvent( HomeClickedProblemOfDayCardHyperskillAnalyticEvent( isCompleted = false ) @@ -190,7 +194,7 @@ class HomeReducer( } is HomeFeature.ProblemOfDayState.Solved -> { state to setOf( - Action.LogAnalyticEvent( + InternalAction.LogAnalyticEvent( HomeClickedProblemOfDayCardHyperskillAnalyticEvent( isCompleted = true ) @@ -211,6 +215,11 @@ class HomeReducer( reduceGamificationToolbarMessage(state.toolbarState, message.message) state.copy(toolbarState = toolbarState) to toolbarActions } + is Message.ChallengeWidgetMessage -> { + val (challengeWidgetState, challengeWidgetActions) = + reduceChallengeWidgetMessage(state.challengeWidgetState, message.message) + state.copy(challengeWidgetState = challengeWidgetState) to challengeWidgetActions + } } ?: (state to emptySet()) private fun initialize(state: State, forceUpdate: Boolean): HomeReducerResult { @@ -219,7 +228,7 @@ class HomeReducer( forceUpdate && (state.homeState is HomeState.Content || state.homeState is HomeState.NetworkError) val (homeState, homeActions) = if (shouldReloadHome) { - HomeState.Loading to setOf(Action.FetchHomeScreenData) + HomeState.Loading to setOf(InternalAction.FetchHomeScreenData) } else { state.homeState to emptySet() } @@ -230,10 +239,17 @@ class HomeReducer( GamificationToolbarFeature.InternalMessage.Initialize(forceUpdate) ) + val (challengeWidgetState, challengeWidgetActions) = + reduceChallengeWidgetMessage( + state.challengeWidgetState, + ChallengeWidgetFeature.InternalMessage.Initialize(forceUpdate) + ) + return state.copy( homeState = homeState, - toolbarState = toolbarState - ) to homeActions + toolbarActions + toolbarState = toolbarState, + challengeWidgetState = challengeWidgetState + ) to homeActions + toolbarActions + challengeWidgetActions } private fun handlePullToRefresh(state: State): HomeReducerResult { @@ -241,22 +257,30 @@ class HomeReducer( state.homeState is HomeState.Content && !state.homeState.isRefreshing ) { state.homeState.copy(isRefreshing = true) to setOf( - Action.FetchHomeScreenData, - Action.LogAnalyticEvent(HomeClickedPullToRefreshHyperskillAnalyticEvent()) + InternalAction.FetchHomeScreenData, + InternalAction.LogAnalyticEvent(HomeClickedPullToRefreshHyperskillAnalyticEvent()) ) } else { state.homeState to emptySet() } - val (toolbarState, toolbarActions) = reduceGamificationToolbarMessage( - state.toolbarState, - GamificationToolbarFeature.InternalMessage.PullToRefresh - ) + val (toolbarState, toolbarActions) = + reduceGamificationToolbarMessage( + state.toolbarState, + GamificationToolbarFeature.InternalMessage.PullToRefresh + ) + + val (challengeWidgetState, challengeWidgetActions) = + reduceChallengeWidgetMessage( + state.challengeWidgetState, + ChallengeWidgetFeature.InternalMessage.PullToRefresh + ) return state.copy( homeState = homeState, - toolbarState = toolbarState - ) to homeActions + toolbarActions + toolbarState = toolbarState, + challengeWidgetState = challengeWidgetState + ) to homeActions + toolbarActions + challengeWidgetActions } private fun reduceGamificationToolbarMessage( @@ -270,11 +294,30 @@ class HomeReducer( if (it is GamificationToolbarFeature.Action.ViewAction) { Action.ViewAction.GamificationToolbarViewAction(it) } else { - Action.GamificationToolbarAction(it) + InternalAction.GamificationToolbarAction(it) } } .toSet() return gamificationToolbarState to actions } + + private fun reduceChallengeWidgetMessage( + state: ChallengeWidgetFeature.State, + message: ChallengeWidgetFeature.Message + ): Pair> { + val (challengeWidgetState, challengeWidgetActions) = challengeWidgetReducer.reduce(state, message) + + val actions = challengeWidgetActions + .map { + if (it is ChallengeWidgetFeature.Action.ViewAction) { + Action.ViewAction.ChallengeWidgetViewAction(it) + } else { + InternalAction.ChallengeWidgetAction(it) + } + } + .toSet() + + return challengeWidgetState to actions + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/view/mapper/HomeViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/view/mapper/HomeViewStateMapper.kt new file mode 100644 index 0000000000..b6aad19abe --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/home/view/mapper/HomeViewStateMapper.kt @@ -0,0 +1,16 @@ +package org.hyperskill.app.home.view.mapper + +import org.hyperskill.app.challenges.widget.view.mapper.ChallengeWidgetViewStateMapper +import org.hyperskill.app.home.presentation.HomeFeature + +internal class HomeViewStateMapper( + private val challengeWidgetViewStateMapper: ChallengeWidgetViewStateMapper +) { + fun map(state: HomeFeature.State): HomeFeature.ViewState = + HomeFeature.ViewState( + homeState = state.homeState, + toolbarState = state.toolbarState, + challengeWidgetViewState = challengeWidgetViewStateMapper.map(state.challengeWidgetState), + isRefreshing = state.isRefreshing + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/data/repository/LearningActivitiesRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/data/repository/LearningActivitiesRepositoryImpl.kt index 6b0c32e142..8a315dce87 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/data/repository/LearningActivitiesRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/data/repository/LearningActivitiesRepositoryImpl.kt @@ -6,9 +6,12 @@ import org.hyperskill.app.learning_activities.domain.model.LearningActivityState import org.hyperskill.app.learning_activities.domain.model.LearningActivityType import org.hyperskill.app.learning_activities.domain.repository.LearningActivitiesRepository import org.hyperskill.app.learning_activities.remote.model.LearningActivitiesRequest +import org.hyperskill.app.learning_activities.remote.model.LearningActivitiesWithSectionsRequest +import org.hyperskill.app.learning_activities.remote.model.LearningActivitiesWithSectionsResponse import org.hyperskill.app.learning_activities.remote.model.NextLearningActivityRequest +import org.hyperskill.app.study_plan.domain.model.StudyPlanSectionType -class LearningActivitiesRepositoryImpl( +internal class LearningActivitiesRepositoryImpl( private val learningActivitiesRemoteDataSource: LearningActivitiesRemoteDataSource ) : LearningActivitiesRepository { override suspend fun getUncompletedTopicsLearningActivities( @@ -46,4 +49,17 @@ class LearningActivitiesRepositoryImpl( types = types ) ) + + override suspend fun getLearningActivitiesWithSections( + studyPlanSectionTypes: Set, + learningActivityTypes: Set, + learningActivityStates: Set + ): Result = + learningActivitiesRemoteDataSource.getLearningActivitiesWithSections( + LearningActivitiesWithSectionsRequest( + studyPlanSectionTypes = studyPlanSectionTypes, + learningActivityTypes = learningActivityTypes, + learningActivityStates = learningActivityStates + ) + ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/data/repository/NextLearningActivityStateRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/data/repository/NextLearningActivityStateRepositoryImpl.kt index 8e76708c57..f6e18cc87e 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/data/repository/NextLearningActivityStateRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/data/repository/NextLearningActivityStateRepositoryImpl.kt @@ -6,7 +6,7 @@ import org.hyperskill.app.learning_activities.domain.model.LearningActivity import org.hyperskill.app.learning_activities.domain.repository.NextLearningActivityStateRepository import org.hyperskill.app.learning_activities.remote.model.NextLearningActivityRequest -class NextLearningActivityStateRepositoryImpl( +internal class NextLearningActivityStateRepositoryImpl( private val learningActivitiesRemoteDataSource: LearningActivitiesRemoteDataSource ) : NextLearningActivityStateRepository, BaseStateRepository() { override suspend fun loadState(): Result = diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/data/source/LearningActivitiesRemoteDataSource.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/data/source/LearningActivitiesRemoteDataSource.kt index 95643f5b63..771b12110f 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/data/source/LearningActivitiesRemoteDataSource.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/data/source/LearningActivitiesRemoteDataSource.kt @@ -2,6 +2,8 @@ package org.hyperskill.app.learning_activities.data.source import org.hyperskill.app.learning_activities.domain.model.LearningActivity import org.hyperskill.app.learning_activities.remote.model.LearningActivitiesRequest +import org.hyperskill.app.learning_activities.remote.model.LearningActivitiesWithSectionsRequest +import org.hyperskill.app.learning_activities.remote.model.LearningActivitiesWithSectionsResponse import org.hyperskill.app.learning_activities.remote.model.NextLearningActivityRequest interface LearningActivitiesRemoteDataSource { @@ -12,4 +14,8 @@ interface LearningActivitiesRemoteDataSource { suspend fun getLearningActivities( request: LearningActivitiesRequest ): Result> + + suspend fun getLearningActivitiesWithSections( + request: LearningActivitiesWithSectionsRequest + ): Result } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/domain/interactor/LearningActivitiesInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/domain/interactor/LearningActivitiesInteractor.kt deleted file mode 100644 index 30f6cd7ff7..0000000000 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/domain/interactor/LearningActivitiesInteractor.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.hyperskill.app.learning_activities.domain.interactor - -import org.hyperskill.app.learning_activities.domain.model.LearningActivity -import org.hyperskill.app.learning_activities.domain.repository.LearningActivitiesRepository - -class LearningActivitiesInteractor( - private val learningActivitiesRepository: LearningActivitiesRepository -) { - suspend fun getUncompletedTopicsLearningActivities( - studyPlanId: Long, - pageSize: Int = 10, - page: Int = 1 - ): Result> = - learningActivitiesRepository.getUncompletedTopicsLearningActivities(studyPlanId, pageSize, page) -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/domain/repository/LearningActivitiesRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/domain/repository/LearningActivitiesRepository.kt index d190fbbf0a..8562e85e26 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/domain/repository/LearningActivitiesRepository.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/domain/repository/LearningActivitiesRepository.kt @@ -3,6 +3,8 @@ package org.hyperskill.app.learning_activities.domain.repository import org.hyperskill.app.learning_activities.domain.model.LearningActivity import org.hyperskill.app.learning_activities.domain.model.LearningActivityState import org.hyperskill.app.learning_activities.domain.model.LearningActivityType +import org.hyperskill.app.learning_activities.remote.model.LearningActivitiesWithSectionsResponse +import org.hyperskill.app.study_plan.domain.model.StudyPlanSectionType interface LearningActivitiesRepository { suspend fun getUncompletedTopicsLearningActivities( @@ -20,4 +22,10 @@ interface LearningActivitiesRepository { suspend fun getNextLearningActivity( types: Set ): Result + + suspend fun getLearningActivitiesWithSections( + studyPlanSectionTypes: Set, + learningActivityTypes: Set, + learningActivityStates: Set + ): Result } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/injection/LearningActivitiesDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/injection/LearningActivitiesDataComponent.kt index f869cb694a..72d9e2e2b4 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/injection/LearningActivitiesDataComponent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/injection/LearningActivitiesDataComponent.kt @@ -1,9 +1,7 @@ package org.hyperskill.app.learning_activities.injection -import org.hyperskill.app.learning_activities.domain.interactor.LearningActivitiesInteractor import org.hyperskill.app.learning_activities.domain.repository.LearningActivitiesRepository interface LearningActivitiesDataComponent { val learningActivitiesRepository: LearningActivitiesRepository - val learningActivitiesInteractor: LearningActivitiesInteractor } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/injection/LearningActivitiesDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/injection/LearningActivitiesDataComponentImpl.kt index ffd6cda8da..b49c451d49 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/injection/LearningActivitiesDataComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/injection/LearningActivitiesDataComponentImpl.kt @@ -3,11 +3,10 @@ package org.hyperskill.app.learning_activities.injection import org.hyperskill.app.core.injection.AppGraph import org.hyperskill.app.learning_activities.data.repository.LearningActivitiesRepositoryImpl import org.hyperskill.app.learning_activities.data.source.LearningActivitiesRemoteDataSource -import org.hyperskill.app.learning_activities.domain.interactor.LearningActivitiesInteractor import org.hyperskill.app.learning_activities.domain.repository.LearningActivitiesRepository import org.hyperskill.app.learning_activities.remote.LearningActivitiesRemoteDataSourceImpl -class LearningActivitiesDataComponentImpl( +internal class LearningActivitiesDataComponentImpl( appGraph: AppGraph ) : LearningActivitiesDataComponent { private val learningActivitiesRemoteDataSource: LearningActivitiesRemoteDataSource = @@ -15,7 +14,4 @@ class LearningActivitiesDataComponentImpl( override val learningActivitiesRepository: LearningActivitiesRepository get() = LearningActivitiesRepositoryImpl(learningActivitiesRemoteDataSource) - - override val learningActivitiesInteractor: LearningActivitiesInteractor - get() = LearningActivitiesInteractor(learningActivitiesRepository) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/LearningActivitiesRemoteDataSourceImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/LearningActivitiesRemoteDataSourceImpl.kt index 9172b67c5a..8b316fe418 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/LearningActivitiesRemoteDataSourceImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/LearningActivitiesRemoteDataSourceImpl.kt @@ -13,9 +13,11 @@ import org.hyperskill.app.learning_activities.data.source.LearningActivitiesRemo import org.hyperskill.app.learning_activities.domain.model.LearningActivity import org.hyperskill.app.learning_activities.remote.model.LearningActivitiesRequest import org.hyperskill.app.learning_activities.remote.model.LearningActivitiesResponse +import org.hyperskill.app.learning_activities.remote.model.LearningActivitiesWithSectionsRequest +import org.hyperskill.app.learning_activities.remote.model.LearningActivitiesWithSectionsResponse import org.hyperskill.app.learning_activities.remote.model.NextLearningActivityRequest -class LearningActivitiesRemoteDataSourceImpl( +internal class LearningActivitiesRemoteDataSourceImpl( private val httpClient: HttpClient ) : LearningActivitiesRemoteDataSource { override suspend fun getNextLearningActivity(request: NextLearningActivityRequest): Result = @@ -59,4 +61,18 @@ class LearningActivitiesRemoteDataSourceImpl( } } .body().learningActivities + + override suspend fun getLearningActivitiesWithSections( + request: LearningActivitiesWithSectionsRequest + ): Result = + kotlin.runCatching { + httpClient + .get("/api/learning-activities/with-sections") { + contentType(ContentType.Application.Json) + request.parameters.forEach { (key, value) -> + parameter(key, value) + } + } + .body() + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesRequest.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesRequest.kt index f04e9b6665..153a705671 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesRequest.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesRequest.kt @@ -27,9 +27,9 @@ class LearningActivitiesRequest( STUDY_PLAN to studyPlanId, PAGE_SIZE to pageSize, PAGE to page, - LearningActivitiesRequestParams.STATE to + LearningActivitiesRequestParams.PARAM_STATE to states.joinToString(",") { it.value.toString() }.ifEmpty { null }, - LearningActivitiesRequestParams.TYPES to + LearningActivitiesRequestParams.PARAM_TYPES to types.joinToString(",") { it.value.toString() }.ifEmpty { null }, IDS to ids.joinToString(separator = ",").ifEmpty { null } ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesRequestParams.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesRequestParams.kt index 684f33e284..0f23ac5495 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesRequestParams.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesRequestParams.kt @@ -1,6 +1,6 @@ package org.hyperskill.app.learning_activities.remote.model internal object LearningActivitiesRequestParams { - const val STATE = "state" - const val TYPES = "types" + const val PARAM_STATE = "state" + const val PARAM_TYPES = "types" } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesWithSectionsRequest.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesWithSectionsRequest.kt new file mode 100644 index 0000000000..4d599cd484 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesWithSectionsRequest.kt @@ -0,0 +1,26 @@ +package org.hyperskill.app.learning_activities.remote.model + +import org.hyperskill.app.learning_activities.domain.model.LearningActivityState +import org.hyperskill.app.learning_activities.domain.model.LearningActivityType +import org.hyperskill.app.study_plan.domain.model.StudyPlanSectionType +import ru.nobird.app.core.model.mapOfNotNull + +class LearningActivitiesWithSectionsRequest( + studyPlanSectionTypes: Set, + learningActivityTypes: Set, + learningActivityStates: Set +) { + companion object { + private const val PARAM_SECTION_TYPES = "section_types" + } + + val parameters: Map = + mapOfNotNull( + PARAM_SECTION_TYPES to + studyPlanSectionTypes.joinToString(",") { it.value }.ifEmpty { null }, + LearningActivitiesRequestParams.PARAM_TYPES to + learningActivityTypes.joinToString(",") { it.value.toString() }.ifEmpty { null }, + LearningActivitiesRequestParams.PARAM_STATE to + learningActivityStates.joinToString(",") { it.value.toString() }.ifEmpty { null } + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesWithSectionsResponse.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesWithSectionsResponse.kt new file mode 100644 index 0000000000..afeee49adb --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/LearningActivitiesWithSectionsResponse.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.learning_activities.remote.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.hyperskill.app.core.remote.Meta +import org.hyperskill.app.core.remote.MetaResponse +import org.hyperskill.app.learning_activities.domain.model.LearningActivity +import org.hyperskill.app.study_plan.domain.model.StudyPlanSection + +@Serializable +class LearningActivitiesWithSectionsResponse( + @SerialName("meta") + override val meta: Meta, + + @SerialName("learning-activities") + val learningActivities: List, + + @SerialName("study-plan-sections") + val studyPlanSections: List +) : MetaResponse \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/NextLearningActivityRequest.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/NextLearningActivityRequest.kt index cf0e0c323b..fd7aae79ad 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/NextLearningActivityRequest.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/learning_activities/remote/model/NextLearningActivityRequest.kt @@ -9,7 +9,7 @@ class NextLearningActivityRequest( ) { val parameters: Map = mapOf( - LearningActivitiesRequestParams.STATE to state.value.toString(), - LearningActivitiesRequestParams.TYPES to types.joinToString(",") { it.value.toString() } + LearningActivitiesRequestParams.PARAM_STATE to state.value.toString(), + LearningActivitiesRequestParams.PARAM_TYPES to types.joinToString(",") { it.value.toString() } ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/magic_links/injection/MagicLinksDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/magic_links/injection/MagicLinksDataComponent.kt index a55251ed41..51fbb62213 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/magic_links/injection/MagicLinksDataComponent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/magic_links/injection/MagicLinksDataComponent.kt @@ -1,7 +1,9 @@ package org.hyperskill.app.magic_links.injection +import org.hyperskill.app.magic_links.domain.interactor.MagicLinksInteractor import org.hyperskill.app.magic_links.domain.interactor.UrlPathProcessor interface MagicLinksDataComponent { val urlPathProcessor: UrlPathProcessor + val magicLinksInteractor: MagicLinksInteractor } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/magic_links/injection/MagicLinksDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/magic_links/injection/MagicLinksDataComponentImpl.kt index 6305e563b8..3b17f111cb 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/magic_links/injection/MagicLinksDataComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/magic_links/injection/MagicLinksDataComponentImpl.kt @@ -17,8 +17,8 @@ class MagicLinksDataComponentImpl(appGraph: AppGraph) : MagicLinksDataComponent magicLinksRemoteDataSource ) - private val magicLinksInteractor: MagicLinksInteractor = - MagicLinksInteractor(magicLinksRepository) + override val magicLinksInteractor: MagicLinksInteractor + get() = MagicLinksInteractor(magicLinksRepository) private val urlBuilder: HyperskillUrlBuilder = HyperskillUrlBuilder( appGraph.networkComponent.endpointConfigInfo diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/view/mapper/ProblemsLimitViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/view/mapper/ProblemsLimitViewStateMapper.kt index 46803a187e..771dcc1312 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/view/mapper/ProblemsLimitViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/view/mapper/ProblemsLimitViewStateMapper.kt @@ -2,7 +2,7 @@ package org.hyperskill.app.problems_limit.view.mapper import org.hyperskill.app.SharedResources import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature class ProblemsLimitViewStateMapper( diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/model/Profile.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/model/Profile.kt index fd98b32f48..7ab71d9901 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/model/Profile.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/model/Profile.kt @@ -44,6 +44,8 @@ data class Profile( val trackId: Long?, @SerialName("track_title") val trackTitle: String?, + @SerialName("project") + val projectId: Long?, @SerialName("is_beta") val isBeta: Boolean = false, @SerialName("timezone") diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/progress_screen/view/ProgressScreenViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/progress_screen/view/ProgressScreenViewStateMapper.kt index 0d715b2854..f7fa49a563 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/progress_screen/view/ProgressScreenViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/progress_screen/view/ProgressScreenViewStateMapper.kt @@ -2,7 +2,7 @@ package org.hyperskill.app.progress_screen.view import org.hyperskill.app.SharedResources import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.progress_screen.presentation.ProgressScreenFeature import org.hyperskill.app.progress_screen.view.ProgressScreenViewState.TrackProgressViewState.Content.AppliedTopicsState import org.hyperskill.app.subscriptions.domain.model.isFreemium diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/injection/ProjectSelectionDetailsFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/injection/ProjectSelectionDetailsFeatureBuilder.kt index 3c21daf29c..d3e0264322 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/injection/ProjectSelectionDetailsFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/injection/ProjectSelectionDetailsFeatureBuilder.kt @@ -7,7 +7,7 @@ import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.core.presentation.transformState import org.hyperskill.app.core.view.mapper.NumbersFormatter import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.logging.presentation.wrapWithLogger import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.profile.domain.repository.ProfileRepository diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/view/ProjectSelectionDetailsViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/view/ProjectSelectionDetailsViewStateMapper.kt index e9026b09b9..c0e45e7672 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/view/ProjectSelectionDetailsViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/view/ProjectSelectionDetailsViewStateMapper.kt @@ -3,7 +3,7 @@ package org.hyperskill.app.project_selection.details.view import org.hyperskill.app.SharedResources import org.hyperskill.app.core.view.mapper.NumbersFormatter import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.progresses.domain.model.averageRating import org.hyperskill.app.project_selection.details.presentation.ProjectSelectionDetailsFeature import org.hyperskill.app.projects.domain.model.Project diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/list/injection/ProjectSelectionListComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/list/injection/ProjectSelectionListComponentImpl.kt index de8b2fd13e..43b786a957 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/list/injection/ProjectSelectionListComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/list/injection/ProjectSelectionListComponentImpl.kt @@ -7,7 +7,7 @@ import org.hyperskill.app.project_selection.list.presentation.ProjectSelectionLi import org.hyperskill.app.project_selection.list.view.mapper.ProjectSelectionListViewStateMapper import ru.nobird.app.presentation.redux.feature.Feature -class ProjectSelectionListComponentImpl(private val appGraph: AppGraph) : ProjectSelectionListComponent { +internal class ProjectSelectionListComponentImpl(private val appGraph: AppGraph) : ProjectSelectionListComponent { override fun projectSelectionListFeature( params: ProjectSelectionListParams @@ -15,7 +15,7 @@ class ProjectSelectionListComponentImpl(private val appGraph: AppGraph) : Projec ProjectSelectionListFeatureBuilder.build( params = params, trackRepository = appGraph.buildTrackDataComponent().trackRepository, - currentStudyPlanStateRepository = appGraph.buildStudyPlanDataComponent().currentStudyPlanStateRepository, + currentStudyPlanStateRepository = appGraph.stateRepositoriesComponent.currentStudyPlanStateRepository, projectsRepository = appGraph.buildProjectsDataComponent().projectsRepository, progressesRepository = appGraph.buildProgressesDataComponent().progressesRepository, currentProfileStateRepository = appGraph.profileDataComponent.currentProfileStateRepository, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/list/view/mapper/ProjectSelectionListViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/list/view/mapper/ProjectSelectionListViewStateMapper.kt index e404babc29..6ae630e75a 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/list/view/mapper/ProjectSelectionListViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/list/view/mapper/ProjectSelectionListViewStateMapper.kt @@ -3,7 +3,7 @@ package org.hyperskill.app.project_selection.list.view.mapper import org.hyperskill.app.SharedResources import org.hyperskill.app.core.view.mapper.NumbersFormatter import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.progresses.domain.model.averageRating import org.hyperskill.app.project_selection.list.presentation.ProjectSelectionListFeature import org.hyperskill.app.project_selection.list.presentation.bestRatedProjectId diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt index 88016584ce..d26613d7b0 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt @@ -203,18 +203,6 @@ object HyperskillSentryTransactionBuilder { /** * StudyPlanWidgetFeature */ - fun buildStudyPlanWidgetFetchCurrentStudyPlan(): HyperskillSentryTransaction = - HyperskillSentryTransaction( - name = "study-plan-widget-feature-fetch-current-study-plan", - operation = HyperskillSentryTransactionOperation.API_LOAD - ) - - fun buildStudyPlanWidgetFetchStudyPlanSections(): HyperskillSentryTransaction = - HyperskillSentryTransaction( - name = "study-plan-widget-feature-fetch-study-plan-sections", - operation = HyperskillSentryTransactionOperation.API_LOAD - ) - fun buildStudyPlanWidgetFetchLearningActivities(isCurrentSection: Boolean): HyperskillSentryTransaction = HyperskillSentryTransaction( name = "study-plan-widget-feature-fetch-learning-activities", @@ -224,9 +212,9 @@ object HyperskillSentryTransactionBuilder { ) ) - fun buildStudyPlanWidgetFetchTrack(): HyperskillSentryTransaction = + fun buildStudyPlanWidgetFetchLearningActivitiesWithSections(): HyperskillSentryTransaction = HyperskillSentryTransaction( - name = "study-plan-widget-feature-fetch-track", + name = "study-plan-widget-feature-fetch-learning-activities-with-sections", operation = HyperskillSentryTransactionOperation.API_LOAD ) @@ -301,4 +289,13 @@ object HyperskillSentryTransactionBuilder { name = "first-problem-onboarding-feature-fetch-next-learning-activity", operation = HyperskillSentryTransactionOperation.API_LOAD ) + + /** + * ChallengeWidgetFeature + */ + fun buildChallengeWidgetFeatureFetchChallenges(): HyperskillSentryTransaction = + HyperskillSentryTransaction( + name = "challenge-widget-feature-fetch-challenges", + operation = HyperskillSentryTransactionOperation.API_LOAD + ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/data/flow/DailyStepCompletedFlowImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/data/flow/DailyStepCompletedFlowImpl.kt new file mode 100644 index 0000000000..f8e82e4242 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/data/flow/DailyStepCompletedFlowImpl.kt @@ -0,0 +1,16 @@ +package org.hyperskill.app.step_completion.data.flow + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import org.hyperskill.app.step_completion.domain.flow.DailyStepCompletedFlow + +internal class DailyStepCompletedFlowImpl : DailyStepCompletedFlow { + private val dailyStepCompletedMutableSharedFlow = MutableSharedFlow() + + override fun observe(): Flow = + dailyStepCompletedMutableSharedFlow + + override suspend fun notifyDataChanged(data: Long) { + dailyStepCompletedMutableSharedFlow.emit(data) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/data/flow/TopicCompletedFlowImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/data/flow/TopicCompletedFlowImpl.kt index 2028d4032a..c0f3915511 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/data/flow/TopicCompletedFlowImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/data/flow/TopicCompletedFlowImpl.kt @@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import org.hyperskill.app.step_completion.domain.flow.TopicCompletedFlow -class TopicCompletedFlowImpl : TopicCompletedFlow { +internal class TopicCompletedFlowImpl : TopicCompletedFlow { private val topicCompletedMutableSharedFlow = MutableSharedFlow() override fun observe(): Flow = diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/domain/flow/DailyStepCompletedFlow.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/domain/flow/DailyStepCompletedFlow.kt new file mode 100644 index 0000000000..937bca0a67 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/domain/flow/DailyStepCompletedFlow.kt @@ -0,0 +1,5 @@ +package org.hyperskill.app.step_completion.domain.flow + +import org.hyperskill.app.core.domain.flow.SharedDataFlow + +interface DailyStepCompletedFlow : SharedDataFlow \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionComponentImpl.kt index 3c459d139b..effa484b9e 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionComponentImpl.kt @@ -28,6 +28,7 @@ internal class StepCompletionComponentImpl( appGraph.stateRepositoriesComponent.nextLearningActivityStateRepository, appGraph.profileDataComponent.currentProfileStateRepository, appGraph.stateRepositoriesComponent.currentGamificationToolbarDataStateRepository, + appGraph.stepCompletionFlowDataComponent.dailyStepCompletedFlow, appGraph.stepCompletionFlowDataComponent.topicCompletedFlow, appGraph.progressesFlowDataComponent.topicProgressFlow, appGraph.buildNotificationComponent().notificationInteractor diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponent.kt index b46d3ec6dc..1cf8a7293c 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponent.kt @@ -1,7 +1,9 @@ package org.hyperskill.app.step_completion.injection +import org.hyperskill.app.step_completion.domain.flow.DailyStepCompletedFlow import org.hyperskill.app.step_completion.domain.flow.TopicCompletedFlow interface StepCompletionFlowDataComponent { val topicCompletedFlow: TopicCompletedFlow + val dailyStepCompletedFlow: DailyStepCompletedFlow } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponentImpl.kt index 04da9680a3..f1d0b563f2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponentImpl.kt @@ -1,8 +1,14 @@ package org.hyperskill.app.step_completion.injection +import org.hyperskill.app.step_completion.data.flow.DailyStepCompletedFlowImpl import org.hyperskill.app.step_completion.data.flow.TopicCompletedFlowImpl +import org.hyperskill.app.step_completion.domain.flow.DailyStepCompletedFlow import org.hyperskill.app.step_completion.domain.flow.TopicCompletedFlow -class StepCompletionFlowDataComponentImpl : StepCompletionFlowDataComponent { - override val topicCompletedFlow: TopicCompletedFlow = TopicCompletedFlowImpl() +internal class StepCompletionFlowDataComponentImpl : StepCompletionFlowDataComponent { + override val topicCompletedFlow: TopicCompletedFlow = + TopicCompletedFlowImpl() + + override val dailyStepCompletedFlow: DailyStepCompletedFlow = + DailyStepCompletedFlowImpl() } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionActionDispatcher.kt index 309f19268d..cfb464023a 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionActionDispatcher.kt @@ -24,6 +24,7 @@ import org.hyperskill.app.step.domain.model.Step import org.hyperskill.app.step.domain.model.StepRoute import org.hyperskill.app.step_completion.domain.analytic.StepCompletionStepSolvedAppsFlyerAnalyticEvent import org.hyperskill.app.step_completion.domain.analytic.StepCompletionTopicCompletedAppsFlyerAnalyticEvent +import org.hyperskill.app.step_completion.domain.flow.DailyStepCompletedFlow import org.hyperskill.app.step_completion.domain.flow.TopicCompletedFlow import org.hyperskill.app.step_completion.presentation.StepCompletionFeature.Action import org.hyperskill.app.step_completion.presentation.StepCompletionFeature.Message @@ -46,6 +47,7 @@ class StepCompletionActionDispatcher( private val nextLearningActivityStateRepository: NextLearningActivityStateRepository, private val currentProfileStateRepository: CurrentProfileStateRepository, private val currentGamificationToolbarDataStateRepository: CurrentGamificationToolbarDataStateRepository, + private val dailyStepCompletedFlow: DailyStepCompletedFlow, private val topicCompletedFlow: TopicCompletedFlow, private val topicProgressFlow: TopicProgressFlow, private val notificationInteractor: NotificationInteractor @@ -212,6 +214,10 @@ class StepCompletionActionDispatcher( ) ) + if (cachedProfile.dailyStep == stepId) { + dailyStepCompletedFlow.notifyDataChanged(stepId) + } + val currentGamificationToolbarData = currentGamificationToolbarDataStateRepository .getState(forceUpdate = false) .getOrNull() diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/view/mapper/StepQuizStatsTextMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/view/mapper/StepQuizStatsTextMapper.kt index 338c9e4207..0bf6233f3f 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/view/mapper/StepQuizStatsTextMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/view/mapper/StepQuizStatsTextMapper.kt @@ -2,7 +2,7 @@ package org.hyperskill.app.step_quiz.view.mapper import org.hyperskill.app.SharedResources import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter class StepQuizStatsTextMapper( private val resourceProvider: ResourceProvider, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/domain/interactor/StudyPlanInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/domain/interactor/StudyPlanInteractor.kt deleted file mode 100644 index fe40161502..0000000000 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/domain/interactor/StudyPlanInteractor.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.hyperskill.app.study_plan.domain.interactor - -import org.hyperskill.app.learning_activities.domain.model.LearningActivity -import org.hyperskill.app.learning_activities.domain.model.LearningActivityState -import org.hyperskill.app.learning_activities.domain.model.LearningActivityType -import org.hyperskill.app.learning_activities.domain.repository.LearningActivitiesRepository -import org.hyperskill.app.study_plan.domain.model.StudyPlan -import org.hyperskill.app.study_plan.domain.model.StudyPlanSection -import org.hyperskill.app.study_plan.domain.repository.CurrentStudyPlanStateRepository -import org.hyperskill.app.study_plan.domain.repository.StudyPlanSectionsRepository - -class StudyPlanInteractor( - private val currentStudyPlanStateRepository: CurrentStudyPlanStateRepository, - private val studyPlanSectionsRepository: StudyPlanSectionsRepository, - private val learningActivitiesRepository: LearningActivitiesRepository -) { - suspend fun getCurrentStudyPlan(forceLoadFromRemote: Boolean): Result = - currentStudyPlanStateRepository.getState(forceLoadFromRemote) - - suspend fun getStudyPlanSections(sectionsIds: List): Result> = - studyPlanSectionsRepository.getStudyPlanSections(sectionsIds) - - suspend fun getLearningActivities( - activitiesIds: List, - types: Set, - states: Set - ): Result> = - learningActivitiesRepository.getLearningActivities(activitiesIds, types, states) -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/injection/StudyPlanDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/injection/StudyPlanDataComponent.kt deleted file mode 100644 index 96e6e6f990..0000000000 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/injection/StudyPlanDataComponent.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.hyperskill.app.study_plan.injection - -import org.hyperskill.app.learning_activities.domain.repository.LearningActivitiesRepository -import org.hyperskill.app.study_plan.domain.interactor.StudyPlanInteractor -import org.hyperskill.app.study_plan.domain.repository.CurrentStudyPlanStateRepository -import org.hyperskill.app.study_plan.domain.repository.StudyPlanSectionsRepository - -interface StudyPlanDataComponent { - val currentStudyPlanStateRepository: CurrentStudyPlanStateRepository - val studyPlanSectionsRepository: StudyPlanSectionsRepository - val learningActivitiesRepository: LearningActivitiesRepository - val studyPlanInteractor: StudyPlanInteractor -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/injection/StudyPlanDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/injection/StudyPlanDataComponentImpl.kt deleted file mode 100644 index 23f9c07e3c..0000000000 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/injection/StudyPlanDataComponentImpl.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.hyperskill.app.study_plan.injection - -import org.hyperskill.app.core.injection.AppGraph -import org.hyperskill.app.learning_activities.data.repository.LearningActivitiesRepositoryImpl -import org.hyperskill.app.learning_activities.domain.repository.LearningActivitiesRepository -import org.hyperskill.app.learning_activities.remote.LearningActivitiesRemoteDataSourceImpl -import org.hyperskill.app.study_plan.data.repository.StudyPlanSectionsRepositoryImpl -import org.hyperskill.app.study_plan.domain.interactor.StudyPlanInteractor -import org.hyperskill.app.study_plan.domain.repository.CurrentStudyPlanStateRepository -import org.hyperskill.app.study_plan.domain.repository.StudyPlanSectionsRepository -import org.hyperskill.app.study_plan.remote.StudyPlanSectionsRemoteDataSourceImpl - -class StudyPlanDataComponentImpl(private val appGraph: AppGraph) : StudyPlanDataComponent { - - private val studyPlanSectionsRemoteDataSource = StudyPlanSectionsRemoteDataSourceImpl( - appGraph.networkComponent.authorizedHttpClient - ) - - private val learningActivitiesRemoteDataSource = LearningActivitiesRemoteDataSourceImpl( - appGraph.networkComponent.authorizedHttpClient - ) - - override val currentStudyPlanStateRepository: CurrentStudyPlanStateRepository - get() = appGraph.stateRepositoriesComponent.currentStudyPlanStateRepository - - override val studyPlanSectionsRepository: StudyPlanSectionsRepository - get() = StudyPlanSectionsRepositoryImpl(studyPlanSectionsRemoteDataSource) - - override val learningActivitiesRepository: LearningActivitiesRepository - get() = LearningActivitiesRepositoryImpl(learningActivitiesRemoteDataSource) - - override val studyPlanInteractor: StudyPlanInteractor - get() = StudyPlanInteractor( - currentStudyPlanStateRepository, - studyPlanSectionsRepository, - learningActivitiesRepository - ) -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenReducer.kt index 6b58ab9ec5..1480fa0e6d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenReducer.kt @@ -40,7 +40,7 @@ internal class StudyPlanScreenReducer( state.copy( studyPlanWidgetState = widgetState, - toolbarState = toolbarState, + toolbarState = toolbarState ) to widgetActions + toolbarActions + setOf( StudyPlanScreenFeature.InternalAction.LogAnalyticEvent( StudyPlanClickedPullToRefreshHyperskillAnalyticEvent() @@ -50,12 +50,9 @@ internal class StudyPlanScreenReducer( is StudyPlanScreenFeature.Message.ScreenBecomesActive -> { val (widgetState, widgetActions) = reduceStudyPlanWidgetMessage( state.studyPlanWidgetState, - StudyPlanWidgetFeature.Message.ReloadContentInBackground + StudyPlanWidgetFeature.InternalMessage.ReloadContentInBackground ) - - state.copy( - studyPlanWidgetState = widgetState, - ) to widgetActions + state.copy(studyPlanWidgetState = widgetState) to widgetActions } is StudyPlanScreenFeature.Message.GamificationToolbarMessage -> { val (toolbarState, toolbarActions) = @@ -98,7 +95,7 @@ internal class StudyPlanScreenReducer( val (studyPlanState, studyPlanActions) = reduceStudyPlanWidgetMessage( state.studyPlanWidgetState, - StudyPlanWidgetFeature.Message.Initialize(forceUpdate = retryContentLoadingClicked) + StudyPlanWidgetFeature.InternalMessage.Initialize(forceUpdate = retryContentLoadingClicked) ) val analyticActions = if (retryContentLoadingClicked) { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/view/StudyPlanScreenViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/view/StudyPlanScreenViewStateMapper.kt index 6cc3b7f43e..2c8e8fbd2b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/view/StudyPlanScreenViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/view/StudyPlanScreenViewStateMapper.kt @@ -13,15 +13,20 @@ internal class StudyPlanScreenViewStateMapper( ) { fun map(state: StudyPlanScreenFeature.State): StudyPlanScreenFeature.ViewState = StudyPlanScreenFeature.ViewState( - trackTitle = state.studyPlanWidgetState.track?.title?.let { title -> - resourceProvider.getString( - SharedResources.strings.study_plan_track_title_template, - title - ) - }, + trackTitle = getTrackTitle(state), toolbarState = state.toolbarState, problemsLimitViewState = problemsLimitViewStateMapper.mapState(state.problemsLimitState), studyPlanWidgetViewState = studyPlanWidgetViewStateMapper.map(state.studyPlanWidgetState), isRefreshing = state.isRefreshing ) + + private fun getTrackTitle(state: StudyPlanScreenFeature.State): String? = + state.studyPlanWidgetState.profile?.trackTitle + ?.takeIf { it.isNotBlank() } + ?.let { title -> + resourceProvider.getString( + SharedResources.strings.study_plan_track_title_template, + title + ) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/injection/StudyPlanWidgetComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/injection/StudyPlanWidgetComponentImpl.kt index 0c2b627a5e..4de39b8923 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/injection/StudyPlanWidgetComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/injection/StudyPlanWidgetComponentImpl.kt @@ -10,11 +10,11 @@ class StudyPlanWidgetComponentImpl(private val appGraph: AppGraph) : StudyPlanWi override val studyPlanWidgetDispatcher: StudyPlanWidgetActionDispatcher get() = StudyPlanWidgetActionDispatcher( config = ActionDispatcherOptions(), - studyPlanInteractor = appGraph.buildStudyPlanDataComponent().studyPlanInteractor, - trackInteractor = appGraph.buildTrackDataComponent().trackInteractor, + learningActivitiesRepository = appGraph.buildLearningActivitiesDataComponent().learningActivitiesRepository, nextLearningActivityStateRepository = appGraph .stateRepositoriesComponent.nextLearningActivityStateRepository, currentProfileStateRepository = appGraph.profileDataComponent.currentProfileStateRepository, + currentStudyPlanStateRepository = appGraph.stateRepositoriesComponent.currentStudyPlanStateRepository, sentryInteractor = appGraph.sentryComponent.sentryInteractor, analyticInteractor = appGraph.analyticComponent.analyticInteractor ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StudyPlanWidgetActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StudyPlanWidgetActionDispatcher.kt index 6ec7f74fbb..9b2f07714d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StudyPlanWidgetActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StudyPlanWidgetActionDispatcher.kt @@ -1,102 +1,49 @@ package org.hyperskill.app.study_plan.widget.presentation -import kotlinx.coroutines.delay +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.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.learning_activities.domain.repository.LearningActivitiesRepository import org.hyperskill.app.learning_activities.domain.repository.NextLearningActivityStateRepository import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder import org.hyperskill.app.sentry.domain.withTransaction -import org.hyperskill.app.study_plan.domain.interactor.StudyPlanInteractor +import org.hyperskill.app.study_plan.domain.repository.CurrentStudyPlanStateRepository import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature.Action import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature.InternalAction +import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature.InternalMessage import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature.Message -import org.hyperskill.app.track.domain.interactor.TrackInteractor import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher class StudyPlanWidgetActionDispatcher( config: ActionDispatcherOptions, - private val studyPlanInteractor: StudyPlanInteractor, - private val trackInteractor: TrackInteractor, + private val learningActivitiesRepository: LearningActivitiesRepository, private val nextLearningActivityStateRepository: NextLearningActivityStateRepository, private val currentProfileStateRepository: CurrentProfileStateRepository, + private val currentStudyPlanStateRepository: CurrentStudyPlanStateRepository, private val sentryInteractor: SentryInteractor, private val analyticInteractor: AnalyticInteractor ) : CoroutineActionDispatcher(config.createConfig()) { - override suspend fun doSuspendableAction(action: Action) { - when (action) { - is InternalAction.FetchStudyPlan -> { - if (action.delayBeforeFetching != null) { - delay(action.delayBeforeFetching) - } - val sentryTransaction = HyperskillSentryTransactionBuilder.buildStudyPlanWidgetFetchCurrentStudyPlan() - sentryInteractor.startTransaction(sentryTransaction) - - studyPlanInteractor.getCurrentStudyPlan(forceLoadFromRemote = true) - .onSuccess { studyPlan -> - sentryInteractor.finishTransaction(sentryTransaction) - onNewMessage( - StudyPlanWidgetFeature.StudyPlanFetchResult.Success( - studyPlan = studyPlan, - attemptNumber = action.attemptNumber, - showLoadingIndicators = action.showLoadingIndicators - ) - ) - } - .onFailure { - sentryInteractor.finishTransaction(sentryTransaction, throwable = it) - onNewMessage(StudyPlanWidgetFeature.StudyPlanFetchResult.Failed) - } + init { + currentProfileStateRepository.changes + .distinctUntilChanged() + .onEach { profile -> + onNewMessage(InternalMessage.ProfileChanged(profile)) } - is InternalAction.FetchSections -> { - val sentryTransaction = HyperskillSentryTransactionBuilder.buildStudyPlanWidgetFetchStudyPlanSections() - sentryInteractor.startTransaction(sentryTransaction) - - studyPlanInteractor.getStudyPlanSections(action.sectionsIds) - .onSuccess { sections -> - sentryInteractor.finishTransaction(sentryTransaction) - onNewMessage(StudyPlanWidgetFeature.SectionsFetchResult.Success(sections)) - } - .onFailure { - sentryInteractor.finishTransaction(sentryTransaction, throwable = it) - onNewMessage(StudyPlanWidgetFeature.SectionsFetchResult.Failed) - } - } - is InternalAction.FetchActivities -> { - sentryInteractor.startTransaction(action.sentryTransaction) + .launchIn(actionScope) + } - studyPlanInteractor.getLearningActivities(action.activitiesIds, action.types, action.states) - .onSuccess { learningActivities -> - sentryInteractor.finishTransaction(action.sentryTransaction) - onNewMessage( - StudyPlanWidgetFeature.LearningActivitiesFetchResult.Success( - action.sectionId, - learningActivities - ) - ) - } - .onFailure { - sentryInteractor.finishTransaction(action.sentryTransaction, throwable = it) - onNewMessage(StudyPlanWidgetFeature.LearningActivitiesFetchResult.Failed(action.sectionId)) - } + override suspend fun doSuspendableAction(action: Action) { + when (action) { + is InternalAction.FetchLearningActivitiesWithSections -> { + handleFetchLearningActivitiesWithSectionsAction(action, ::onNewMessage) } - is InternalAction.FetchTrack -> { - val sentryTransaction = HyperskillSentryTransactionBuilder.buildStudyPlanWidgetFetchTrack() - sentryInteractor.startTransaction(sentryTransaction) - - trackInteractor.getTrack(action.trackId, true) - .onSuccess { - sentryInteractor.finishTransaction(sentryTransaction) - onNewMessage( - StudyPlanWidgetFeature.TrackFetchResult.Success(it) - ) - } - .onFailure { - sentryInteractor.finishTransaction(sentryTransaction, throwable = it) - onNewMessage(StudyPlanWidgetFeature.TrackFetchResult.Failed) - } + is InternalAction.FetchLearningActivities -> { + handleFetchLearningActivitiesAction(action, ::onNewMessage) } is InternalAction.FetchProfile -> { sentryInteractor.withTransaction( @@ -109,10 +56,12 @@ class StudyPlanWidgetActionDispatcher( .let(StudyPlanWidgetFeature.ProfileFetchResult::Success) }.let(::onNewMessage) } - is InternalAction.UpdateNextLearningActivityState -> { nextLearningActivityStateRepository.updateState(newState = action.learningActivity) } + is InternalAction.UpdateCurrentStudyPlanState -> { + currentStudyPlanStateRepository.getState(forceUpdate = action.forceUpdate) + } is InternalAction.CaptureSentryException -> { sentryInteractor.captureException(action.throwable) } @@ -124,4 +73,52 @@ class StudyPlanWidgetActionDispatcher( } } } + + private suspend fun handleFetchLearningActivitiesWithSectionsAction( + action: InternalAction.FetchLearningActivitiesWithSections, + onNewMessage: (Message) -> Unit + ) { + sentryInteractor.withTransaction( + HyperskillSentryTransactionBuilder.buildStudyPlanWidgetFetchLearningActivitiesWithSections(), + onError = { StudyPlanWidgetFeature.LearningActivitiesWithSectionsFetchResult.Failed } + ) { + learningActivitiesRepository + .getLearningActivitiesWithSections( + studyPlanSectionTypes = action.studyPlanSectionTypes, + learningActivityTypes = action.learningActivityTypes, + learningActivityStates = action.learningActivityStates + ) + .getOrThrow() + .let { response -> + StudyPlanWidgetFeature.LearningActivitiesWithSectionsFetchResult.Success( + learningActivities = response.learningActivities, + studyPlanSections = response.studyPlanSections + ) + } + }.let(onNewMessage) + } + + private suspend fun handleFetchLearningActivitiesAction( + action: InternalAction.FetchLearningActivities, + onNewMessage: (Message) -> Unit + ) { + sentryInteractor.withTransaction( + action.sentryTransaction, + onError = { StudyPlanWidgetFeature.LearningActivitiesFetchResult.Failed(action.sectionId) } + ) { + learningActivitiesRepository + .getLearningActivities( + activitiesIds = action.activitiesIds, + types = action.types, + states = action.states + ) + .getOrThrow() + .let { learningActivities -> + StudyPlanWidgetFeature.LearningActivitiesFetchResult.Success( + sectionId = action.sectionId, + activities = learningActivities + ) + } + }.let(onNewMessage) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StudyPlanWidgetFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StudyPlanWidgetFeature.kt index 6d584c758c..dd25e27e5e 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StudyPlanWidgetFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StudyPlanWidgetFeature.kt @@ -1,30 +1,24 @@ package org.hyperskill.app.study_plan.widget.presentation -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds import org.hyperskill.app.analytic.domain.model.AnalyticEvent import org.hyperskill.app.learning_activities.domain.model.LearningActivity import org.hyperskill.app.learning_activities.domain.model.LearningActivityState import org.hyperskill.app.learning_activities.domain.model.LearningActivityType import org.hyperskill.app.learning_activities.presentation.model.LearningActivityTargetViewAction import org.hyperskill.app.profile.domain.model.Profile +import org.hyperskill.app.profile.domain.model.isLearningPathDividedTrackTopicsEnabled import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransaction -import org.hyperskill.app.study_plan.domain.model.StudyPlan import org.hyperskill.app.study_plan.domain.model.StudyPlanSection -import org.hyperskill.app.track.domain.model.Track +import org.hyperskill.app.study_plan.domain.model.StudyPlanSectionType object StudyPlanWidgetFeature { - internal val STUDY_PLAN_FETCH_INTERVAL: Duration = 1.seconds - data class State( - val studyPlan: StudyPlan? = null, - - val track: Track? = null, + val profile: Profile? = null, val studyPlanSections: Map = emptyMap(), /** - * Describes status of sections loading, including [studyPlan] loading + * Describes status of sections loading */ val sectionsStatus: ContentStatus = ContentStatus.IDLE, @@ -36,13 +30,14 @@ object StudyPlanWidgetFeature { /** * Pull to refresh flag */ - val isRefreshing: Boolean = false, - + val isRefreshing: Boolean = false + ) { /** * Divided track topics feature enabled flag */ - val isLearningPathDividedTrackTopicsEnabled: Boolean = false - ) + val isLearningPathDividedTrackTopicsEnabled: Boolean + get() = profile?.features?.isLearningPathDividedTrackTopicsEnabled ?: false + } enum class ContentStatus { IDLE, @@ -62,16 +57,12 @@ object StudyPlanWidgetFeature { ) sealed interface Message { - data class Initialize(val forceUpdate: Boolean = false) : Message - data class SectionClicked(val sectionId: Long) : Message data class ActivityClicked(val activityId: Long) : Message data class RetryActivitiesLoading(val sectionId: Long) : Message - object ReloadContentInBackground : Message - object PullToRefresh : Message /** @@ -82,20 +73,21 @@ object StudyPlanWidgetFeature { object StageImplementUnsupportedModalHiddenEventMessage : Message } - internal sealed interface StudyPlanFetchResult : Message { - data class Success( - val studyPlan: StudyPlan, - val attemptNumber: Int, - val showLoadingIndicators: Boolean - ) : StudyPlanFetchResult + internal sealed interface InternalMessage : Message { + data class Initialize(val forceUpdate: Boolean = false) : InternalMessage + + object ReloadContentInBackground : InternalMessage - object Failed : StudyPlanFetchResult + data class ProfileChanged(val profile: Profile) : InternalMessage } - internal sealed interface SectionsFetchResult : Message { - data class Success(val sections: List) : SectionsFetchResult + internal sealed interface LearningActivitiesWithSectionsFetchResult : Message { + data class Success( + val learningActivities: List, + val studyPlanSections: List + ) : LearningActivitiesWithSectionsFetchResult - object Failed : SectionsFetchResult + object Failed : LearningActivitiesWithSectionsFetchResult } internal sealed interface LearningActivitiesFetchResult : Message { @@ -107,12 +99,6 @@ object StudyPlanWidgetFeature { data class Failed(val sectionId: Long) : LearningActivitiesFetchResult } - internal sealed interface TrackFetchResult : Message { - data class Success(val track: Track) : TrackFetchResult - - object Failed : TrackFetchResult - } - internal sealed interface ProfileFetchResult : Message { data class Success(val profile: Profile) : ProfileFetchResult @@ -129,21 +115,13 @@ object StudyPlanWidgetFeature { } internal sealed interface InternalAction : Action { - /** - * Triggers a study plan fetching. - * @param [delayBeforeFetching] is used to wait for definite duration before fetching. - * @param [attemptNumber] represents the number of current attempt of the StudyPlan fetching. - * [attemptNumber] should be passed back in the [StudyPlanFetchResult.Success.attemptNumber]. - */ - data class FetchStudyPlan( - val delayBeforeFetching: Duration? = null, - val attemptNumber: Int = 1, - val showLoadingIndicators: Boolean = true + data class FetchLearningActivitiesWithSections( + val studyPlanSectionTypes: Set = StudyPlanSectionType.supportedTypes(), + val learningActivityTypes: Set = LearningActivityType.supportedTypes(), + val learningActivityStates: Set = setOf(LearningActivityState.TODO) ) : InternalAction - data class FetchSections(val sectionsIds: List) : InternalAction - - data class FetchActivities( + data class FetchLearningActivities( val sectionId: Long, val activitiesIds: List, val types: Set = LearningActivityType.supportedTypes(), @@ -151,10 +129,9 @@ object StudyPlanWidgetFeature { val sentryTransaction: HyperskillSentryTransaction ) : InternalAction - data class FetchTrack(val trackId: Long) : InternalAction - object FetchProfile : InternalAction + data class UpdateCurrentStudyPlanState(val forceUpdate: Boolean) : InternalAction data class UpdateNextLearningActivityState(val learningActivity: LearningActivity?) : InternalAction data class CaptureSentryException(val throwable: Throwable) : InternalAction 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 bfc83e7652..f906555d4f 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 @@ -4,7 +4,6 @@ import kotlin.math.max import kotlin.math.min import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute import org.hyperskill.app.learning_activities.presentation.mapper.LearningActivityTargetViewActionMapper -import org.hyperskill.app.profile.domain.model.isLearningPathDividedTrackTopicsEnabled import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder import org.hyperskill.app.study_plan.domain.analytic.StudyPlanClickedActivityHyperskillAnalyticEvent import org.hyperskill.app.study_plan.domain.analytic.StudyPlanClickedRetryActivitiesLoadingHyperskillAnalyticEvent @@ -13,12 +12,12 @@ import org.hyperskill.app.study_plan.domain.analytic.StudyPlanStageImplementUnsu import org.hyperskill.app.study_plan.domain.analytic.StudyPlanStageImplementUnsupportedModalHiddenHyperskillAnalyticEvent import org.hyperskill.app.study_plan.domain.analytic.StudyPlanStageImplementUnsupportedModalShownHyperskillAnalyticEvent import org.hyperskill.app.study_plan.domain.model.StudyPlanSectionType -import org.hyperskill.app.study_plan.domain.model.StudyPlanStatus import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature.Action import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature.InternalAction +import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature.InternalMessage import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature.Message -import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature.STUDY_PLAN_FETCH_INTERVAL import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature.State +import ru.nobird.app.core.model.slice import ru.nobird.app.presentation.redux.reducer.StateReducer internal typealias StudyPlanWidgetReducerResult = Pair> @@ -30,15 +29,16 @@ class StudyPlanWidgetReducer : StateReducer { override fun reduce(state: State, message: Message): StudyPlanWidgetReducerResult = when (message) { - is Message.Initialize -> + is InternalMessage.Initialize -> coldContentFetch(state, message) - is StudyPlanWidgetFeature.StudyPlanFetchResult.Success -> - handleStudyPlanFetchSuccess(state, message) - is StudyPlanWidgetFeature.SectionsFetchResult.Success -> - handleSectionsFetchSuccess(state, message) + is StudyPlanWidgetFeature.LearningActivitiesWithSectionsFetchResult.Success -> + handleLearningActivitiesWithSectionsFetchSuccess(state, message) + StudyPlanWidgetFeature.LearningActivitiesWithSectionsFetchResult.Failed -> { + state.copy(sectionsStatus = StudyPlanWidgetFeature.ContentStatus.ERROR) to emptySet() + } is Message.RetryActivitiesLoading -> handleRetryActivitiesLoading(state, message) - is Message.ReloadContentInBackground -> { + is InternalMessage.ReloadContentInBackground -> { val currentSectionId = state.getCurrentSection()?.id state.copy( studyPlanSections = state.studyPlanSections.mapValues { (sectionId, sectionInfo) -> @@ -50,13 +50,11 @@ class StudyPlanWidgetReducer : StateReducer { } ) } - ) to setOf(InternalAction.FetchStudyPlan(showLoadingIndicators = false)) + ) to getContentFetchActions(forceUpdate = true) } is Message.PullToRefresh -> if (!state.isRefreshing) { - state.copy(isRefreshing = true) to setOf( - InternalAction.FetchStudyPlan(showLoadingIndicators = false) - ) + state.copy(isRefreshing = true) to getContentFetchActions(forceUpdate = true) } else { null } @@ -64,25 +62,15 @@ class StudyPlanWidgetReducer : StateReducer { handleLearningActivitiesFetchSuccess(state, message) is StudyPlanWidgetFeature.LearningActivitiesFetchResult.Failed -> handleLearningActivitiesFetchFailed(state, message) - is StudyPlanWidgetFeature.StudyPlanFetchResult.Failed, - is StudyPlanWidgetFeature.SectionsFetchResult.Failed -> { - state.copy(sectionsStatus = StudyPlanWidgetFeature.ContentStatus.ERROR) to emptySet() - } - is StudyPlanWidgetFeature.TrackFetchResult.Success -> { - state.copy(track = message.track) to emptySet() - } - is StudyPlanWidgetFeature.TrackFetchResult.Failed -> { - null - } is StudyPlanWidgetFeature.ProfileFetchResult.Success -> { - state.copy( - isLearningPathDividedTrackTopicsEnabled = - message.profile.features.isLearningPathDividedTrackTopicsEnabled - ) to emptySet() + state.copy(profile = message.profile) to emptySet() } is StudyPlanWidgetFeature.ProfileFetchResult.Failed -> { null } + is InternalMessage.ProfileChanged -> { + state.copy(profile = message.profile) to emptySet() + } is Message.SectionClicked -> changeSectionExpanse(state, message.sectionId, shouldLogAnalyticEvent = true) is Message.ActivityClicked -> @@ -114,61 +102,59 @@ class StudyPlanWidgetReducer : StateReducer { ) } ?: (state to emptySet()) - private fun coldContentFetch(state: State, message: Message.Initialize): StudyPlanWidgetReducerResult = + private fun coldContentFetch(state: State, message: InternalMessage.Initialize): StudyPlanWidgetReducerResult = if (state.sectionsStatus == StudyPlanWidgetFeature.ContentStatus.IDLE || state.sectionsStatus == StudyPlanWidgetFeature.ContentStatus.ERROR && message.forceUpdate ) { State(sectionsStatus = StudyPlanWidgetFeature.ContentStatus.LOADING) to - setOf(InternalAction.FetchStudyPlan(), InternalAction.FetchProfile) + getContentFetchActions(forceUpdate = message.forceUpdate) } else { state to emptySet() } - private fun handleStudyPlanFetchSuccess( - state: State, - message: StudyPlanWidgetFeature.StudyPlanFetchResult.Success - ): StudyPlanWidgetReducerResult { - val actions = if (message.studyPlan.status != StudyPlanStatus.READY) { - setOf( - InternalAction.FetchStudyPlan( - delayBeforeFetching = STUDY_PLAN_FETCH_INTERVAL * message.attemptNumber, - attemptNumber = message.attemptNumber + 1 - ) - ) - } else { - setOfNotNull( - InternalAction.FetchSections(message.studyPlan.sections), - message.studyPlan.trackId?.let(InternalAction::FetchTrack) - ) - } - - return if (message.showLoadingIndicators) { - state.copy( - studyPlan = message.studyPlan, - sectionsStatus = StudyPlanWidgetFeature.ContentStatus.LOADING - ) to actions - } else { - state.copy(studyPlan = message.studyPlan) to actions - } - } + private fun getContentFetchActions(forceUpdate: Boolean): Set = + setOf( + InternalAction.FetchLearningActivitiesWithSections(), + InternalAction.FetchProfile, + InternalAction.UpdateCurrentStudyPlanState(forceUpdate) + ) - private fun handleSectionsFetchSuccess( + private fun handleLearningActivitiesWithSectionsFetchSuccess( state: State, - message: StudyPlanWidgetFeature.SectionsFetchResult.Success + message: StudyPlanWidgetFeature.LearningActivitiesWithSectionsFetchResult.Success ): StudyPlanWidgetReducerResult { - if (state.studyPlan == null) { - return state to emptySet() - } + val learningActivitiesIds = message.learningActivities.map { it.id }.toSet() - val sortedSupportedSections = message.sections - .filter { it.isVisible && StudyPlanSectionType.supportedTypes().contains(it.type) } - .sortedBy { state.studyPlan.sections.indexOf(it.id) } + /** + * The current section is the section that contains learning activities that were returned. + * There could be a situation when the API returns activities from not first visible section. + * + * So, we should hide all visible sections that above of current section. + * For example, the first visible section contains only not supported activities. + */ + var visibleSections = message.studyPlanSections.filter { it.isVisible } + val currentSectionIndex = visibleSections + .indexOfFirst { studyPlanSection -> + studyPlanSection.activities.toSet().intersect(learningActivitiesIds).isNotEmpty() + } + .takeIf { it != -1 } + ?: return state.copy( + studyPlanSections = emptyMap(), + sectionsStatus = StudyPlanWidgetFeature.ContentStatus.LOADED, + isRefreshing = false + ) to emptySet() + val currentSectionId = visibleSections[currentSectionIndex].id + visibleSections = visibleSections.slice(from = currentSectionIndex) - val studyPlanSections = sortedSupportedSections.associate { studyPlanSection -> + val studyPlanSections = visibleSections.associate { studyPlanSection -> studyPlanSection.id to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSection, - isExpanded = false, - contentStatus = StudyPlanWidgetFeature.ContentStatus.IDLE + isExpanded = studyPlanSection.id == currentSectionId, + contentStatus = if (studyPlanSection.id == currentSectionId) { + StudyPlanWidgetFeature.ContentStatus.LOADED + } else { + StudyPlanWidgetFeature.ContentStatus.IDLE + } ) } @@ -178,11 +164,13 @@ class StudyPlanWidgetReducer : StateReducer { isRefreshing = false ) - return if (sortedSupportedSections.isNotEmpty()) { - changeSectionExpanse( - loadedSectionsState, - sortedSupportedSections.first().id, - shouldLogAnalyticEvent = false + return if (visibleSections.isNotEmpty()) { + handleLearningActivitiesFetchSuccess( + state = loadedSectionsState, + message = StudyPlanWidgetFeature.LearningActivitiesFetchResult.Success( + sectionId = currentSectionId, + activities = message.learningActivities + ) ) } else { loadedSectionsState to emptySet() @@ -257,7 +245,7 @@ class StudyPlanWidgetReducer : StateReducer { sectionInfo.copy(contentStatus = StudyPlanWidgetFeature.ContentStatus.LOADING) } ) to setOf( - InternalAction.FetchActivities( + InternalAction.FetchLearningActivities( sectionId = message.sectionId, activitiesIds = getPaginatedActivitiesIds( section = section, @@ -305,7 +293,7 @@ class StudyPlanWidgetReducer : StateReducer { isCurrentSection = sectionId == state.getCurrentSection()?.id ) updateSectionState(StudyPlanWidgetFeature.ContentStatus.LOADING) to setOfNotNull( - InternalAction.FetchActivities( + InternalAction.FetchLearningActivities( sectionId = sectionId, activitiesIds = getPaginatedActivitiesIds( section = section, @@ -360,8 +348,8 @@ class StudyPlanWidgetReducer : StateReducer { val activityTargetAction = LearningActivityTargetViewActionMapper .mapLearningActivityToTargetViewAction( activity = activity, - trackId = state.track?.id ?: state.studyPlan?.trackId, - projectId = state.studyPlan?.projectId + trackId = state.profile?.trackId, + projectId = state.profile?.projectId ) .fold( onSuccess = { Action.ViewAction.NavigateTo.LearningActivityTarget(it) }, 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 0a82f841a7..ded6b857e2 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 @@ -1,7 +1,7 @@ package org.hyperskill.app.study_plan.widget.view.mapper import kotlin.math.roundToLong -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.learning_activities.domain.model.LearningActivity import org.hyperskill.app.learning_activities.domain.model.LearningActivityState import org.hyperskill.app.learning_activities.view.mapper.LearningActivityTextsMapper @@ -26,8 +26,7 @@ class StudyPlanWidgetViewStateMapper(private val dateFormatter: SharedDateFormat val currentActivityId = state.getCurrentActivity()?.id return StudyPlanWidgetViewState.Content( - sections = state.studyPlan?.sections?.mapNotNull { sectionId -> - val sectionInfo = state.studyPlanSections[sectionId] ?: return@mapNotNull null + sections = state.studyPlanSections.values.map { sectionInfo -> val section = sectionInfo.studyPlanSection val shouldShowSectionStatistics = currentSectionId == section.id || sectionInfo.isExpanded @@ -59,7 +58,7 @@ class StudyPlanWidgetViewStateMapper(private val dateFormatter: SharedDateFormat null } ) - } ?: emptyList() + } ) } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/injection/TrackSelectionDetailsFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/injection/TrackSelectionDetailsFeatureBuilder.kt index 05cabbbec8..7317225aa1 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/injection/TrackSelectionDetailsFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/injection/TrackSelectionDetailsFeatureBuilder.kt @@ -6,7 +6,7 @@ import org.hyperskill.app.core.domain.BuildVariant import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.core.presentation.transformState import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.logging.presentation.wrapWithLogger import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.profile.domain.repository.ProfileRepository diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/view/TrackSelectionDetailsViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/view/TrackSelectionDetailsViewStateMapper.kt index 5ae6ce0a64..6feed00ce2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/view/TrackSelectionDetailsViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/view/TrackSelectionDetailsViewStateMapper.kt @@ -3,7 +3,7 @@ package org.hyperskill.app.track_selection.details.view import org.hyperskill.app.SharedResources import org.hyperskill.app.core.view.mapper.NumbersFormatter import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.progresses.domain.model.averageRating import org.hyperskill.app.track.domain.model.totalTopicsCount import org.hyperskill.app.track_selection.details.presentation.TrackSelectionDetailsFeature diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/view/TrackSelectionListViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/view/TrackSelectionListViewStateMapper.kt index 85f2386e79..39319fa864 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/view/TrackSelectionListViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/view/TrackSelectionListViewStateMapper.kt @@ -1,7 +1,7 @@ package org.hyperskill.app.track_selection.list.view import org.hyperskill.app.core.view.mapper.NumbersFormatter -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.progresses.domain.model.averageRating import org.hyperskill.app.track.domain.model.TrackWithProgress import org.hyperskill.app.track_selection.list.presentation.TrackSelectionListFeature diff --git a/shared/src/commonMain/resources/MR/base/strings.xml b/shared/src/commonMain/resources/MR/base/strings.xml index e702384b5d..355c55defe 100644 --- a/shared/src/commonMain/resources/MR/base/strings.xml +++ b/shared/src/commonMain/resources/MR/base/strings.xml @@ -509,6 +509,19 @@ Hyperskill – Level up your coding skills! https://hi.hyperskill.org/my-hyperskill-app + + Get ready to push your limits! + The challenge awaits! + Well done, challenge completed! + Close to victory, bonus bounty! + Give it another shot next time! + + Starts in + Complete in + Collect Reward + Oops! We were unable to load the challenge. + Reload + Project Mastery Topic Mastery diff --git a/shared/src/commonTest/kotlin/org/hyperskill/challenges/domain/model/ChallengeDeserializationTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/challenges/domain/model/ChallengeDeserializationTest.kt new file mode 100644 index 0000000000..0a78310f95 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/challenges/domain/model/ChallengeDeserializationTest.kt @@ -0,0 +1,53 @@ +package org.hyperskill.challenges.domain.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.datetime.LocalDate +import org.hyperskill.app.challenges.domain.model.Challenge +import org.hyperskill.app.challenges.domain.model.ChallengeTargetType +import org.hyperskill.app.network.injection.NetworkModule + +class ChallengeDeserializationTest { + companion object { + private val TEST_JSON_STRING = """ +{ + "id": 6, + "title": "QA ☾⋆", + "description": "The Challenge! Ho-ho-ho!🎅\r\nHurry up and get yor prise!", + "target_type": "step", + "starting_date": "2023-11-02", + "interval_duration_days": 1, + "intervals_count": 1, + "status": "not completed", + "reward_link": null, + "progress": + [ + false + ], + "finish_date": "2023-11-03", + "current_interval": null +} + """.trimIndent() + } + + @Test + fun `Test Challenge deserialization`() { + val json = NetworkModule.provideJson() + val expected = Challenge( + id = 6, + title = "QA ☾⋆", + description = "The Challenge! Ho-ho-ho!🎅\r\nHurry up and get yor prise!", + targetTypeValue = ChallengeTargetType.STEP.value, + startingDate = LocalDate.parse("2023-11-02"), + intervalDurationDays = 1, + intervalsCount = 1, + statusValue = "not completed", + rewardLink = null, + progress = listOf(false), + finishDate = LocalDate.parse("2023-11-03"), + currentInterval = null + ) + val decodedObject = json.decodeFromString(Challenge.serializer(), TEST_JSON_STRING) + assertEquals(expected, decodedObject) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/challenges/widget/view/mapper/ChallengeWidgetViewStateMapperTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/challenges/widget/view/mapper/ChallengeWidgetViewStateMapperTest.kt new file mode 100644 index 0000000000..9376ad1df9 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/challenges/widget/view/mapper/ChallengeWidgetViewStateMapperTest.kt @@ -0,0 +1,50 @@ +package org.hyperskill.challenges.widget.view.mapper + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.datetime.LocalDate +import org.hyperskill.ResourceProviderStub +import org.hyperskill.app.challenges.domain.model.Challenge +import org.hyperskill.app.challenges.domain.model.ChallengeStatus +import org.hyperskill.app.challenges.domain.model.ChallengeTargetType +import org.hyperskill.app.challenges.widget.presentation.ChallengeWidgetFeature +import org.hyperskill.app.challenges.widget.view.mapper.ChallengeWidgetViewStateMapper +import org.hyperskill.app.challenges.widget.view.model.ChallengeWidgetViewState +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter + +class ChallengeWidgetViewStateMapperTest { + private val viewStateMapper = ChallengeWidgetViewStateMapper( + dateFormatter = SharedDateFormatter(ResourceProviderStub()), + resourceProvider = ResourceProviderStub() + ) + + @Test + fun `Not started challenge is mapped to Announcement in ViewState`() { + val given = Challenge( + id = 6, + title = "QA ☾⋆", + description = "The Challenge! Ho-ho-ho!🎅\r\nHurry up and get yor prise!", + targetTypeValue = ChallengeTargetType.STEP.value, + startingDate = LocalDate.parse("2023-11-02"), + intervalDurationDays = 1, + intervalsCount = 1, + statusValue = ChallengeStatus.NOT_STARTED.value, + rewardLink = null, + progress = listOf(false), + finishDate = LocalDate.parse("2023-11-03"), + currentInterval = null + ) + + val expected = ChallengeWidgetViewState.Content.Announcement( + headerData = ChallengeWidgetViewState.Content.HeaderData( + title = "QA ☾⋆", + description = "The Challenge! Ho-ho-ho!🎅\r\nHurry up and get yor prise!", + formattedDurationOfTime = "2 Nov - 3 Nov" + ), + startsInState = ChallengeWidgetViewState.Content.Announcement.StartsInState.Deadline + ) + val actual = viewStateMapper.map(ChallengeWidgetFeature.State.Content(challenge = given)) + + assertEquals(expected, actual) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/core/view/mapper/date/MonthFormatterTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/core/view/mapper/date/MonthFormatterTest.kt new file mode 100644 index 0000000000..34831f40fa --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/core/view/mapper/date/MonthFormatterTest.kt @@ -0,0 +1,45 @@ +package org.hyperskill.core.view.mapper.date + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.datetime.Month +import org.hyperskill.app.core.view.mapper.date.MonthFormatter + +class MonthFormatterTest { + @Test + fun `Format Month to short format correct`() { + val months = listOf( + Month.JANUARY, + Month.FEBRUARY, + Month.MARCH, + Month.APRIL, + Month.MAY, + Month.JUNE, + Month.JULY, + Month.AUGUST, + Month.SEPTEMBER, + Month.OCTOBER, + Month.NOVEMBER, + Month.DECEMBER + ) + val expected = listOf( + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ) + for (i in months.indices) { + val month = months[i] + val actual = MonthFormatter.formatMonthToShort(month) + assertEquals(expected[i], actual) + } + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/SharedDateFormatterTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/core/view/mapper/date/SharedDateFormatterTest.kt similarity index 86% rename from shared/src/commonTest/kotlin/org/hyperskill/SharedDateFormatterTest.kt rename to shared/src/commonTest/kotlin/org/hyperskill/core/view/mapper/date/SharedDateFormatterTest.kt index 5754bebbd5..0a372f2778 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/SharedDateFormatterTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/core/view/mapper/date/SharedDateFormatterTest.kt @@ -1,8 +1,12 @@ -package org.hyperskill +package org.hyperskill.core.view.mapper.date +import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.time.DurationUnit import kotlin.time.toDuration -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import kotlinx.datetime.LocalDate +import org.hyperskill.ResourceProviderStub +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter class SharedDateFormatterTest { companion object { @@ -99,4 +103,11 @@ class SharedDateFormatterTest { // dateFormatter.formatTimeDistance(ONE_MINUTE_IN_MILLIS * 60 * 24 * 30 * 34) // ) // } + + @Test + fun `Format day numeric and month short`() { + val given = LocalDate.parse("2023-11-02") + val expected = "2 Nov" + assertEquals(expected, dateFormatter.formatDayNumericAndMonthShort(given)) + } } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/profile/ProfileStub.kt b/shared/src/commonTest/kotlin/org/hyperskill/profile/ProfileStub.kt index b8fc86cadc..8733cd4ab5 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/profile/ProfileStub.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/profile/ProfileStub.kt @@ -7,7 +7,8 @@ fun Profile.Companion.stub( id: Long = 0, isBeta: Boolean = false, isGuest: Boolean = false, - trackId: Long? = null + trackId: Long? = null, + projectId: Long? = null ): Profile = Profile( id = id, @@ -32,6 +33,7 @@ fun Profile.Companion.stub( isStaff = false, trackId = trackId, trackTitle = null, + projectId = projectId, isBeta = isBeta, featuresMap = emptyMap() ) \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/progress_screen/ProgressScreenTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/progress_screen/ProgressScreenTest.kt index 38637d2d0c..adaf6d82bf 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/progress_screen/ProgressScreenTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/progress_screen/ProgressScreenTest.kt @@ -6,7 +6,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue import org.hyperskill.ResourceProviderStub -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.profile.domain.model.Profile import org.hyperskill.app.progress_screen.domain.analytic.ProgressScreenClickedChangeProjectHyperskillAnalyticEvent import org.hyperskill.app.progress_screen.domain.analytic.ProgressScreenClickedChangeTrackHyperskillAnalyticEvent diff --git a/shared/src/commonTest/kotlin/org/hyperskill/projects_selection/ProjectsListTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/projects_selection/ProjectsListTest.kt index 49d65e3888..948507eb32 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/projects_selection/ProjectsListTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/projects_selection/ProjectsListTest.kt @@ -7,7 +7,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import org.hyperskill.ResourceProviderStub import org.hyperskill.app.core.view.mapper.NumbersFormatter -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.profile.domain.model.Profile import org.hyperskill.app.project_selection.list.domain.analytic.ProjectSelectionListClickedProjectHyperskillAnalyticsEvent import org.hyperskill.app.project_selection.list.presentation.ProjectSelectionListFeature diff --git a/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenTest.kt index 7d6fc2772d..93fb1ec329 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenTest.kt @@ -2,6 +2,7 @@ package org.hyperskill.study_plan.screen import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue import kotlin.test.fail import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarScreen import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature @@ -41,13 +42,11 @@ class StudyPlanScreenTest { @Test fun `Pull-to-refresh message should trigger logging pull-to-refresh analytic event`() { val (_, actions) = reducer.reduce(stubState(), StudyPlanScreenFeature.Message.PullToRefresh) - - assertEquals(actions.size, 2) - val targetAction = actions.last() as StudyPlanScreenFeature.InternalAction.LogAnalyticEvent - if (targetAction.analyticEvent is StudyPlanClickedPullToRefreshHyperskillAnalyticEvent) { - // pass - } else { - fail("Unexpected action: $targetAction") + assertTrue { + val targetAction = actions + .filterIsInstance() + .first() + targetAction.analyticEvent is StudyPlanClickedPullToRefreshHyperskillAnalyticEvent } } diff --git a/shared/src/commonTest/kotlin/org/hyperskill/study_plan/widget/StudyPlanWidgetTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/study_plan/widget/StudyPlanWidgetTest.kt index a210c6b7f8..5e123aac33 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/study_plan/widget/StudyPlanWidgetTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/study_plan/widget/StudyPlanWidgetTest.kt @@ -6,26 +6,25 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.test.fail import org.hyperskill.ResourceProviderStub -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.learning_activities.domain.model.LearningActivity import org.hyperskill.app.learning_activities.domain.model.LearningActivityState import org.hyperskill.app.learning_activities.domain.model.LearningActivityTargetType import org.hyperskill.app.learning_activities.domain.model.LearningActivityType import org.hyperskill.app.learning_activities.presentation.model.LearningActivityTargetViewAction +import org.hyperskill.app.profile.domain.model.Profile import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder import org.hyperskill.app.step.domain.model.StepRoute import org.hyperskill.app.study_plan.domain.analytic.StudyPlanClickedActivityHyperskillAnalyticEvent import org.hyperskill.app.study_plan.domain.analytic.StudyPlanClickedRetryActivitiesLoadingHyperskillAnalyticEvent import org.hyperskill.app.study_plan.domain.analytic.StudyPlanClickedSectionHyperskillAnalyticEvent -import org.hyperskill.app.study_plan.domain.model.StudyPlan import org.hyperskill.app.study_plan.domain.model.StudyPlanSection import org.hyperskill.app.study_plan.domain.model.StudyPlanSectionType -import org.hyperskill.app.study_plan.domain.model.StudyPlanStatus import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetReducer import org.hyperskill.app.study_plan.widget.view.mapper.StudyPlanWidgetViewStateMapper import org.hyperskill.app.study_plan.widget.view.model.StudyPlanWidgetViewState -import org.hyperskill.study_plan.domain.model.stub +import org.hyperskill.profile.stub class StudyPlanWidgetTest { @@ -38,183 +37,144 @@ class StudyPlanWidgetTest { ) @Test - fun `Initialize message should trigger studyPLan fetching`() { + fun `Initialize message should trigger learning activities with sections fetching`() { val initialState = StudyPlanWidgetFeature.State() - val (state, actions) = reducer.reduce(initialState, StudyPlanWidgetFeature.Message.Initialize()) - assertContains(actions, StudyPlanWidgetFeature.InternalAction.FetchStudyPlan()) + val (state, actions) = reducer.reduce(initialState, StudyPlanWidgetFeature.InternalMessage.Initialize()) + assertContains(actions, StudyPlanWidgetFeature.InternalAction.FetchLearningActivitiesWithSections()) assertEquals(state.sectionsStatus, StudyPlanWidgetFeature.ContentStatus.LOADING) } @Test - fun `Receiving not ready studyPlan should trigger fetch studyPlan again`() { - val initialState = StudyPlanWidgetFeature.State() - StudyPlanStatus.values() - .asSequence() - .filterNot { it == StudyPlanStatus.READY } - .forEach { status -> - val studyPlan = StudyPlan.stub(id = 0, status = status) - val (_, actions) = reducer.reduce( - initialState, - StudyPlanWidgetFeature.StudyPlanFetchResult.Success(studyPlan, 1, true) - ) - assertTrue { - actions.any { - it is StudyPlanWidgetFeature.InternalAction.FetchStudyPlan - } - } - } - } - - @Test - fun `Receiving not ready studyPlan should trigger fetch studyPlan with delay`() { - val initialState = StudyPlanWidgetFeature.State() - val studyPlan = StudyPlan.stub(id = 0, status = StudyPlanStatus.INITING) - repeat(5) { tryNumber -> - val (_, actions) = reducer.reduce( - initialState, - StudyPlanWidgetFeature.StudyPlanFetchResult.Success(studyPlan, tryNumber, true) - ) - val expectedAction = StudyPlanWidgetFeature.InternalAction.FetchStudyPlan( - delayBeforeFetching = StudyPlanWidgetFeature.STUDY_PLAN_FETCH_INTERVAL * tryNumber, - attemptNumber = tryNumber + 1 + fun `Sections status should be Loaded after loading finish`() { + val (state, _) = reducer.reduce( + StudyPlanWidgetFeature.State(), + StudyPlanWidgetFeature.LearningActivitiesWithSectionsFetchResult.Success( + learningActivities = emptyList(), + studyPlanSections = emptyList() ) - assertContains(actions, expectedAction) - } + ) + assertEquals(StudyPlanWidgetFeature.ContentStatus.LOADED, state.sectionsStatus) } @Test - fun `Receiving ready studyPlan should stop study plan polling`() { - val initialState = StudyPlanWidgetFeature.State() - val studyPlan = StudyPlan.stub(id = 0, status = StudyPlanStatus.READY) - val (_, actions) = reducer.reduce( - initialState, - StudyPlanWidgetFeature.StudyPlanFetchResult.Success(studyPlan, 1, true) + fun `Loaded sections should be filtered by visibility`() { + val hiddenSection = studyPlanSectionStub(id = 1, isVisible = false) + val visibleSection = studyPlanSectionStub( + id = 2, + isVisible = true, + type = StudyPlanSectionType.ROOT_TOPICS, + activities = listOf(1L) ) - assertTrue { - actions.none { - it is StudyPlanWidgetFeature.InternalAction.FetchStudyPlan - } - } - } - @Test - fun `Receiving not ready studyPlan loading state should be shown`() { - val initialState = StudyPlanWidgetFeature.State() - StudyPlanStatus.values() - .asSequence() - .filterNot { it == StudyPlanStatus.READY } - .forEach { status -> - val studyPlan = StudyPlan.stub(id = 0, status = status) - val (state, _) = reducer.reduce( - initialState, - StudyPlanWidgetFeature.StudyPlanFetchResult.Success(studyPlan, 1, true) + val (state, _) = reducer.reduce( + StudyPlanWidgetFeature.State(), + StudyPlanWidgetFeature.LearningActivitiesWithSectionsFetchResult.Success( + learningActivities = listOf( + stubLearningActivity(id = 1L) + ), + studyPlanSections = listOf( + hiddenSection, + visibleSection ) - val viewState = studyPlanWidgetViewStateMapper.map(state) - assertEquals(StudyPlanWidgetViewState.Loading, viewState) - } + ) + ) + + assertEquals(1, state.studyPlanSections.size) + assertEquals(visibleSection, state.studyPlanSections[visibleSection.id]?.studyPlanSection) + + assertEquals(null, state.studyPlanSections[hiddenSection.id]) } @Test - fun `Receiving ready studyPlan should trigger sections loading`() { - val expectedSections = listOf(0L, 1L, 2L) - val studyPlanStub = StudyPlan.stub( + fun `Loaded visible sections should be removed if their position precedes current section`() { + val visibleSection = studyPlanSectionStub( id = 0, - status = StudyPlanStatus.READY, - sections = expectedSections + isVisible = true, + type = StudyPlanSectionType.STAGE + ) + val currentSection = studyPlanSectionStub( + id = 1, + isVisible = true, + type = StudyPlanSectionType.STAGE, + activities = listOf(1L) ) - val initialState = StudyPlanWidgetFeature.State(sectionsStatus = StudyPlanWidgetFeature.ContentStatus.LOADING) - val (state, actions) = - reducer.reduce(initialState, StudyPlanWidgetFeature.StudyPlanFetchResult.Success(studyPlanStub, 1, true)) - assertContains(actions, StudyPlanWidgetFeature.InternalAction.FetchSections(expectedSections)) - assertEquals(studyPlanStub, state.studyPlan) - assertEquals(initialState.sectionsStatus, state.sectionsStatus) - } - @Test - fun `Sections status should be Loaded after loading finish`() { val (state, _) = reducer.reduce( - StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0) - ), - StudyPlanWidgetFeature.SectionsFetchResult.Success(emptyList()) + StudyPlanWidgetFeature.State(), + StudyPlanWidgetFeature.LearningActivitiesWithSectionsFetchResult.Success( + learningActivities = listOf(stubLearningActivity(id = 1L)), + studyPlanSections = listOf(visibleSection, currentSection) + ) ) - assertEquals(StudyPlanWidgetFeature.ContentStatus.LOADED, state.sectionsStatus) + + assertEquals(1, state.studyPlanSections.size) + assertEquals(currentSection, state.studyPlanSections[currentSection.id]?.studyPlanSection) + + assertEquals(null, state.studyPlanSections[visibleSection.id]) } @Test - fun `Loaded sections should be filtered by supportance`() { - assertEquals( - setOf( - StudyPlanSectionType.STAGE, - StudyPlanSectionType.EXTRA_TOPICS, - StudyPlanSectionType.ROOT_TOPICS, - StudyPlanSectionType.NEXT_PROJECT, - StudyPlanSectionType.NEXT_TRACK - ), - StudyPlanSectionType.supportedTypes(), - "Test should be updated according to new supported types" - ) - - val visibleUnsupportedSection = studyPlanSectionStub( - id = 0, - isVisible = true, - type = StudyPlanSectionType.WRAP_UP_TRACK - ) - val hiddenSection = studyPlanSectionStub(id = 1, isVisible = false) - val visibleSupportedSection = studyPlanSectionStub( - id = 2, - isVisible = true, - type = StudyPlanSectionType.ROOT_TOPICS + fun `Study plan sections should be empty if loaded sections does not contains current section`() { + val expectedState = StudyPlanWidgetFeature.State( + studyPlanSections = emptyMap(), + sectionsStatus = StudyPlanWidgetFeature.ContentStatus.LOADED, + isRefreshing = false ) val (state, _) = reducer.reduce( - StudyPlanWidgetFeature.State(studyPlan = StudyPlan.stub(id = 0, sections = listOf(0, 1, 2))), - StudyPlanWidgetFeature.SectionsFetchResult.Success( - listOf( - visibleUnsupportedSection, - hiddenSection, - visibleSupportedSection + StudyPlanWidgetFeature.State(), + StudyPlanWidgetFeature.LearningActivitiesWithSectionsFetchResult.Success( + learningActivities = emptyList(), + studyPlanSections = listOf( + studyPlanSectionStub(id = 0), + studyPlanSectionStub(id = 1, activities = listOf(1)) ) ) ) - assertEquals(1, state.studyPlanSections.size) - assertEquals(visibleSupportedSection, state.studyPlanSections[visibleSupportedSection.id]?.studyPlanSection) - - assertEquals(null, state.studyPlanSections[hiddenSection.id]) - assertEquals(null, state.studyPlanSections[visibleUnsupportedSection.id]) + assertEquals(expectedState, state) } @Test - fun `Sections in ViewState should be sorted by sections order in studyPlan`() { + fun `Sections in ViewState should be sorted by backend order`() { val expectedSectionsIds = listOf(3, 5, 2, 1, 4) val expectedViewState = StudyPlanWidgetViewState.Content( expectedSectionsIds.mapIndexed { index, sectionId -> sectionViewState( - section = studyPlanSectionStub(id = sectionId), + section = studyPlanSectionStub( + id = sectionId, + activities = if (index == 0) listOf(1L) else emptyList() + ), content = if (index > 0) { StudyPlanWidgetViewState.SectionContent.Collapsed } else { - StudyPlanWidgetViewState.SectionContent.Loading + StudyPlanWidgetViewState.SectionContent.Content( + listOf( + studyPlanSectionItemStub( + activityId = 1, + state = StudyPlanWidgetViewState.SectionItemState.NEXT + ) + ) + ) }, isCurrent = sectionId == expectedSectionsIds[0] ) } ) - val fetchedSectionsIds = listOf(1, 2, 3, 4, 5) + val fetchedSectionsIds = listOf(3, 5, 2, 1, 4) val (state, _) = reducer.reduce( - StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub( - id = 0, - sections = expectedSectionsIds, - status = StudyPlanStatus.READY - ) - ), - StudyPlanWidgetFeature.SectionsFetchResult.Success( - fetchedSectionsIds.map { sectionId -> - studyPlanSectionStub(id = sectionId) + StudyPlanWidgetFeature.State(), + StudyPlanWidgetFeature.LearningActivitiesWithSectionsFetchResult.Success( + learningActivities = listOf( + stubLearningActivity(id = 1L) + ), + studyPlanSections = fetchedSectionsIds.mapIndexed { index, sectionId -> + studyPlanSectionStub( + id = sectionId, + activities = if (index == 0) listOf(1L) else emptyList() + ) } ) ) @@ -225,24 +185,29 @@ class StudyPlanWidgetTest { } @Test - fun `First section should be expanded and its activities loading should be triggered`() { + fun `First section should be expanded and its activities should be loaded`() { val firstSection = studyPlanSectionStub(id = 0, activities = listOf(0, 1, 2)) val secondSection = studyPlanSectionStub(id = 1) + val (state, actions) = reducer.reduce( - StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(0, 1)) - ), - StudyPlanWidgetFeature.SectionsFetchResult.Success(listOf(firstSection, secondSection)) - ) - assertContains( - actions, - StudyPlanWidgetFeature.InternalAction.FetchActivities( - sectionId = firstSection.id, - activitiesIds = firstSection.activities, - sentryTransaction = HyperskillSentryTransactionBuilder.buildStudyPlanWidgetFetchLearningActivities(true) + StudyPlanWidgetFeature.State(), + StudyPlanWidgetFeature.LearningActivitiesWithSectionsFetchResult.Success( + learningActivities = listOf( + stubLearningActivity(id = 0), + stubLearningActivity(id = 1), + stubLearningActivity(id = 2) + ), + studyPlanSections = listOf(firstSection, secondSection) ) ) - assertEquals(true, state.studyPlanSections[firstSection.id]?.isExpanded) + + assertTrue { + actions.filterIsInstance().isEmpty() + } + + val actualFirstSection = state.studyPlanSections[firstSection.id] + assertEquals(StudyPlanWidgetFeature.ContentStatus.LOADED, actualFirstSection?.contentStatus) + assertEquals(true, actualFirstSection?.isExpanded) } @Test @@ -292,7 +257,7 @@ class StudyPlanWidgetTest { StudyPlanWidgetFeature.LearningActivitiesFetchResult.Success(sectionId = currentSectionId, emptyList()) ) assertTrue(state.studyPlanSections.containsKey(currentSectionId).not()) - val nextSection = state.studyPlanSections.get(nextSectionId) + val nextSection = state.studyPlanSections[nextSectionId] assertTrue(nextSection?.isExpanded == true) assertEquals(StudyPlanWidgetFeature.ContentStatus.LOADING, nextSection?.contentStatus) } @@ -432,7 +397,7 @@ class StudyPlanWidgetTest { assertContains( actions, - StudyPlanWidgetFeature.InternalAction.FetchActivities( + StudyPlanWidgetFeature.InternalAction.FetchLearningActivities( sectionId = section.id, activitiesIds = activities, sentryTransaction = HyperskillSentryTransactionBuilder.buildStudyPlanWidgetFetchLearningActivities(true) @@ -461,13 +426,12 @@ class StudyPlanWidgetTest { val idleSection = StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = section, - contentStatus = StudyPlanWidgetFeature.ContentStatus.LOADED, + contentStatus = StudyPlanWidgetFeature.ContentStatus.IDLE, isExpanded = false ) listOf(collapsedSection, idleSection).forEach { givenSection -> val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(sectionId)), studyPlanSections = mapOf(sectionId to givenSection), sectionsStatus = StudyPlanWidgetFeature.ContentStatus.LOADED ) @@ -498,7 +462,6 @@ class StudyPlanWidgetTest { isExpanded = true ) val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(sectionId)), studyPlanSections = mapOf(sectionId to section), sectionsStatus = StudyPlanWidgetFeature.ContentStatus.LOADED, activities = mapOf(activityId to stubLearningActivity(activityId)) @@ -537,7 +500,6 @@ class StudyPlanWidgetTest { isExpanded = true ) val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(sectionId)), studyPlanSections = mapOf(sectionId to section), sectionsStatus = StudyPlanWidgetFeature.ContentStatus.LOADED, activities = mapOf(activityId to stubLearningActivity(activityId)) @@ -566,10 +528,10 @@ class StudyPlanWidgetTest { } @Test - fun `Reload content in background should trigger fetch studyPlan without loading indicators`() { - val state = StudyPlanWidgetFeature.State(studyPlan = StudyPlan.stub(id = 0)) - val (_, actions) = reducer.reduce(state, StudyPlanWidgetFeature.Message.ReloadContentInBackground) - assertContains(actions, StudyPlanWidgetFeature.InternalAction.FetchStudyPlan(showLoadingIndicators = false)) + fun `Reload content in background should trigger fetch learning activities with sections`() { + val state = StudyPlanWidgetFeature.State() + val (_, actions) = reducer.reduce(state, StudyPlanWidgetFeature.InternalMessage.ReloadContentInBackground) + assertContains(actions, StudyPlanWidgetFeature.InternalAction.FetchLearningActivitiesWithSections()) } @Test @@ -581,16 +543,18 @@ class StudyPlanWidgetTest { isExpanded = true ) val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(sectionId)), studyPlanSections = mapOf(sectionId to section), sectionsStatus = StudyPlanWidgetFeature.ContentStatus.LOADED ) - val (newState, actions) = reducer.reduce(state, StudyPlanWidgetFeature.Message.ReloadContentInBackground) + + val (newState, actions) = + reducer.reduce(state, StudyPlanWidgetFeature.InternalMessage.ReloadContentInBackground) + assertEquals( state.studyPlanSections[sectionId]?.contentStatus, newState.studyPlanSections[sectionId]?.contentStatus ) - assertContains(actions, StudyPlanWidgetFeature.InternalAction.FetchStudyPlan(showLoadingIndicators = false)) + assertContains(actions, StudyPlanWidgetFeature.InternalAction.FetchLearningActivitiesWithSections()) } @Test @@ -614,7 +578,6 @@ class StudyPlanWidgetTest { ) val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(0)), studyPlanSections = mapOf( 0L to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub(id = 0, activities = listOf(0)), @@ -653,7 +616,6 @@ class StudyPlanWidgetTest { ) val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(0)), studyPlanSections = mapOf( 0L to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub(id = 0, activities = listOf(0)), @@ -691,7 +653,6 @@ class StudyPlanWidgetTest { ) val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(0)), studyPlanSections = mapOf( 0L to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub(id = 0, activities = listOf(0)), @@ -717,7 +678,6 @@ class StudyPlanWidgetTest { fun `Section content statistics in ViewState should be always visible for first visible section`() { fun makeState(isExpanded: Boolean): StudyPlanWidgetFeature.State = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(0)), studyPlanSections = mapOf( 0L to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub( @@ -782,7 +742,6 @@ class StudyPlanWidgetTest { ) val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(0, 1)), studyPlanSections = mapOf( 0L to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub(id = 0, activities = listOf(0)), @@ -819,7 +778,7 @@ class StudyPlanWidgetTest { val sectionId = 3L val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, projectId = projectId, sections = listOf(sectionId)), + profile = Profile.stub(projectId = projectId), studyPlanSections = mapOf( sectionId to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub(id = sectionId, activities = listOf(activityId)), @@ -854,7 +813,6 @@ class StudyPlanWidgetTest { fun `Click on stage implement learning activity with non stage target should do nothing`() { val activityId = 0L val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(0)), studyPlanSections = mapOf( 0L to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub(id = 0, activities = listOf(activityId)), @@ -886,7 +844,6 @@ class StudyPlanWidgetTest { val stepId = 1L val sectionId = 2L val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(sectionId)), studyPlanSections = mapOf( sectionId to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub(id = sectionId, activities = listOf(activityId)), @@ -931,7 +888,6 @@ class StudyPlanWidgetTest { fun `Click on learn topic learning activity with non step target should do nothing`() { val activityId = 0L val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(0)), studyPlanSections = mapOf( 0L to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub(id = 0, activities = listOf(activityId)), @@ -962,7 +918,7 @@ class StudyPlanWidgetTest { val activityId = 0L val sectionId = 1L val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, projectId = 1L), + profile = Profile.stub(projectId = 1), studyPlanSections = mapOf( sectionId to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub(id = sectionId, activities = listOf(activityId)), @@ -1000,7 +956,7 @@ class StudyPlanWidgetTest { val sectionId = 1L val trackId = 2L val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, trackId = trackId), + profile = Profile.stub(trackId = trackId), studyPlanSections = mapOf( sectionId to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub(id = sectionId, activities = listOf(activityId)), @@ -1030,7 +986,6 @@ class StudyPlanWidgetTest { val activityId = 0L val sectionId = 1L val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0), studyPlanSections = mapOf( sectionId to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub(id = sectionId, activities = listOf(activityId)), @@ -1059,7 +1014,6 @@ class StudyPlanWidgetTest { fun `Clicking on section should change section expansion state`() { val sectionId = 0L val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0), studyPlanSections = mapOf( sectionId to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub(id = sectionId), @@ -1205,7 +1159,6 @@ class StudyPlanWidgetTest { val notNextActivityId = 1L val sectionId = 2L val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(sectionId)), studyPlanSections = mapOf( sectionId to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub( @@ -1249,7 +1202,6 @@ class StudyPlanWidgetTest { val secondActivityId = 1L val sectionId = 2L val state = StudyPlanWidgetFeature.State( - studyPlan = StudyPlan.stub(id = 0, sections = listOf(sectionId)), studyPlanSections = mapOf( sectionId to StudyPlanWidgetFeature.StudyPlanSectionInfo( studyPlanSection = studyPlanSectionStub( diff --git a/shared/src/commonTest/kotlin/org/hyperskill/track_selection/details/TrackSelectionDetailsTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/track_selection/details/TrackSelectionDetailsTest.kt index 3b57738b39..fdcee625e7 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/track_selection/details/TrackSelectionDetailsTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/track_selection/details/TrackSelectionDetailsTest.kt @@ -8,7 +8,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import org.hyperskill.ResourceProviderStub -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.profile.domain.model.Profile import org.hyperskill.app.providers.domain.model.Provider import org.hyperskill.app.subscriptions.domain.model.SubscriptionType diff --git a/shared/src/commonTest/kotlin/org/hyperskill/track_selection/list/TrackSelectionListTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/track_selection/list/TrackSelectionListTest.kt index 85ce3cf9a3..5f0ae2dbab 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/track_selection/list/TrackSelectionListTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/track_selection/list/TrackSelectionListTest.kt @@ -6,7 +6,7 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue import org.hyperskill.ResourceProviderStub import org.hyperskill.app.core.view.mapper.NumbersFormatter -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.track.domain.model.TrackWithProgress import org.hyperskill.app.track_selection.list.injection.TrackSelectionListParams import org.hyperskill.app.track_selection.list.presentation.TrackSelectionListFeature diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/core/injection/CommonComponentImpl.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/core/injection/CommonComponentImpl.kt index ed6212897b..93e495b91a 100644 --- a/shared/src/iosMain/kotlin/org/hyperskill/app/core/injection/CommonComponentImpl.kt +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/core/injection/CommonComponentImpl.kt @@ -11,7 +11,7 @@ import org.hyperskill.app.core.remote.UserAgentInfo import org.hyperskill.app.core.view.mapper.NumbersFormatter import org.hyperskill.app.core.view.mapper.ResourceProvider import org.hyperskill.app.core.view.mapper.ResourceProviderImpl -import org.hyperskill.app.core.view.mapper.SharedDateFormatter +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.network.injection.NetworkModule import platform.Foundation.NSUserDefaults