diff --git a/README.md b/README.md index 62cd797a95..8474439d2e 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,7 @@ Master coding on-the-go with [Hyperskill](https://hyperskill.org/)! Immerse yourself in curated lessons, keep your streak, and sharpen your knowledge with 3000+ hands-on topics. Hyperskill is the ultimate app to learn programming languages and technologies, offering over 50 courses and 300+ projects that cater to all levels of expertise. -![Choose your course](resources/screenshots/01.webp) -![Practice in a best way](resources/screenshots/02.webp) -![Learn practice repeat](resources/screenshots/03.webp) +Learn to code anywhere, anytimeMaster real-world skillsFollow your study plan Features: * **Curated Learning Experience:** Dive into lessons that are structured to guide you from beginner to expert. diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallContent.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallContent.kt index a72527b760..891e9718a0 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallContent.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallContent.kt @@ -17,6 +17,10 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.colorResource @@ -31,11 +35,13 @@ import org.hyperskill.app.R import org.hyperskill.app.android.core.extensions.compose.plus 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.paywall.presentation.PaywallFeature @Composable fun PaywallContent( buyButtonText: String, - priceText: String?, + products: List, + onProductClick: (String) -> Unit, onTermsOfServiceClick: () -> Unit, onBuySubscriptionClick: () -> Unit, modifier: Modifier = Modifier, @@ -49,7 +55,9 @@ fun PaywallContent( .windowInsetsPadding(WindowInsets.safeDrawing) ) { Column( - modifier = Modifier.weight(1f) + modifier = Modifier + .weight(1f) + .fillMaxWidth() ) { Image( painter = painterResource(id = org.hyperskill.app.android.R.drawable.img_paywall), @@ -64,10 +72,12 @@ fun PaywallContent( ) Spacer(modifier = Modifier.height(24.dp)) SubscriptionDetails() - Spacer(modifier = Modifier.height(32.dp)) - if (priceText != null) { - SubscriptionPrice(priceText) - } + Spacer(modifier = Modifier.height(24.dp)) + SubscriptionProducts( + products = products, + onProdutsClick = onProductClick + ) + Spacer(modifier = Modifier.height(24.dp)) } Column { HyperskillButton( @@ -87,23 +97,27 @@ fun PaywallContent( } @Composable -private fun SubscriptionPrice( - priceText: String, +private fun SubscriptionProducts( + products: List, + onProdutsClick: (String) -> Unit, modifier: Modifier = Modifier ) { Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = modifier ) { - Text( - text = stringResource(id = R.string.paywall_android_subscription_duration), - style = MaterialTheme.typography.subtitle1, - fontWeight = FontWeight.Medium - ) - Text( - text = priceText, - style = MaterialTheme.typography.body2 - ) + products.forEach { option -> + key(option.productId) { + SubscriptionProduct( + product = option, + onClick = remember { + { + onProdutsClick(option.productId) + } + } + ) + } + } } } @@ -124,9 +138,15 @@ private fun TermsOfService( @Composable fun PaywallContentPreview() { HyperskillTheme { + val options by remember { + mutableStateOf( + PaywallPreviewDefaults.subscriptionProducts + ) + } PaywallContent( buyButtonText = PaywallPreviewDefaults.BUY_BUTTON_TEXT, - priceText = PaywallPreviewDefaults.PRICE_TEXT, + products = options, + onProductClick = { }, onTermsOfServiceClick = {}, onBuySubscriptionClick = {} ) diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallDefaults.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallDefaults.kt index 22bd8fbddd..695ee5de81 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallDefaults.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallDefaults.kt @@ -9,10 +9,8 @@ import org.hyperskill.app.R object PaywallDefaults { val ContentPadding = PaddingValues( - start = 24.dp, - end = 20.dp, - top = 24.dp, - bottom = 32.dp + horizontal = 20.dp, + vertical = 24.dp ) val BackgroundColor: Color diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallPreviewDefaults.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallPreviewDefaults.kt index cde159a3f9..1943c56fdf 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallPreviewDefaults.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallPreviewDefaults.kt @@ -1,6 +1,23 @@ package org.hyperskill.app.android.paywall.ui +import org.hyperskill.app.paywall.presentation.PaywallFeature + object PaywallPreviewDefaults { - const val BUY_BUTTON_TEXT = "Subscribe" - const val PRICE_TEXT = "$11.99 / month" + const val BUY_BUTTON_TEXT = "Start now" + val subscriptionProducts = listOf( + PaywallFeature.ViewStateContent.SubscriptionProduct( + productId = "1", + title = "Annual 100$", + subtitle = "$8.33 / month", + isBestValue = true, + isSelected = true + ), + PaywallFeature.ViewStateContent.SubscriptionProduct( + productId = "2", + title = "Monthly", + subtitle = "$12 / month", + isBestValue = false, + isSelected = false + ) + ) } \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallScreen.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallScreen.kt index 7d768e90dc..6fb6639f5f 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallScreen.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallScreen.kt @@ -58,6 +58,7 @@ fun PaywallScreen( PaywallScreen( viewState = state, onBackClick = onBackClick, + onProductClick = viewModel::onOptionClick, onBuySubscriptionClick = remember(activity) { { viewModel.onBuySubscriptionClick(activity) @@ -73,6 +74,7 @@ fun PaywallScreen( fun PaywallScreen( viewState: ViewState, onBackClick: () -> Unit, + onProductClick: (String) -> Unit, onBuySubscriptionClick: () -> Unit, onCloseClick: () -> Unit, onRetryLoadingClick: () -> Unit, @@ -106,7 +108,8 @@ fun PaywallScreen( is ViewStateContent.Content -> PaywallContent( buyButtonText = contentState.buyButtonText, - priceText = contentState.priceText, + products = contentState.subscriptionProducts, + onProductClick = onProductClick, onBuySubscriptionClick = onBuySubscriptionClick, onTermsOfServiceClick = onTermsOfServiceClick, padding = padding @@ -192,16 +195,14 @@ private class PaywallPreviewProvider : PreviewParameterProvider { isToolbarVisible = true, contentState = ViewStateContent.Content( buyButtonText = PaywallPreviewDefaults.BUY_BUTTON_TEXT, - priceText = "$11.99 / month", - trialText = null + subscriptionProducts = PaywallPreviewDefaults.subscriptionProducts ) ), ViewState( isToolbarVisible = false, contentState = ViewStateContent.Content( buyButtonText = PaywallPreviewDefaults.BUY_BUTTON_TEXT, - priceText = PaywallPreviewDefaults.PRICE_TEXT, - trialText = null + subscriptionProducts = PaywallPreviewDefaults.subscriptionProducts ) ), ViewState( @@ -237,6 +238,7 @@ fun PaywallScreenPreview( PaywallScreen( viewState = viewState, onBackClick = {}, + onProductClick = {}, onBuySubscriptionClick = {}, onCloseClick = {}, onRetryLoadingClick = {}, diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionDetails.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionDetails.kt index ff534f8a8c..2c68d3eca6 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionDetails.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionDetails.kt @@ -27,15 +27,15 @@ fun SubscriptionDetails( modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp) ) { - SubscriptionOption(text = stringResource(id = SharedR.string.mobile_only_subscription_feature_1)) - SubscriptionOption(text = stringResource(id = SharedR.string.mobile_only_subscription_feature_2)) - SubscriptionOption(text = stringResource(id = SharedR.string.mobile_only_subscription_feature_3)) - SubscriptionOption(text = stringResource(id = SharedR.string.mobile_only_subscription_feature_4)) + SubscriptionDetail(text = stringResource(id = SharedR.string.mobile_only_subscription_feature_1)) + SubscriptionDetail(text = stringResource(id = SharedR.string.mobile_only_subscription_feature_2)) + SubscriptionDetail(text = stringResource(id = SharedR.string.mobile_only_subscription_feature_3)) + SubscriptionDetail(text = stringResource(id = SharedR.string.mobile_only_subscription_feature_4)) } } @Composable -fun SubscriptionOption( +fun SubscriptionDetail( text: String, modifier: Modifier = Modifier ) { diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionProduct.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionProduct.kt new file mode 100644 index 0000000000..cd93a450ae --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionProduct.kt @@ -0,0 +1,257 @@ +package org.hyperskill.app.android.paywall.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.dp +import androidx.core.view.HapticFeedbackConstantsCompat +import org.hyperskill.app.android.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.paywall.presentation.PaywallFeature +import org.hyperskill.app.R as SharedR + +@Composable +fun SubscriptionProduct( + product: PaywallFeature.ViewStateContent.SubscriptionProduct, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val currentOnClick by rememberUpdatedState(newValue = onClick) + if (product.isBestValue) { + BestValueSubscriptionProduct( + title = product.title, + subtitle = product.subtitle, + isSelected = product.isSelected, + modifier = modifier, + onClick = currentOnClick + ) + } else { + SubscriptionProduct( + title = product.title, + subtitle = product.subtitle, + isSelected = product.isSelected, + modifier = modifier, + onClick = currentOnClick + ) + } +} + +@Composable +fun SubscriptionProduct( + title: String, + subtitle: String, + isSelected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + val borderColor by animateColorAsState( + targetValue = colorResource( + id = if (isSelected) { + SharedR.color.color_overlay_blue + } else SharedR.color.color_on_surface_alpha_12 + ), + label = "Border color" + ) + val borderWidth by animateDpAsState( + targetValue = if (isSelected) 2.dp else 1.dp, + label = "Border width" + ) + val view = LocalView.current + val verticalPadding by animateDpAsState( + targetValue = if (isSelected) 34.dp else 18.dp, + label = "Vertical padding", + finishedListener = { + view.performHapticFeedback(HapticFeedbackConstantsCompat.TOGGLE_ON) + } + ) + val textColor by animateColorAsState( + targetValue = colorResource( + if (isSelected) { + SharedR.color.color_on_surface + } else { + SharedR.color.color_on_surface_alpha_60 + } + ), + label = "Text color" + ) + SubscriptionProduct( + title = title, + subtitle = subtitle, + borderColor = borderColor, + borderWidth = borderWidth, + verticalPadding = verticalPadding, + textColor = textColor, + modifier = modifier, + onClick = onClick + ) +} + +@Composable +fun SubscriptionProduct( + title: String, + subtitle: String, + borderColor: Color, + borderWidth: Dp, + verticalPadding: Dp, + textColor: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(dimensionResource(id = R.dimen.corner_radius))) + .border( + width = borderWidth, + color = borderColor, + shape = RoundedCornerShape(dimensionResource(id = R.dimen.corner_radius)) + ) + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = verticalPadding, horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = title, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = textColor + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = subtitle, + color = textColor + ) + } + } +} + +private const val BestValueLayoutId = "Best value tag" + +@Suppress("MagicNumber") +@Composable +fun BestValueSubscriptionProduct( + title: String, + subtitle: String, + isSelected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Layout( + modifier = modifier, + content = { + SubscriptionProduct( + title = title, + subtitle = subtitle, + isSelected = isSelected, + onClick = onClick + ) + Text( + text = stringResource(id = SharedR.string.paywall_best_value_label), + color = colorResource(id = SharedR.color.color_on_error), + modifier = Modifier + .layoutId(BestValueLayoutId) + .clip(RoundedCornerShape(16.dp)) + .background(colorResource(id = SharedR.color.color_overlay_blue)) + .padding(horizontal = 10.dp, vertical = 5.dp) + ) + } + ) { measurables, constraints -> + val tagMeasurable = measurables.first { + it.layoutId == BestValueLayoutId + } + val optionMeasurable = (measurables - tagMeasurable).first() + + val tagPlaceable = tagMeasurable.measure(constraints) + val optionPlaceable = optionMeasurable.measure(constraints) + + val optionTopPadding = tagPlaceable.height / 2 + val optionEndPadding = optionTopPadding / 2 + + layout( + width = optionPlaceable.width, + height = optionPlaceable.height + optionTopPadding + ) { + optionPlaceable.place(x = 0, y = optionTopPadding) + tagPlaceable.place( + x = optionPlaceable.width - tagPlaceable.width + optionEndPadding, + y = 0 + ) + } + } +} + +private class SubscriptionProductPreviewProvider : + PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + PaywallFeature.ViewStateContent.SubscriptionProduct( + productId = "1", + title = "Annual 100$", + subtitle = "$8.33 / month", + isBestValue = true, + isSelected = true + ), + PaywallFeature.ViewStateContent.SubscriptionProduct( + productId = "2", + title = "Monthly", + subtitle = "$12 / month", + isBestValue = false, + isSelected = false + ) + ) +} + +@Preview(showBackground = true) +@Composable +private fun SubscriptionProductPreview( + @PreviewParameter(provider = SubscriptionProductPreviewProvider::class) + option: PaywallFeature.ViewStateContent.SubscriptionProduct +) { + HyperskillTheme { + var mutableOption by remember { + mutableStateOf(option) + } + SubscriptionProduct( + product = mutableOption, + onClick = { + mutableOption = mutableOption.copy(isSelected = !mutableOption.isSelected) + }, + modifier = Modifier.padding(horizontal = 10.dp) + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/drawable-hdpi/img_paywall.webp b/androidHyperskillApp/src/main/res/drawable-hdpi/img_paywall.webp index 1482d2f89f..667e759d5d 100644 Binary files a/androidHyperskillApp/src/main/res/drawable-hdpi/img_paywall.webp and b/androidHyperskillApp/src/main/res/drawable-hdpi/img_paywall.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-mdpi/img_paywall.webp b/androidHyperskillApp/src/main/res/drawable-mdpi/img_paywall.webp index 802ff4e406..9e08f03515 100644 Binary files a/androidHyperskillApp/src/main/res/drawable-mdpi/img_paywall.webp and b/androidHyperskillApp/src/main/res/drawable-mdpi/img_paywall.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xhdpi/img_paywall.webp b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_paywall.webp index 0b77ce147c..bff6d1b447 100644 Binary files a/androidHyperskillApp/src/main/res/drawable-xhdpi/img_paywall.webp and b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_paywall.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_paywall.webp b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_paywall.webp index 5038d34de7..4446550f81 100644 Binary files a/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_paywall.webp and b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_paywall.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_paywall.webp b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_paywall.webp index 28dc6cb8fb..c67908b927 100644 Binary files a/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_paywall.webp and b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_paywall.webp differ diff --git a/androidHyperskillApp/src/main/res/layout/view_profile_settings_content.xml b/androidHyperskillApp/src/main/res/layout/view_profile_settings_content.xml index f1a5f4e7f6..a0a7e2f1ea 100644 --- a/androidHyperskillApp/src/main/res/layout/view_profile_settings_content.xml +++ b/androidHyperskillApp/src/main/res/layout/view_profile_settings_content.xml @@ -30,7 +30,7 @@ android:textAppearance="@style/TextAppearance.AppCompat.Body2" android:textColor="@color/color_primary" /> - + android:textSize="16sp" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@+id/settingsSubscriptionDetails" + app:layout_constraintTop_toTopOf="parent" /> + android:textSize="16sp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent"/> - + diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index 86ff7e88f4..5787612812 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -55,6 +55,7 @@ ImplicitDefaultLocale:TimeIntervalUtil.kt$TimeIntervalUtil$String.format("%02d:00 \u2014 %02d:00", i, i + 1) InvalidPackageDeclaration:HandleActions.kt$package org.hyperskill.app.core.view LambdaParameterInRestartableEffect:OnComposableShownFirstTime.kt$block + LargeClass:StepQuizCodeBlanksReducer.kt$StepQuizCodeBlanksReducer : StateReducer LargeClass:StepQuizReducer.kt$StepQuizReducer : StateReducer LongMethod:AppReducer.kt$AppReducer$private fun handleFetchAppStartupConfigSuccess( state: State, message: Message.FetchAppStartupConfigSuccess ): ReducerResult LongMethod:ChallengeCard.kt$@Composable fun ChallengeCard( viewState: ChallengeWidgetViewState, onNewMessage: (Message) -> Unit ) @@ -156,7 +157,6 @@ MagicNumber:StepDelegate.kt$StepDelegate$25 MagicNumber:StepDelegate.kt$StepDelegate$5 MagicNumber:StepDelegate.kt$StepDelegate$50 - MagicNumber:StepQuizCodeBlanksReducer.kt$StepQuizCodeBlanksReducer$47580L MagicNumber:StudyPlanActivityAdapterDelegate.kt$StudyPlanActivityAdapterDelegate.ViewHolder$100f MagicNumber:SubscriptionSyncLoading.kt$0.5f MagicNumber:TimeIntervalPickerDialogFragment.kt$TimeIntervalPickerDialogFragment$50f @@ -203,7 +203,7 @@ ModifierReused:LeaderboardPlaceInfo.kt$Row( modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = placeNumber.toString(), style = MaterialTheme.typography.body2, color = colorResource(id = R.color.color_on_surface_alpha_60), modifier = Modifier.align(Alignment.CenterVertically) ) if (placeNumber in 1..3) { Image( painter = painterResource( id = when (placeNumber) { 1 -> org.hyperskill.app.android.R.drawable.ic_leaderboard_first_place 2 -> org.hyperskill.app.android.R.drawable.ic_leaderboard_second_place 3 -> org.hyperskill.app.android.R.drawable.ic_leaderboard_third_place else -> error("Place icon should not be visible for the place number $placeNumber") } ), contentDescription = null, modifier = modifier .requiredSize(24.dp) .align(Alignment.CenterVertically) ) } } ModifierWithoutDefault:BadgeImage.kt$modifier NestedBlockDepth:AuthSocialWebViewClient.kt$AuthSocialWebViewClient$override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? ): Boolean - NestedBlockDepth:StepQuizCodeBlanksReducer.kt$StepQuizCodeBlanksReducer$private fun setCodeBlockIsActive(codeBlock: CodeBlock, isActive: Boolean): CodeBlock + NestedBlockDepth:StepQuizCodeBlanksViewStateMapper.kt$StepQuizCodeBlanksViewStateMapper$private fun mapContentState( state: StepQuizCodeBlanksFeature.State.Content ): StepQuizCodeBlanksViewState.Content PreviewPublic:BadgeCard.kt$BadgeCardPreview PreviewPublic:BadgeCard.kt$LastLevelBadgeCardPreview PreviewPublic:BadgeGrid.kt$PhoneBadgeGridPreview @@ -248,6 +248,7 @@ ReturnCount:SearchReducer.kt$SearchReducer$private fun handleSearchResultsItemClickedMessage( state: State, message: Message.SearchResultsItemClicked ): SearchReducerResult? ReturnCount:SharedDateFormatter.kt$SharedDateFormatter$fun formatTimeDistance(millis: Long): String ReturnCount:StateExtentions.kt$internal fun ChallengeWidgetFeature.State.Content.setCurrentChallengeIntervalProgressAsCompleted(): Challenge? + ReturnCount:StepQuizCodeBlanksReducer.kt$StepQuizCodeBlanksReducer$private fun handleDecreaseIndentLevelButtonClicked( state: State ): StepQuizCodeBlanksReducerResult? ReturnCount:StepQuizCodeBlanksReducer.kt$StepQuizCodeBlanksReducer$private fun handleDeleteButtonClicked( state: State ): StepQuizCodeBlanksReducerResult? ReturnCount:StepQuizCodeBlanksReducer.kt$StepQuizCodeBlanksReducer$private fun handleSpaceButtonClicked( state: State ): StepQuizCodeBlanksReducerResult? ReturnCount:StepQuizCodeBlanksReducer.kt$StepQuizCodeBlanksReducer$private fun handleSuggestionClicked( state: State, message: Message.SuggestionClicked ): StepQuizCodeBlanksReducerResult? @@ -299,6 +300,7 @@ UnstableCollections:CommentReactions.kt$List<CommentReaction> UnstableCollections:DebugScreenUI.kt$List<EndpointConfigType> UnstableCollections:LeaderboardList.kt$List<LeaderboardWidgetListItem> + UnstableCollections:PaywallContent.kt$List<PaywallFeature.ViewStateContent.SubscriptionProduct> UnstableCollections:ServerSwitcher.kt$List<EndpointConfigType> UnstableCollections:TopicSearchResultContent.kt$List<Item> UnstableCollections:WelcomeOnboardingChooseProgrammingLanguage.kt$List<WelcomeOnboardingProgrammingLanguage> diff --git a/gradle/app.versions.toml b/gradle/app.versions.toml index 83757f0110..66197c498c 100644 --- a/gradle/app.versions.toml +++ b/gradle/app.versions.toml @@ -2,5 +2,5 @@ minSdk = '24' targetSdk = '34' compileSdk = '34' -versionName = '1.70' -versionCode = '541' \ No newline at end of file +versionName = '1.71' +versionCode = '547' \ No newline at end of file diff --git a/iosHyperskillApp/NotificationServiceExtension/Info.plist b/iosHyperskillApp/NotificationServiceExtension/Info.plist index 5a6418042d..43d555cbd1 100644 --- a/iosHyperskillApp/NotificationServiceExtension/Info.plist +++ b/iosHyperskillApp/NotificationServiceExtension/Info.plist @@ -9,9 +9,9 @@ CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleVersion - 570 + 576 CFBundleShortVersionString - 1.70 + 1.71 CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleExecutable diff --git a/iosHyperskillApp/Podfile.lock b/iosHyperskillApp/Podfile.lock index 9df6b57437..4c649cba3e 100644 --- a/iosHyperskillApp/Podfile.lock +++ b/iosHyperskillApp/Podfile.lock @@ -102,7 +102,8 @@ PODS: - Sentry/Core (8.17.2): - SentryPrivate (= 8.17.2) - SentryPrivate (8.17.2) - - shared (1.0) + - shared (1.0): + - RevenueCat (= 4.41.1) - SkeletonUI (1.0.11) - SnapKit (5.7.1) - STRegex (2.1.1) @@ -241,7 +242,7 @@ SPEC CHECKSUMS: RevenueCat: 1e7be26ae57d83bfd4191c9c4f1a01b3b409b215 Sentry: 64a9f9c3637af913adcf53deced05bbe452d1410 SentryPrivate: 024c6fed507ac39ae98e6d087034160f942920d5 - shared: 210f065b47c10083f8ccc49e665dec6d32c5b2d3 + shared: 347ef463ef2f66af50fc8e59d533b6007bc63518 SkeletonUI: a5514a3877d39f28229c852a567660d0f7542330 SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a STRegex: d49e88d0fe58538d3175fdd989bc1243b9be2a07 diff --git a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj index 79c449b636..aa2b33102c 100644 --- a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj +++ b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj @@ -144,11 +144,14 @@ 2C2D73442B1736E000CBB1DA /* AppTabItemsAvailabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2D73432B1736E000CBB1DA /* AppTabItemsAvailabilityService.swift */; }; 2C2ECCA5288C0661008DDCBA /* StepQuizRetryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2ECCA4288C0661008DDCBA /* StepQuizRetryButton.swift */; }; 2C2ECCA7288C0BF7008DDCBA /* View+ConditionalViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2ECCA6288C0BF7008DDCBA /* View+ConditionalViewModifier.swift */; }; + 2C2F7CFB2C94023100C300B9 /* PaywallSubscriptionProductsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2F7CFA2C94023100C300B9 /* PaywallSubscriptionProductsView.swift */; }; 2C2FD61E28191EC0004E7AF6 /* SentryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2FD61D28191EC0004E7AF6 /* SentryManager.swift */; }; 2C2FD62028191FFE004E7AF6 /* Sentry-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2C2FD61F28191FFE004E7AF6 /* Sentry-Info.plist */; }; 2C2FD622281920B1004E7AF6 /* SentryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2FD621281920B1004E7AF6 /* SentryInfo.swift */; }; 2C2FD62428192123004E7AF6 /* BundlePropertyListDeserializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2FD62328192123004E7AF6 /* BundlePropertyListDeserializer.swift */; }; 2C306A0E29B4590C0068FF4F /* StageImplementFeatureViewStateKsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C306A0D29B4590C0068FF4F /* StageImplementFeatureViewStateKsExtensions.swift */; }; + 2C308B1F2C86E29400E85D14 /* StepQuizCodeBlanksCodeBlocksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C308B1E2C86E29400E85D14 /* StepQuizCodeBlanksCodeBlocksView.swift */; }; + 2C308B212C86E4C200E85D14 /* StepQuizCodeBlanksActionButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C308B202C86E4C200E85D14 /* StepQuizCodeBlanksActionButtonsView.swift */; }; 2C3100532AB194A200C09BFB /* StepQuizParsonsViewDataMapperCodeContentCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C3100522AB194A200C09BFB /* StepQuizParsonsViewDataMapperCodeContentCache.swift */; }; 2C32374D2837F7190062CAF6 /* Images.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C32374C2837F7190062CAF6 /* Images.swift */; }; 2C32375328380C340062CAF6 /* NavigationToolbarInfoItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C32375228380C340062CAF6 /* NavigationToolbarInfoItem.swift */; }; @@ -481,6 +484,7 @@ 2CBD1917291D392400F5FB0B /* UIView+Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CBD1916291D392400F5FB0B /* UIView+Animations.swift */; }; 2CBD1919291D399500F5FB0B /* UIKitBounceButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CBD1918291D399500F5FB0B /* UIKitBounceButton.swift */; }; 2CBD191D291D3BF400F5FB0B /* UIKitRoundedRectangleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CBD191C291D3BF400F5FB0B /* UIKitRoundedRectangleButton.swift */; }; + 2CBEE4C72C870059004486E8 /* StepQuizCodeBlanksIfStatementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CBEE4C62C870059004486E8 /* StepQuizCodeBlanksIfStatementView.swift */; }; 2CBFB94A28897DBB0044D1BA /* StepQuizCodeFullScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CBFB94928897DBB0044D1BA /* StepQuizCodeFullScreenView.swift */; }; 2CBFB94C28897DD70044D1BA /* StepQuizCodeFullScreenAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CBFB94B28897DD70044D1BA /* StepQuizCodeFullScreenAssembly.swift */; }; 2CC4AAF1280DB513002276A0 /* WebOAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC4AAF0280DB513002276A0 /* WebOAuthService.swift */; }; @@ -521,8 +525,8 @@ 2CD48D8E28586B6F00CFCC4A /* StepQuizViewDataMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD48D8D28586B6F00CFCC4A /* StepQuizViewDataMapper.swift */; }; 2CD4EDF92B79D51E0091F0B2 /* View+SafeAreaInset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD4EDF82B79D51E0091F0B2 /* View+SafeAreaInset.swift */; }; 2CD4EDFB2B79D74B0091F0B2 /* TransparentBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD4EDFA2B79D74B0091F0B2 /* TransparentBlurView.swift */; }; - 2CD67CA12C452B2400240C17 /* StepQuizCodeBlanksBlankView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD67CA02C452B2400240C17 /* StepQuizCodeBlanksBlankView.swift */; }; - 2CD67CA32C452DED00240C17 /* StepQuizCodeBlanksOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD67CA22C452DED00240C17 /* StepQuizCodeBlanksOptionView.swift */; }; + 2CD67CA12C452B2400240C17 /* StepQuizCodeBlanksCodeBlockChildBlankView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD67CA02C452B2400240C17 /* StepQuizCodeBlanksCodeBlockChildBlankView.swift */; }; + 2CD67CA32C452DED00240C17 /* StepQuizCodeBlanksCodeBlockChildTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD67CA22C452DED00240C17 /* StepQuizCodeBlanksCodeBlockChildTextView.swift */; }; 2CD7C2D32BFDDC5500DFD5BE /* TopicCompletedModalSpacebotAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD7C2D22BFDDC5500DFD5BE /* TopicCompletedModalSpacebotAvatarView.swift */; }; 2CDA9838294432C900ADE539 /* SkeletonCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDA9837294432C900ADE539 /* SkeletonCircleView.swift */; }; 2CDA98412944512D00ADE539 /* ProfileSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDA98402944512D00ADE539 /* ProfileSkeletonView.swift */; }; @@ -581,6 +585,8 @@ 2CEEE03328916A3D00282849 /* ProblemOfDayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEEE03228916A3D00282849 /* ProblemOfDayViewModel.swift */; }; 2CEEE03528916A6800282849 /* ProblemOfDayOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEEE03428916A6800282849 /* ProblemOfDayOutputProtocol.swift */; }; 2CEEE03728917F1100282849 /* TimeIntervalExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEEE03628917F1100282849 /* TimeIntervalExtensions.swift */; }; + 2CEFEBE22C8AD43F0069567E /* StepQuizCodeBlanksElifStatementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEFEBE12C8AD43F0069567E /* StepQuizCodeBlanksElifStatementView.swift */; }; + 2CEFEBE42C8AD5280069567E /* StepQuizCodeBlanksElseStatementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEFEBE32C8AD5280069567E /* StepQuizCodeBlanksElseStatementView.swift */; }; 2CF0B4E629F9CEAF009C2A2D /* StudyPlanSectionActivitiesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF0B4E529F9CEAF009C2A2D /* StudyPlanSectionActivitiesList.swift */; }; 2CF2DA3A27EC5B2D0055426D /* Assembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF2DA3927EC5B2D0055426D /* Assembly.swift */; }; 2CF34F912C2E8EAE0054477E /* CommentsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF34F902C2E8EAE0054477E /* CommentsContentView.swift */; }; @@ -941,11 +947,14 @@ 2C2D73432B1736E000CBB1DA /* AppTabItemsAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTabItemsAvailabilityService.swift; sourceTree = ""; }; 2C2ECCA4288C0661008DDCBA /* StepQuizRetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizRetryButton.swift; sourceTree = ""; }; 2C2ECCA6288C0BF7008DDCBA /* View+ConditionalViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalViewModifier.swift"; sourceTree = ""; }; + 2C2F7CFA2C94023100C300B9 /* PaywallSubscriptionProductsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallSubscriptionProductsView.swift; sourceTree = ""; }; 2C2FD61D28191EC0004E7AF6 /* SentryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryManager.swift; sourceTree = ""; }; 2C2FD61F28191FFE004E7AF6 /* Sentry-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Sentry-Info.plist"; sourceTree = ""; }; 2C2FD621281920B1004E7AF6 /* SentryInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryInfo.swift; sourceTree = ""; }; 2C2FD62328192123004E7AF6 /* BundlePropertyListDeserializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundlePropertyListDeserializer.swift; sourceTree = ""; }; 2C306A0D29B4590C0068FF4F /* StageImplementFeatureViewStateKsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageImplementFeatureViewStateKsExtensions.swift; sourceTree = ""; }; + 2C308B1E2C86E29400E85D14 /* StepQuizCodeBlanksCodeBlocksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksCodeBlocksView.swift; sourceTree = ""; }; + 2C308B202C86E4C200E85D14 /* StepQuizCodeBlanksActionButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksActionButtonsView.swift; sourceTree = ""; }; 2C3100522AB194A200C09BFB /* StepQuizParsonsViewDataMapperCodeContentCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizParsonsViewDataMapperCodeContentCache.swift; sourceTree = ""; }; 2C32374C2837F7190062CAF6 /* Images.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Images.swift; sourceTree = ""; }; 2C32375228380C340062CAF6 /* NavigationToolbarInfoItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationToolbarInfoItem.swift; sourceTree = ""; }; @@ -1283,6 +1292,7 @@ 2CBD1916291D392400F5FB0B /* UIView+Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Animations.swift"; sourceTree = ""; }; 2CBD1918291D399500F5FB0B /* UIKitBounceButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitBounceButton.swift; sourceTree = ""; }; 2CBD191C291D3BF400F5FB0B /* UIKitRoundedRectangleButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitRoundedRectangleButton.swift; sourceTree = ""; }; + 2CBEE4C62C870059004486E8 /* StepQuizCodeBlanksIfStatementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksIfStatementView.swift; sourceTree = ""; }; 2CBFB94928897DBB0044D1BA /* StepQuizCodeFullScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenView.swift; sourceTree = ""; }; 2CBFB94B28897DD70044D1BA /* StepQuizCodeFullScreenAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenAssembly.swift; sourceTree = ""; }; 2CC4AAF0280DB513002276A0 /* WebOAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebOAuthService.swift; sourceTree = ""; }; @@ -1323,8 +1333,8 @@ 2CD48D8D28586B6F00CFCC4A /* StepQuizViewDataMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizViewDataMapper.swift; sourceTree = ""; }; 2CD4EDF82B79D51E0091F0B2 /* View+SafeAreaInset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+SafeAreaInset.swift"; sourceTree = ""; }; 2CD4EDFA2B79D74B0091F0B2 /* TransparentBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentBlurView.swift; sourceTree = ""; }; - 2CD67CA02C452B2400240C17 /* StepQuizCodeBlanksBlankView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksBlankView.swift; sourceTree = ""; }; - 2CD67CA22C452DED00240C17 /* StepQuizCodeBlanksOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksOptionView.swift; sourceTree = ""; }; + 2CD67CA02C452B2400240C17 /* StepQuizCodeBlanksCodeBlockChildBlankView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksCodeBlockChildBlankView.swift; sourceTree = ""; }; + 2CD67CA22C452DED00240C17 /* StepQuizCodeBlanksCodeBlockChildTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksCodeBlockChildTextView.swift; sourceTree = ""; }; 2CD7C2D22BFDDC5500DFD5BE /* TopicCompletedModalSpacebotAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicCompletedModalSpacebotAvatarView.swift; sourceTree = ""; }; 2CDA9837294432C900ADE539 /* SkeletonCircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCircleView.swift; sourceTree = ""; }; 2CDA98402944512D00ADE539 /* ProfileSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSkeletonView.swift; sourceTree = ""; }; @@ -1384,6 +1394,8 @@ 2CEEE03228916A3D00282849 /* ProblemOfDayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemOfDayViewModel.swift; sourceTree = ""; }; 2CEEE03428916A6800282849 /* ProblemOfDayOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemOfDayOutputProtocol.swift; sourceTree = ""; }; 2CEEE03628917F1100282849 /* TimeIntervalExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeIntervalExtensions.swift; sourceTree = ""; }; + 2CEFEBE12C8AD43F0069567E /* StepQuizCodeBlanksElifStatementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksElifStatementView.swift; sourceTree = ""; }; + 2CEFEBE32C8AD5280069567E /* StepQuizCodeBlanksElseStatementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksElseStatementView.swift; sourceTree = ""; }; 2CF0B4E529F9CEAF009C2A2D /* StudyPlanSectionActivitiesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyPlanSectionActivitiesList.swift; sourceTree = ""; }; 2CF2DA3927EC5B2D0055426D /* Assembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assembly.swift; sourceTree = ""; }; 2CF34F902C2E8EAE0054477E /* CommentsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsContentView.swift; sourceTree = ""; }; @@ -2146,6 +2158,52 @@ path = UIKit; sourceTree = ""; }; + 2C308B192C86E08F00E85D14 /* CodeBlocks */ = { + isa = PBXGroup; + children = ( + 2C308B1E2C86E29400E85D14 /* StepQuizCodeBlanksCodeBlocksView.swift */, + 2C308B1B2C86E17300E85D14 /* ActionButtons */, + 2CBEE4C42C86E955004486E8 /* Children */, + 2CBEE4C52C87003A004486E8 /* Conditions */, + 2C308B1D2C86E20E00E85D14 /* Print */, + 2C308B1C2C86E20600E85D14 /* Variable */, + ); + path = CodeBlocks; + sourceTree = ""; + }; + 2C308B1A2C86E09D00E85D14 /* Suggestions */ = { + isa = PBXGroup; + children = ( + 2C677D012C4A3F860019AF03 /* StepQuizCodeBlanksSuggestionsView.swift */, + ); + path = Suggestions; + sourceTree = ""; + }; + 2C308B1B2C86E17300E85D14 /* ActionButtons */ = { + isa = PBXGroup; + children = ( + 2C008A262C5771350041D8BB /* StepQuizCodeBlanksActionButton.swift */, + 2C308B202C86E4C200E85D14 /* StepQuizCodeBlanksActionButtonsView.swift */, + ); + path = ActionButtons; + sourceTree = ""; + }; + 2C308B1C2C86E20600E85D14 /* Variable */ = { + isa = PBXGroup; + children = ( + 2C3B84E72C637AE100FE9D5C /* StepQuizCodeBlanksVariableInstructionView.swift */, + ); + path = Variable; + sourceTree = ""; + }; + 2C308B1D2C86E20E00E85D14 /* Print */ = { + isa = PBXGroup; + children = ( + 2CB3BC552C46171000F5354F /* StepQuizCodeBlanksPrintInstructionView.swift */, + ); + path = Print; + sourceTree = ""; + }; 2C323750283808300062CAF6 /* View */ = { isa = PBXGroup; children = ( @@ -2483,6 +2541,7 @@ 2C9320F42B68F14100999992 /* PaywallContentView.swift */, 2C7C0D622B6B45A20093609D /* PaywallFeaturesView.swift */, 2C7271272B6B92AD005628B0 /* PaywallFooterView.swift */, + 2C2F7CFA2C94023100C300B9 /* PaywallSubscriptionProductsView.swift */, ); path = Content; sourceTree = ""; @@ -3574,6 +3633,26 @@ path = SwiftUI; sourceTree = ""; }; + 2CBEE4C42C86E955004486E8 /* Children */ = { + isa = PBXGroup; + children = ( + 2CD67CA02C452B2400240C17 /* StepQuizCodeBlanksCodeBlockChildBlankView.swift */, + 2CD67CA22C452DED00240C17 /* StepQuizCodeBlanksCodeBlockChildTextView.swift */, + 2CE7AA1A2C7C4255000ABCD7 /* StepQuizCodeBlanksCodeBlockChildView.swift */, + ); + path = Children; + sourceTree = ""; + }; + 2CBEE4C52C87003A004486E8 /* Conditions */ = { + isa = PBXGroup; + children = ( + 2CEFEBE12C8AD43F0069567E /* StepQuizCodeBlanksElifStatementView.swift */, + 2CEFEBE32C8AD5280069567E /* StepQuizCodeBlanksElseStatementView.swift */, + 2CBEE4C62C870059004486E8 /* StepQuizCodeBlanksIfStatementView.swift */, + ); + path = Conditions; + sourceTree = ""; + }; 2CBFB94828897D970044D1BA /* StepQuizCodeFullScreen */ = { isa = PBXGroup; children = ( @@ -3741,14 +3820,9 @@ 2CD67C9D2C451B0200240C17 /* Views */ = { isa = PBXGroup; children = ( - 2C008A262C5771350041D8BB /* StepQuizCodeBlanksActionButton.swift */, - 2CD67CA02C452B2400240C17 /* StepQuizCodeBlanksBlankView.swift */, - 2CE7AA1A2C7C4255000ABCD7 /* StepQuizCodeBlanksCodeBlockChildView.swift */, - 2CD67CA22C452DED00240C17 /* StepQuizCodeBlanksOptionView.swift */, - 2CB3BC552C46171000F5354F /* StepQuizCodeBlanksPrintInstructionView.swift */, - 2C677D012C4A3F860019AF03 /* StepQuizCodeBlanksSuggestionsView.swift */, - 2C3B84E72C637AE100FE9D5C /* StepQuizCodeBlanksVariableInstructionView.swift */, 2C84E70B2C47BAB6002EE787 /* StepQuizCodeBlanksView.swift */, + 2C308B192C86E08F00E85D14 /* CodeBlocks */, + 2C308B1A2C86E09D00E85D14 /* Suggestions */, ); path = Views; sourceTree = ""; @@ -5057,6 +5131,7 @@ E9D2D675284E0B30000757AC /* StepQuizMatchingView.swift in Sources */, 2CBC97CD2A555AA20078E445 /* StageImplementProjectCompletedModalView.swift in Sources */, 2CC95C0E2A4EBB970036C73E /* ProjectLevelAvatarView.swift in Sources */, + 2CBEE4C72C870059004486E8 /* StepQuizCodeBlanksIfStatementView.swift in Sources */, 2C93C2D4292E5905004D1861 /* HyperskillSentryBreadcrumb+SentryBreadcrumb.swift in Sources */, 2C306A0E29B4590C0068FF4F /* StageImplementFeatureViewStateKsExtensions.swift in Sources */, 2CA7B892289329C600A789EF /* UIView+FindViewController.swift in Sources */, @@ -5169,6 +5244,7 @@ 2C0DB90728644F2C001EA35E /* CodeEditorView.swift in Sources */, 2CDA98452944590800ADE539 /* ProfileStatisticsView.swift in Sources */, 2C8DD40E2AFB907000FD5359 /* ShareStreakAction.swift in Sources */, + 2CEFEBE22C8AD43F0069567E /* StepQuizCodeBlanksElifStatementView.swift in Sources */, 2C7CB6822ADFDB45006F78DA /* UIFont+SizeOfString.swift in Sources */, 2CC4AAF1280DB513002276A0 /* WebOAuthService.swift in Sources */, 2CF2DA3A27EC5B2D0055426D /* Assembly.swift in Sources */, @@ -5187,7 +5263,7 @@ E9A022AE291D0E3F004317DB /* TopicsRepetitionsCardView.swift in Sources */, 2C5CBBE32948F4B600113007 /* StepQuizSQLViewModel.swift in Sources */, E9F923F628A2633D00C065A7 /* WelcomeView.swift in Sources */, - 2CD67CA32C452DED00240C17 /* StepQuizCodeBlanksOptionView.swift in Sources */, + 2CD67CA32C452DED00240C17 /* StepQuizCodeBlanksCodeBlockChildTextView.swift in Sources */, E9BDB4052A7BE1E30069EF98 /* BadgeImageView.swift in Sources */, 2C7FE8A52B98261600F09615 /* PurchaseManager.swift in Sources */, 2C106D9928C1CE6E004FA584 /* SendEmailFeedbackController.swift in Sources */, @@ -5304,7 +5380,7 @@ 2CB9537E2AF2474100CA64BA /* StepQuizHintsFeatureStateKsExtensions.swift in Sources */, 2C963BCA2812D3550036DD53 /* ProfileSettingsView.swift in Sources */, 2C772E7D28ABB4E500A58758 /* AppleIDSocialAuthSDKProvider.swift in Sources */, - 2CD67CA12C452B2400240C17 /* StepQuizCodeBlanksBlankView.swift in Sources */, + 2CD67CA12C452B2400240C17 /* StepQuizCodeBlanksCodeBlockChildBlankView.swift in Sources */, 2C963BCC2812D9330036DD53 /* ProfileSettingsAssembly.swift in Sources */, E9470C6B29810AB7008ACF9A /* StepQuizOutputProtocol.swift in Sources */, 2C079687285CFFF500EE0487 /* StepQuizSortingAssembly.swift in Sources */, @@ -5358,6 +5434,7 @@ 2CF34F9D2C340DB60054477E /* CommentsSkeletonView.swift in Sources */, E9D537D02A71056100F21828 /* ProfileBadgesGridItemView.swift in Sources */, 2CB0ADEE2B04AD6D0089D557 /* ChallengeWidgetViewModel.swift in Sources */, + 2C308B212C86E4C200E85D14 /* StepQuizCodeBlanksActionButtonsView.swift in Sources */, 2CACBCC22B7A3E4E006D3AB2 /* UsersInterviewWidgetAssembly.swift in Sources */, E9CC6C0729893F2200D8D070 /* StepQuizInputProtocol.swift in Sources */, 2C96743728882A0C0091B6C9 /* StepQuizCodeDetailsView.swift in Sources */, @@ -5454,6 +5531,7 @@ E9FB89AC2893EA580011EFFB /* NotificationPermissionStatus.swift in Sources */, 2C4FBD8C2876C39C00ACA5C8 /* ProfileAboutView.swift in Sources */, 2C023C88285D928100D2D5A9 /* StepQuizTableViewModel.swift in Sources */, + 2C2F7CFB2C94023100C300B9 /* PaywallSubscriptionProductsView.swift in Sources */, E99B21872887E9C5006A6154 /* StepQuizSortingSkeletonView.swift in Sources */, E98BE36D2A374394000B430F /* StreakRecoveryModalView.swift in Sources */, 2CAE8D0C2805829A00E6C83D /* StepViewData.swift in Sources */, @@ -5485,6 +5563,7 @@ E94BB0482A9DF9DD00736B7C /* StepQuizParsonsView.swift in Sources */, E99CCB0B287E945300898BBF /* HomeViewModel.swift in Sources */, 2C7CB6782ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift in Sources */, + 2C308B1F2C86E29400E85D14 /* StepQuizCodeBlanksCodeBlocksView.swift in Sources */, 2C0FA879292FD73400A37636 /* ProfileSettingsFeatureViewStateKsExtensions.swift in Sources */, 2C1061AA285C3C3300EBD614 /* StepQuizChoiceAssembly.swift in Sources */, 2CF72AA828477E0600E1C192 /* StepQuizTableRowView.swift in Sources */, @@ -5515,6 +5594,7 @@ 2C93AF2529B34FE6004639E0 /* StepQuizPyCharmAssembly.swift in Sources */, 2CDA98412944512D00ADE539 /* ProfileSkeletonView.swift in Sources */, 2CEDE70729965B4D0032D399 /* RestartApplicationLocalNotification.swift in Sources */, + 2CEFEBE42C8AD5280069567E /* StepQuizCodeBlanksElseStatementView.swift in Sources */, 2CACBCBC2B7A12F1006D3AB2 /* UsersInterviewWidgetView.swift in Sources */, 2CDA98432944524D00ADE539 /* HomeSkeletonView.swift in Sources */, 2C9D493D29F07015000599AB /* StudyPlanSectionErrorView.swift in Sources */, @@ -5693,7 +5773,7 @@ buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 570; + CURRENT_PROJECT_VERSION = 576; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = UJ4KC2QN7B; GENERATE_INFOPLIST_FILE = NO; @@ -5714,7 +5794,7 @@ buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 570; + CURRENT_PROJECT_VERSION = 576; DEVELOPMENT_TEAM = UJ4KC2QN7B; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = iosHyperskillAppUITests/Info.plist; @@ -5735,7 +5815,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 570; + CURRENT_PROJECT_VERSION = 576; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = UJ4KC2QN7B; INFOPLIST_FILE = iosHyperskillAppTests/Info.plist; @@ -5756,7 +5836,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 570; + CURRENT_PROJECT_VERSION = 576; DEVELOPMENT_TEAM = UJ4KC2QN7B; INFOPLIST_FILE = iosHyperskillAppTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -5777,7 +5857,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 570; + CURRENT_PROJECT_VERSION = 576; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = UJ4KC2QN7B; INFOPLIST_FILE = NotificationServiceExtension/Info.plist; @@ -5806,7 +5886,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 570; + CURRENT_PROJECT_VERSION = 576; DEVELOPMENT_TEAM = UJ4KC2QN7B; INFOPLIST_FILE = NotificationServiceExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -5952,7 +6032,7 @@ CODE_SIGN_ENTITLEMENTS = iosHyperskillApp/iosHyperskillApp.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 570; + CURRENT_PROJECT_VERSION = 576; DEVELOPMENT_ASSET_PATHS = "\"iosHyperskillApp/Preview Content\""; DEVELOPMENT_TEAM = UJ4KC2QN7B; ENABLE_PREVIEWS = YES; @@ -5988,7 +6068,7 @@ CODE_SIGN_ENTITLEMENTS = iosHyperskillApp/iosHyperskillApp.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 570; + CURRENT_PROJECT_VERSION = 576; DEVELOPMENT_ASSET_PATHS = "\"iosHyperskillApp/Preview Content\""; DEVELOPMENT_TEAM = UJ4KC2QN7B; ENABLE_PREVIEWS = YES; diff --git a/iosHyperskillApp/iosHyperskillApp/Info.plist b/iosHyperskillApp/iosHyperskillApp/Info.plist index 827ca6db12..cf90b39b32 100644 --- a/iosHyperskillApp/iosHyperskillApp/Info.plist +++ b/iosHyperskillApp/iosHyperskillApp/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.70 + 1.71 CFBundleURLTypes @@ -34,7 +34,7 @@ CFBundleVersion - 570 + 576 FirebaseAppDelegateProxyEnabled FirebaseMessagingAutoInitEnabled diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/BlockOptionsExtensions.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/BlockOptionsExtensions.swift index 6548c21f71..86f0364e31 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/BlockOptionsExtensions.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/BlockOptionsExtensions.swift @@ -14,7 +14,8 @@ extension Block.Options { codeBlanksStrings: [String]? = nil, codeBlanksVariables: [String]? = nil, codeBlanksOperations: [String]? = nil, - codeBlanksEnabled: Bool? = nil + codeBlanksEnabled: Bool? = nil, + codeBlanksTemplate: [CodeBlockTemplateEntry]? = nil ) { self.init( isMultipleChoice: isMultipleChoice.flatMap(KotlinBoolean.init(value:)), @@ -27,7 +28,8 @@ extension Block.Options { codeBlanksStrings: codeBlanksStrings, codeBlanksVariables: codeBlanksVariables, codeBlanksOperations: codeBlanksOperations, - codeBlanksEnabled: codeBlanksEnabled.flatMap(KotlinBoolean.init(value:)) + codeBlanksEnabled: codeBlanksEnabled.flatMap(KotlinBoolean.init(value:)), + codeBlanksTemplate: codeBlanksTemplate ) } // swiftlint:enable discouraged_optional_boolean diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift index 8210c00547..20e8bb9228 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift @@ -646,6 +646,9 @@ enum Strings { static let subscriptionFeature1 = sharedStrings.mobile_only_subscription_feature_1.localized() static let subscriptionFeature2 = sharedStrings.mobile_only_subscription_feature_2.localized() static let subscriptionFeature3 = sharedStrings.mobile_only_subscription_feature_3.localized() + static let subscriptionFeature4 = sharedStrings.mobile_only_subscription_feature_4.localized() + + static let bestValueBadge = sharedStrings.paywall_best_value_label.localized() } // MARK: - ManageSubscription - diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/PaywallViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/PaywallViewModel.swift index ce34696c98..f083c0b274 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/PaywallViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/PaywallViewModel.swift @@ -6,6 +6,8 @@ final class PaywallViewModel: FeatureViewModel< PaywallFeatureMessage, PaywallFeatureActionViewAction > { + private let selectionFeedbackGenerator = FeedbackGenerator(feedbackType: .selection) + var contentStateKs: PaywallFeatureViewStateContentKs { .init(state.contentState) } init(feature: Presentation_reduxFeature) { @@ -33,6 +35,12 @@ final class PaywallViewModel: FeatureViewModel< onNewMessage(PaywallFeatureMessageRetryContentLoading()) } + @MainActor + func doSubscriptionProductAction(product: PaywallFeatureViewStateContentSubscriptionProduct) { + selectionFeedbackGenerator.triggerFeedback() + onNewMessage(PaywallFeatureMessageProductClicked(productId: product.productId)) + } + func doBuySubscription() { onNewMessage(PaywallFeatureMessageBuySubscriptionClicked(purchaseParams: PlatformPurchaseParams())) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallContentView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallContentView.swift index f4ce0b73c4..30565a7a3a 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallContentView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallContentView.swift @@ -1,3 +1,4 @@ +import shared import SwiftUI extension PaywallContentView { @@ -14,9 +15,10 @@ extension PaywallContentView { struct PaywallContentView: View { private(set) var appearance = Appearance() + let subscriptionProducts: [PaywallFeatureViewStateContentSubscriptionProduct] let buyButtonText: String - let buyFootnoteText: String? + let onSubscriptionProductTap: (PaywallFeatureViewStateContentSubscriptionProduct) -> Void let onBuyButtonTap: () -> Void let onTermsOfServiceButtonTap: () -> Void @@ -50,6 +52,13 @@ struct PaywallContentView: View { PaywallFeaturesView( appearance: .init(spacing: appearance.interitemSpacing) ) + + PaywallSubscriptionProductsView( + appearance: .init(spacing: appearance.interitemSpacing), + subscriptionProducts: subscriptionProducts, + onTap: onSubscriptionProductTap + ) + .padding(.top) } .padding(appearance.padding) } @@ -57,7 +66,6 @@ struct PaywallContentView: View { PaywallFooterView( appearance: .init(spacing: appearance.interitemSpacing), buyButtonText: buyButtonText, - buyFootnoteText: buyFootnoteText, onBuyButtonTap: onBuyButtonTap, onTermsOfServiceButtonTap: onTermsOfServiceButtonTap ) @@ -68,17 +76,24 @@ struct PaywallContentView: View { #if DEBUG #Preview { PaywallContentView( - buyButtonText: "Subscribe for $11.99/month", - buyFootnoteText: nil, - onBuyButtonTap: {}, - onTermsOfServiceButtonTap: {} - ) -} - -#Preview { - PaywallContentView( - buyButtonText: "Subscribe for $11.99/month", - buyFootnoteText: "Then $11.99 per month", + subscriptionProducts: [ + .init( + productId: "1", + title: "Monthly Subscription", + subtitle: "$11.99 / month", + isBestValue: false, + isSelected: false + ), + .init( + productId: "2", + title: "Yearly Subscription", + subtitle: "$99.99 / year", + isBestValue: true, + isSelected: true + ) + ], + buyButtonText: "Start now", + onSubscriptionProductTap: { _ in }, onBuyButtonTap: {}, onTermsOfServiceButtonTap: {} ) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallFeaturesView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallFeaturesView.swift index e1718fbf12..434d368ff9 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallFeaturesView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallFeaturesView.swift @@ -10,7 +10,8 @@ struct PaywallFeaturesView: View { private static let features = [ Strings.Paywall.subscriptionFeature1, Strings.Paywall.subscriptionFeature2, - Strings.Paywall.subscriptionFeature3 + Strings.Paywall.subscriptionFeature3, + Strings.Paywall.subscriptionFeature4 ] private(set) var appearance = Appearance() @@ -42,6 +43,7 @@ private struct PaywallFeatureView: View { Label( title: { Text(title) + .foregroundColor(.newPrimaryText) .offset(x: !animateTitle ? -width : 0) .clipped() }, diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallFooterView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallFooterView.swift index 2163b0d0ec..a0a368eb7e 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallFooterView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallFooterView.swift @@ -3,7 +3,6 @@ import SwiftUI extension PaywallFooterView { struct Appearance { var spacing = LayoutInsets.defaultInset - var interitemSpacing = LayoutInsets.smallInset } } @@ -11,7 +10,6 @@ struct PaywallFooterView: View { private(set) var appearance = Appearance() let buyButtonText: String - let buyFootnoteText: String? let onBuyButtonTap: () -> Void let onTermsOfServiceButtonTap: () -> Void @@ -30,23 +28,15 @@ struct PaywallFooterView: View { @MainActor private var content: some View { VStack(alignment: .center, spacing: appearance.spacing) { - VStack(alignment: .center, spacing: appearance.interitemSpacing) { - Button( - buyButtonText, - action: { - feedbackGenerator.triggerFeedback() - onBuyButtonTap() - } - ) - .buttonStyle(.primary) - .shineEffect() - - if let buyFootnoteText { - Text(buyFootnoteText) - .font(.footnote.bold()) - .foregroundColor(.newSecondaryText) + Button( + buyButtonText, + action: { + feedbackGenerator.triggerFeedback() + onBuyButtonTap() } - } + ) + .buttonStyle(.primary) + .shineEffect() Button( Strings.Paywall.termsOfServiceButton, @@ -63,20 +53,10 @@ struct PaywallFooterView: View { #if DEBUG #Preview { - VStack { - PaywallFooterView( - buyButtonText: "Subscribe for $11.99/month", - buyFootnoteText: nil, - onBuyButtonTap: {}, - onTermsOfServiceButtonTap: {} - ) - - PaywallFooterView( - buyButtonText: "Subscribe for $11.99/month", - buyFootnoteText: "Then $11.99 per month", - onBuyButtonTap: {}, - onTermsOfServiceButtonTap: {} - ) - } + PaywallFooterView( + buyButtonText: "Subscribe for $11.99/month", + onBuyButtonTap: {}, + onTermsOfServiceButtonTap: {} + ) } #endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallSubscriptionProductsView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallSubscriptionProductsView.swift new file mode 100644 index 0000000000..afbeefe8cb --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/Content/PaywallSubscriptionProductsView.swift @@ -0,0 +1,120 @@ +import shared +import SwiftUI + +extension PaywallSubscriptionProductsView { + struct Appearance { + var spacing = LayoutInsets.smallInset + + let padding = LayoutInsets.defaultInset + + let badgeInsets = LayoutInsets(horizontal: 8, vertical: 4) + let badgeFont = UIFont.preferredFont(forTextStyle: .footnote) + + func badgeTopOffset() -> CGFloat { + badgeFont.pointSize / 2.0 + badgeInsets.top + } + } +} + +struct PaywallSubscriptionProductsView: View { + private(set) var appearance = Appearance() + + let subscriptionProducts: [PaywallFeatureViewStateContentSubscriptionProduct] + + let onTap: (PaywallFeatureViewStateContentSubscriptionProduct) -> Void + + var body: some View { + VStack(alignment: .center, spacing: appearance.spacing) { + ForEach( + Array(subscriptionProducts.enumerated()), + id: \.element.productId + ) { index, product in + buildProductView( + product: product, + action: { + onTap(product) + } + ) + .padding(.top, product.isBestValue && index > 0 ? appearance.spacing : 0) + } + } + } + + private var bestValueBadgeView: some View { + Text(Strings.Paywall.bestValueBadge) + .font(Font(appearance.badgeFont)) + .foregroundColor(Color(ColorPalette.onPrimary)) + .padding(appearance.badgeInsets.edgeInsets) + .background(Color(ColorPalette.primary)) + .clipShape(Capsule()) + .fixedSize() + } + + private func buildProductView( + product: PaywallFeatureViewStateContentSubscriptionProduct, + action: @escaping () -> Void + ) -> some View { + Button( + action: action, + label: { + HStack(alignment: .center, spacing: 0) { + Text(product.title) + .font(.body.bold()) + + Spacer() + + Text(product.subtitle) + .font(.body) + } + .foregroundColor(.newPrimaryText) + .padding(.horizontal, appearance.padding) + .padding(.vertical, product.isSelected ? appearance.padding * 2 : appearance.padding) + .conditionalOpacity(isEnabled: product.isSelected) + .addBorder( + color: product.isSelected ? Color(ColorPalette.primary) : .border, + width: product.isSelected ? 2 : 1 + ) + .animation(.default, value: product.isSelected) + .overlay( + bestValueBadgeView + .opacity(product.isBestValue ? 1 : 0) + .alignmentGuide(.top, computeValue: { dimension in + dimension[.top] + appearance.badgeTopOffset() + }) + .alignmentGuide(.trailing, computeValue: { dimension in + dimension[.trailing] - appearance.badgeInsets.trailing + }) + , + alignment: .init(horizontal: .trailing, vertical: .top) + ) + } + ) + } +} + +#if DEBUG +#Preview { + VStack { + PaywallSubscriptionProductsView( + subscriptionProducts: [ + .init( + productId: "1", + title: "Monthly Subscription", + subtitle: "$11.99 / month", + isBestValue: false, + isSelected: false + ), + .init( + productId: "2", + title: "Yearly Subscription", + subtitle: "$99.99 / year", + isBestValue: true, + isSelected: true + ) + ], + onTap: { _ in } + ) + } + .padding() +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/PaywallView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/PaywallView.swift index ece8d6a376..4b0981a2d2 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/PaywallView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Paywall/Views/PaywallView.swift @@ -53,8 +53,9 @@ struct PaywallView: View { ) case .content(let content): PaywallContentView( + subscriptionProducts: content.subscriptionProducts, buyButtonText: content.buyButtonText, - buyFootnoteText: content.trialText, + onSubscriptionProductTap: viewModel.doSubscriptionProductAction(product:), onBuyButtonTap: viewModel.doBuySubscription, onTermsOfServiceButtonTap: viewModel.doTermsOfServicePresentation ) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift index 908b44506d..a862a41c2d 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift @@ -285,6 +285,14 @@ extension StepQuizViewModel: StepQuizCodeBlanksOutputProtocol { ) ) } + + func handleStepQuizCodeBlanksDidTapDecreaseIndentLevel() { + onNewMessage( + StepQuizFeatureMessageStepQuizCodeBlanksMessage( + message: StepQuizCodeBlanksFeatureMessageDecreaseIndentLevelButtonClicked() + ) + ) + } } // MARK: - StepQuizViewModel: StepQuizProblemOnboardingModalViewControllerDelegate - diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksOutputProtocol.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksOutputProtocol.swift index 12cd5561ac..363b034df2 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksOutputProtocol.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksOutputProtocol.swift @@ -11,4 +11,5 @@ protocol StepQuizCodeBlanksOutputProtocol: AnyObject { func handleStepQuizCodeBlanksDidTapDelete() func handleStepQuizCodeBlanksDidTapEnter() func handleStepQuizCodeBlanksDidTapSpace() + func handleStepQuizCodeBlanksDidTapDecreaseIndentLevel() } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksViewModel.swift index 860a135475..379445416f 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksViewModel.swift @@ -48,4 +48,10 @@ final class StepQuizCodeBlanksViewModel { impactFeedbackGenerator.triggerFeedback() moduleOutput?.handleStepQuizCodeBlanksDidTapSpace() } + + @MainActor + func doDecreaseIndentLevelAction() { + impactFeedbackGenerator.triggerFeedback() + moduleOutput?.handleStepQuizCodeBlanksDidTapDecreaseIndentLevel() + } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksActionButton.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/ActionButtons/StepQuizCodeBlanksActionButton.swift similarity index 80% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksActionButton.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/ActionButtons/StepQuizCodeBlanksActionButton.swift index 524220ab57..00744ba2e1 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksActionButton.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/ActionButtons/StepQuizCodeBlanksActionButton.swift @@ -62,6 +62,19 @@ extension StepQuizCodeBlanksActionButton { action: action ) } + + static func decreaseIndentLevel(action: @escaping () -> Void) -> StepQuizCodeBlanksActionButton { + StepQuizCodeBlanksActionButton( + appearance: .init( + padding: LayoutInsets( + horizontal: LayoutInsets.smallInset, + vertical: 5.5 + ) + ), + imageSystemName: "arrow.left.to.line", + action: action + ) + } } #if DEBUG @@ -71,12 +84,14 @@ extension StepQuizCodeBlanksActionButton { StepQuizCodeBlanksActionButton.delete(action: {}) StepQuizCodeBlanksActionButton.enter(action: {}) StepQuizCodeBlanksActionButton.space(action: {}) + StepQuizCodeBlanksActionButton.decreaseIndentLevel(action: {}) } HStack { StepQuizCodeBlanksActionButton.delete(action: {}) StepQuizCodeBlanksActionButton.enter(action: {}) StepQuizCodeBlanksActionButton.space(action: {}) + StepQuizCodeBlanksActionButton.decreaseIndentLevel(action: {}) } .disabled(true) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/ActionButtons/StepQuizCodeBlanksActionButtonsView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/ActionButtons/StepQuizCodeBlanksActionButtonsView.swift new file mode 100644 index 0000000000..f6a0417012 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/ActionButtons/StepQuizCodeBlanksActionButtonsView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct StepQuizCodeBlanksActionButtonsView: View { + let isDeleteButtonEnabled: Bool + let isSpaceButtonHidden: Bool + let isDecreaseIndentLevelButtonHidden: Bool + + let onSpaceTap: () -> Void + let onDeleteTap: () -> Void + let onEnterTap: () -> Void + let onDecreaseIndentLevelTap: () -> Void + + var body: some View { + HStack(spacing: LayoutInsets.defaultInset) { + Spacer() + + if !isDecreaseIndentLevelButtonHidden { + StepQuizCodeBlanksActionButton + .decreaseIndentLevel(action: onDecreaseIndentLevelTap) + } + + if !isSpaceButtonHidden { + StepQuizCodeBlanksActionButton + .space(action: onSpaceTap) + } + + StepQuizCodeBlanksActionButton + .delete(action: onDeleteTap) + .disabled(!isDeleteButtonEnabled) + + StepQuizCodeBlanksActionButton + .enter(action: onEnterTap) + } + .padding(.horizontal) + } +} + +#if DEBUG +#Preview { + VStack { + StepQuizCodeBlanksActionButtonsView( + isDeleteButtonEnabled: false, + isSpaceButtonHidden: false, + isDecreaseIndentLevelButtonHidden: false, + onSpaceTap: {}, + onDeleteTap: {}, + onEnterTap: {}, + onDecreaseIndentLevelTap: {} + ) + + StepQuizCodeBlanksActionButtonsView( + isDeleteButtonEnabled: true, + isSpaceButtonHidden: true, + isDecreaseIndentLevelButtonHidden: true, + onSpaceTap: {}, + onDeleteTap: {}, + onEnterTap: {}, + onDecreaseIndentLevelTap: {} + ) + } + .padding() +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksBlankView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Children/StepQuizCodeBlanksCodeBlockChildBlankView.swift similarity index 77% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksBlankView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Children/StepQuizCodeBlanksCodeBlockChildBlankView.swift index e0418f71ef..508a77bab9 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksBlankView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Children/StepQuizCodeBlanksCodeBlockChildBlankView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct StepQuizCodeBlanksBlankView: View { +struct StepQuizCodeBlanksCodeBlockChildBlankView: View { var width: CGFloat = 208 var height: CGFloat = 48 @@ -17,7 +17,7 @@ struct StepQuizCodeBlanksBlankView: View { } } -extension StepQuizCodeBlanksBlankView { +extension StepQuizCodeBlanksCodeBlockChildBlankView { init(style: Style, isActive: Bool) { let size = style.size self.init(width: size.width, height: size.height, isActive: isActive) @@ -41,8 +41,8 @@ extension StepQuizCodeBlanksBlankView { #if DEBUG #Preview { VStack { - StepQuizCodeBlanksBlankView(style: .small, isActive: true) - StepQuizCodeBlanksBlankView(style: .large, isActive: false) + StepQuizCodeBlanksCodeBlockChildBlankView(style: .small, isActive: true) + StepQuizCodeBlanksCodeBlockChildBlankView(style: .large, isActive: false) } } #endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksOptionView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Children/StepQuizCodeBlanksCodeBlockChildTextView.swift similarity index 69% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksOptionView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Children/StepQuizCodeBlanksCodeBlockChildTextView.swift index 5d011735d2..832fd731fb 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksOptionView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Children/StepQuizCodeBlanksCodeBlockChildTextView.swift @@ -1,6 +1,6 @@ import SwiftUI -extension StepQuizCodeBlanksOptionView { +extension StepQuizCodeBlanksCodeBlockChildTextView { enum Appearance { static let insets = LayoutInsets(horizontal: 12, vertical: LayoutInsets.smallInset) static let minWidth: CGFloat = 48 @@ -9,7 +9,7 @@ extension StepQuizCodeBlanksOptionView { } } -struct StepQuizCodeBlanksOptionView: View { +struct StepQuizCodeBlanksCodeBlockChildTextView: View { let text: String let isActive: Bool @@ -31,9 +31,9 @@ struct StepQuizCodeBlanksOptionView: View { #if DEBUG #Preview { VStack { - StepQuizCodeBlanksOptionView(text: "print", isActive: false) - StepQuizCodeBlanksOptionView(text: "There is a cat on the keyboard, it is true", isActive: true) - StepQuizCodeBlanksOptionView(text: "Typing messages out of the blue", isActive: true) + StepQuizCodeBlanksCodeBlockChildTextView(text: "print", isActive: false) + StepQuizCodeBlanksCodeBlockChildTextView(text: "There is a cat on the keyboard, it is true", isActive: true) + StepQuizCodeBlanksCodeBlockChildTextView(text: "Typing messages out of the blue", isActive: true) } } #endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksCodeBlockChildView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Children/StepQuizCodeBlanksCodeBlockChildView.swift similarity index 74% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksCodeBlockChildView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Children/StepQuizCodeBlanksCodeBlockChildView.swift index ef8b7d51fc..6f45335c45 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksCodeBlockChildView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Children/StepQuizCodeBlanksCodeBlockChildView.swift @@ -18,9 +18,9 @@ struct StepQuizCodeBlanksCodeBlockChildView: View { child: StepQuizCodeBlanksViewStateCodeBlockChildItem ) -> some View { if let value = child.value { - StepQuizCodeBlanksOptionView(text: value, isActive: child.isActive) + StepQuizCodeBlanksCodeBlockChildTextView(text: value, isActive: child.isActive) } else { - StepQuizCodeBlanksBlankView(style: .small, isActive: child.isActive) + StepQuizCodeBlanksCodeBlockChildBlankView(style: .small, isActive: child.isActive) } } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Conditions/StepQuizCodeBlanksElifStatementView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Conditions/StepQuizCodeBlanksElifStatementView.swift new file mode 100644 index 0000000000..8f4c66aec9 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Conditions/StepQuizCodeBlanksElifStatementView.swift @@ -0,0 +1,73 @@ +import shared +import SwiftUI + +struct StepQuizCodeBlanksElifStatementView: View { + let elifStatementItem: StepQuizCodeBlanksViewStateCodeBlockItemElifStatement + + let onChildTap: (StepQuizCodeBlanksViewStateCodeBlockChildItem) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .center, spacing: LayoutInsets.smallInset) { + Text("elif") + .font(StepQuizCodeBlanksAppearance.blankFont) + .foregroundColor(StepQuizCodeBlanksAppearance.blankTextColor) + + ForEach(elifStatementItem.children, id: \.id) { child in + StepQuizCodeBlanksCodeBlockChildView(child: child, action: onChildTap) + } + + Text(":") + .font(StepQuizCodeBlanksAppearance.blankFont) + .foregroundColor(StepQuizCodeBlanksAppearance.blankTextColor) + } + .padding(.horizontal, LayoutInsets.defaultInset) + .padding(.vertical, LayoutInsets.smallInset) + .background(Color(ColorPalette.violet400Alpha7)) + .cornerRadius(StepQuizCodeBlanksAppearance.cornerRadius) + .padding(.horizontal) + } + .scrollBounceBehaviorBasedOnSize(axes: .horizontal) + } +} + +#if DEBUG +#Preview { + VStack { + StepQuizCodeBlanksElifStatementView( + elifStatementItem: StepQuizCodeBlanksViewStateCodeBlockItemElifStatement( + id: 0, + indentLevel: 0, + children: [ + StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 0, isActive: true, value: nil) + ] + ), + onChildTap: { _ in } + ) + + StepQuizCodeBlanksElifStatementView( + elifStatementItem: StepQuizCodeBlanksViewStateCodeBlockItemElifStatement( + id: 0, + indentLevel: 0, + children: [ + StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 0, isActive: true, value: "x") + ] + ), + onChildTap: { _ in } + ) + + StepQuizCodeBlanksElifStatementView( + elifStatementItem: StepQuizCodeBlanksViewStateCodeBlockItemElifStatement( + id: 0, + indentLevel: 0, + children: [ + StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 0, isActive: false, value: "x"), + StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 1, isActive: true, value: nil) + ] + ), + onChildTap: { _ in } + ) + } + .padding() +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Conditions/StepQuizCodeBlanksElseStatementView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Conditions/StepQuizCodeBlanksElseStatementView.swift new file mode 100644 index 0000000000..91a75e90e2 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Conditions/StepQuizCodeBlanksElseStatementView.swift @@ -0,0 +1,45 @@ +import shared +import SwiftUI + +struct StepQuizCodeBlanksElseStatementView: View { + let elseStatementItem: StepQuizCodeBlanksViewStateCodeBlockItemElseStatement + + var body: some View { + Text("else:") + .font(StepQuizCodeBlanksAppearance.blankFont) + .foregroundColor(StepQuizCodeBlanksAppearance.blankTextColor) + .padding(.horizontal, LayoutInsets.defaultInset) + .padding(.vertical, LayoutInsets.smallInset) + .frame(minHeight: StepQuizCodeBlanksCodeBlockChildTextView.Appearance.minHeight) + .background(Color(ColorPalette.violet400Alpha7)) + .addBorder( + color: elseStatementItem.isActive ? StepQuizCodeBlanksAppearance.activeBorderColor : .clear, + width: elseStatementItem.isActive ? 1 : 0, + cornerRadius: StepQuizCodeBlanksAppearance.cornerRadius + ) + .padding(.horizontal) + .animation(.default, value: elseStatementItem.isActive) + } +} + +#if DEBUG +#Preview { + StepQuizCodeBlanksElseStatementView( + elseStatementItem: StepQuizCodeBlanksViewStateCodeBlockItemElseStatement( + id: 0, + indentLevel: 0, + isActive: true + ) + ) +} + +#Preview { + StepQuizCodeBlanksElseStatementView( + elseStatementItem: StepQuizCodeBlanksViewStateCodeBlockItemElseStatement( + id: 0, + indentLevel: 0, + isActive: false + ) + ) +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Conditions/StepQuizCodeBlanksIfStatementView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Conditions/StepQuizCodeBlanksIfStatementView.swift new file mode 100644 index 0000000000..3def9b2ccc --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Conditions/StepQuizCodeBlanksIfStatementView.swift @@ -0,0 +1,73 @@ +import shared +import SwiftUI + +struct StepQuizCodeBlanksIfStatementView: View { + let ifStatementItem: StepQuizCodeBlanksViewStateCodeBlockItemIfStatement + + let onChildTap: (StepQuizCodeBlanksViewStateCodeBlockChildItem) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .center, spacing: LayoutInsets.smallInset) { + Text("if") + .font(StepQuizCodeBlanksAppearance.blankFont) + .foregroundColor(StepQuizCodeBlanksAppearance.blankTextColor) + + ForEach(ifStatementItem.children, id: \.id) { child in + StepQuizCodeBlanksCodeBlockChildView(child: child, action: onChildTap) + } + + Text(":") + .font(StepQuizCodeBlanksAppearance.blankFont) + .foregroundColor(StepQuizCodeBlanksAppearance.blankTextColor) + } + .padding(.horizontal, LayoutInsets.defaultInset) + .padding(.vertical, LayoutInsets.smallInset) + .background(Color(ColorPalette.violet400Alpha7)) + .cornerRadius(StepQuizCodeBlanksAppearance.cornerRadius) + .padding(.horizontal) + } + .scrollBounceBehaviorBasedOnSize(axes: .horizontal) + } +} + +#if DEBUG +#Preview { + VStack { + StepQuizCodeBlanksIfStatementView( + ifStatementItem: StepQuizCodeBlanksViewStateCodeBlockItemIfStatement( + id: 0, + indentLevel: 0, + children: [ + StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 0, isActive: true, value: nil) + ] + ), + onChildTap: { _ in } + ) + + StepQuizCodeBlanksIfStatementView( + ifStatementItem: StepQuizCodeBlanksViewStateCodeBlockItemIfStatement( + id: 0, + indentLevel: 0, + children: [ + StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 0, isActive: true, value: "x") + ] + ), + onChildTap: { _ in } + ) + + StepQuizCodeBlanksIfStatementView( + ifStatementItem: StepQuizCodeBlanksViewStateCodeBlockItemIfStatement( + id: 0, + indentLevel: 0, + children: [ + StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 0, isActive: false, value: "x"), + StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 1, isActive: true, value: nil) + ] + ), + onChildTap: { _ in } + ) + } + .padding() +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksPrintInstructionView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Print/StepQuizCodeBlanksPrintInstructionView.swift similarity index 94% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksPrintInstructionView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Print/StepQuizCodeBlanksPrintInstructionView.swift index ab0cb80e3b..c634638d95 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksPrintInstructionView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Print/StepQuizCodeBlanksPrintInstructionView.swift @@ -37,6 +37,7 @@ struct StepQuizCodeBlanksPrintInstructionView: View { StepQuizCodeBlanksPrintInstructionView( printItem: StepQuizCodeBlanksViewStateCodeBlockItemPrint( id: 0, + indentLevel: 0, children: [.init(id: 0, isActive: false, value: "")] ), onChildTap: { _ in } @@ -44,6 +45,7 @@ struct StepQuizCodeBlanksPrintInstructionView: View { StepQuizCodeBlanksPrintInstructionView( printItem: StepQuizCodeBlanksViewStateCodeBlockItemPrint( id: 0, + indentLevel: 0, children: [.init(id: 0, isActive: true, value: "")] ), onChildTap: { _ in } @@ -51,6 +53,7 @@ struct StepQuizCodeBlanksPrintInstructionView: View { StepQuizCodeBlanksPrintInstructionView( printItem: StepQuizCodeBlanksViewStateCodeBlockItemPrint( id: 0, + indentLevel: 0, children: [.init(id: 0, isActive: true, value: "There is a cat on the keyboard, it is true")] ), onChildTap: { _ in } @@ -58,6 +61,7 @@ struct StepQuizCodeBlanksPrintInstructionView: View { StepQuizCodeBlanksPrintInstructionView( printItem: StepQuizCodeBlanksViewStateCodeBlockItemPrint( id: 0, + indentLevel: 0, children: [.init(id: 0, isActive: false, value: "There is a cat on the keyboard, it is true")] ), onChildTap: { _ in } @@ -66,6 +70,7 @@ struct StepQuizCodeBlanksPrintInstructionView: View { StepQuizCodeBlanksPrintInstructionView( printItem: StepQuizCodeBlanksViewStateCodeBlockItemPrint( id: 0, + indentLevel: 0, children: [ .init(id: 0, isActive: false, value: "x"), .init(id: 1, isActive: true, value: "") diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/StepQuizCodeBlanksCodeBlocksView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/StepQuizCodeBlanksCodeBlocksView.swift new file mode 100644 index 0000000000..9b5e4c634e --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/StepQuizCodeBlanksCodeBlocksView.swift @@ -0,0 +1,98 @@ +import shared +import SwiftUI + +struct StepQuizCodeBlanksCodeBlocksView: View { + let state: StepQuizCodeBlanksViewStateContent + + let onCodeBlockTap: (StepQuizCodeBlanksViewStateCodeBlockItem) -> Void + let onCodeBlockChildTap: ( + StepQuizCodeBlanksViewStateCodeBlockItem, + StepQuizCodeBlanksViewStateCodeBlockChildItem + ) -> Void + + let onSpaceTap: () -> Void + let onDeleteTap: () -> Void + let onEnterTap: () -> Void + let onDecreaseIndentLevelTap: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: LayoutInsets.smallInset) { + ForEach(state.codeBlocks, id: \.id_) { codeBlock in + HStack(spacing: 0) { + Spacer() + .frame(width: LayoutInsets.defaultInset * CGFloat(codeBlock.indentLevel)) + + buildCodeBlockView( + codeBlock: codeBlock, + onChildTap: { codeBlockChild in + onCodeBlockChildTap(codeBlock, codeBlockChild) + } + ) + .onTapGesture { + onCodeBlockTap(codeBlock) + } + } + } + + if !state.isActionButtonsHidden { + StepQuizCodeBlanksActionButtonsView( + isDeleteButtonEnabled: state.isDeleteButtonEnabled, + isSpaceButtonHidden: state.isSpaceButtonHidden, + isDecreaseIndentLevelButtonHidden: state.isDecreaseIndentLevelButtonHidden, + onSpaceTap: onSpaceTap, + onDeleteTap: onDeleteTap, + onEnterTap: onEnterTap, + onDecreaseIndentLevelTap: onDecreaseIndentLevelTap + ) + } + } + .padding(.vertical, LayoutInsets.defaultInset) + .frame(maxWidth: .infinity, alignment: .leading) + .background(BackgroundView()) + } + + @ViewBuilder + private func buildCodeBlockView( + codeBlock: StepQuizCodeBlanksViewStateCodeBlockItem, + onChildTap: @escaping (StepQuizCodeBlanksViewStateCodeBlockChildItem) -> Void + ) -> some View { + switch StepQuizCodeBlanksViewStateCodeBlockItemKs(codeBlock) { + case .blank(let blankItem): + StepQuizCodeBlanksCodeBlockChildBlankView( + style: .large, + isActive: blankItem.isActive + ) + .padding(.horizontal) + case .print(let printItem): + StepQuizCodeBlanksPrintInstructionView( + printItem: printItem, + onChildTap: onChildTap + ) + case .variable(let variableItem): + StepQuizCodeBlanksVariableInstructionView( + variableItem: variableItem, + onChildTap: onChildTap + ) + case .ifStatement(let ifStatementItem): + StepQuizCodeBlanksIfStatementView( + ifStatementItem: ifStatementItem, + onChildTap: onChildTap + ) + case .elifStatement(let elifStatementItem): + StepQuizCodeBlanksElifStatementView( + elifStatementItem: elifStatementItem, + onChildTap: onChildTap + ) + case .elseStatement(let elseStatementItem): + StepQuizCodeBlanksElseStatementView( + elseStatementItem: elseStatementItem + ) + } + } +} + +extension StepQuizCodeBlanksCodeBlocksView: Equatable { + static func == (lhs: StepQuizCodeBlanksCodeBlocksView, rhs: StepQuizCodeBlanksCodeBlocksView) -> Bool { + lhs.state.isEqual(rhs) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksVariableInstructionView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Variable/StepQuizCodeBlanksVariableInstructionView.swift similarity index 96% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksVariableInstructionView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Variable/StepQuizCodeBlanksVariableInstructionView.swift index c595c99dbd..7d1217b7f4 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksVariableInstructionView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/CodeBlocks/Variable/StepQuizCodeBlanksVariableInstructionView.swift @@ -38,6 +38,7 @@ struct StepQuizCodeBlanksVariableInstructionView: View { StepQuizCodeBlanksVariableInstructionView( variableItem: StepQuizCodeBlanksViewStateCodeBlockItemVariable( id: 0, + indentLevel: 0, children: [ StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 0, isActive: true, value: nil), StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 1, isActive: false, value: nil) @@ -49,6 +50,7 @@ struct StepQuizCodeBlanksVariableInstructionView: View { StepQuizCodeBlanksVariableInstructionView( variableItem: StepQuizCodeBlanksViewStateCodeBlockItemVariable( id: 0, + indentLevel: 0, children: [ StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 0, isActive: false, value: "fruit_a"), StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 1, isActive: true, value: nil) @@ -60,6 +62,7 @@ struct StepQuizCodeBlanksVariableInstructionView: View { StepQuizCodeBlanksVariableInstructionView( variableItem: StepQuizCodeBlanksViewStateCodeBlockItemVariable( id: 0, + indentLevel: 0, children: [ StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 0, isActive: false, value: "fruit_a"), StepQuizCodeBlanksViewStateCodeBlockChildItem( diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksView.swift index 5e3e8ecfd2..8047587412 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksView.swift @@ -23,12 +23,16 @@ struct StepQuizCodeBlanksView: View { titleView Divider() - codeBlocksView( - codeBlocks: contentState.codeBlocks, - isDeleteButtonEnabled: contentState.isDeleteButtonEnabled, - isSpaceButtonHidden: contentState.isSpaceButtonHidden, - isActionButtonsHidden: contentState.isActionButtonsHidden + StepQuizCodeBlanksCodeBlocksView( + state: contentState, + onCodeBlockTap: viewModel.doCodeBlockMainAction(_:), + onCodeBlockChildTap: viewModel.doCodeBlockChildMainAction(codeBlock:codeBlockChild:), + onSpaceTap: viewModel.doSpaceAction, + onDeleteTap: viewModel.doDeleteAction, + onEnterTap: viewModel.doEnterAction, + onDecreaseIndentLevelTap: viewModel.doDecreaseIndentLevelAction ) + .equatable() Divider() StepQuizCodeBlanksSuggestionsView( @@ -36,6 +40,7 @@ struct StepQuizCodeBlanksView: View { isAnimationEffectActive: contentState.isSuggestionsHighlightEffectActive, onSuggestionTap: viewModel.doSuggestionMainAction(_:) ) + .equatable() Divider() } .padding(.horizontal, -LayoutInsets.defaultInset) @@ -52,78 +57,6 @@ struct StepQuizCodeBlanksView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(BackgroundView()) } - - @MainActor - private func codeBlocksView( - codeBlocks: [StepQuizCodeBlanksViewStateCodeBlockItem], - isDeleteButtonEnabled: Bool, - isSpaceButtonHidden: Bool, - isActionButtonsHidden: Bool - ) -> some View { - VStack(alignment: .leading, spacing: LayoutInsets.smallInset) { - ForEach(codeBlocks, id: \.id_) { codeBlock in - switch StepQuizCodeBlanksViewStateCodeBlockItemKs(codeBlock) { - case .blank(let blankItem): - StepQuizCodeBlanksBlankView( - style: .large, - isActive: blankItem.isActive - ) - .padding(.horizontal) - .onTapGesture { - viewModel.doCodeBlockMainAction(codeBlock) - } - case .print(let printItem): - StepQuizCodeBlanksPrintInstructionView( - printItem: printItem, - onChildTap: { codeBlockChild in - viewModel.doCodeBlockChildMainAction( - codeBlock: codeBlock, - codeBlockChild: codeBlockChild - ) - } - ) - .onTapGesture { - viewModel.doCodeBlockMainAction(codeBlock) - } - case .variable(let variableItem): - StepQuizCodeBlanksVariableInstructionView( - variableItem: variableItem, - onChildTap: { codeBlockChild in - viewModel.doCodeBlockChildMainAction( - codeBlock: codeBlock, - codeBlockChild: codeBlockChild - ) - } - ) - .onTapGesture { - viewModel.doCodeBlockMainAction(codeBlock) - } - } - } - - if !isActionButtonsHidden { - HStack(spacing: LayoutInsets.defaultInset) { - Spacer() - - if !isSpaceButtonHidden { - StepQuizCodeBlanksActionButton - .space(action: viewModel.doSpaceAction) - } - - StepQuizCodeBlanksActionButton - .delete(action: viewModel.doDeleteAction) - .disabled(!isDeleteButtonEnabled) - - StepQuizCodeBlanksActionButton - .enter(action: viewModel.doEnterAction) - } - .padding(.horizontal) - } - } - .padding(.vertical, LayoutInsets.defaultInset) - .frame(maxWidth: .infinity, alignment: .leading) - .background(BackgroundView()) - } } extension StepQuizCodeBlanksView: Equatable { @@ -138,10 +71,13 @@ extension StepQuizCodeBlanksView: Equatable { StepQuizCodeBlanksView( viewStateKs: .content( StepQuizCodeBlanksViewStateContent( - codeBlocks: [StepQuizCodeBlanksViewStateCodeBlockItemBlank(id: 0, isActive: true)], + codeBlocks: [ + StepQuizCodeBlanksViewStateCodeBlockItemBlank(id: 0, indentLevel: 0, isActive: true) + ], suggestions: [Suggestion.Print()], isDeleteButtonEnabled: true, isSpaceButtonHidden: true, + isDecreaseIndentLevelButtonHidden: true, onboardingState: StepQuizCodeBlanksFeatureOnboardingStateUnavailable() ) ), @@ -161,6 +97,7 @@ extension StepQuizCodeBlanksView: Equatable { codeBlocks: [ StepQuizCodeBlanksViewStateCodeBlockItemPrint( id: 0, + indentLevel: 0, children: [ StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 0, isActive: true, value: nil) ] @@ -172,6 +109,7 @@ extension StepQuizCodeBlanksView: Equatable { ], isDeleteButtonEnabled: false, isSpaceButtonHidden: true, + isDecreaseIndentLevelButtonHidden: true, onboardingState: StepQuizCodeBlanksFeatureOnboardingStateUnavailable() ) ), @@ -191,6 +129,7 @@ extension StepQuizCodeBlanksView: Equatable { codeBlocks: [ StepQuizCodeBlanksViewStateCodeBlockItemPrint( id: 0, + indentLevel: 0, children: [ StepQuizCodeBlanksViewStateCodeBlockChildItem( id: 0, @@ -201,6 +140,7 @@ extension StepQuizCodeBlanksView: Equatable { ), StepQuizCodeBlanksViewStateCodeBlockItemPrint( id: 1, + indentLevel: 0, children: [ StepQuizCodeBlanksViewStateCodeBlockChildItem(id: 0, isActive: true, value: nil) ] @@ -212,6 +152,7 @@ extension StepQuizCodeBlanksView: Equatable { ], isDeleteButtonEnabled: false, isSpaceButtonHidden: true, + isDecreaseIndentLevelButtonHidden: true, onboardingState: StepQuizCodeBlanksFeatureOnboardingStateUnavailable() ) ), diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksSuggestionsView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/Suggestions/StepQuizCodeBlanksSuggestionsView.swift similarity index 79% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksSuggestionsView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/Suggestions/StepQuizCodeBlanksSuggestionsView.swift index b14c313c5c..0909295738 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksSuggestionsView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/Suggestions/StepQuizCodeBlanksSuggestionsView.swift @@ -21,7 +21,7 @@ struct StepQuizCodeBlanksSuggestionsView: View { onSuggestionTap(suggestion) }, label: { - StepQuizCodeBlanksOptionView( + StepQuizCodeBlanksCodeBlockChildTextView( text: suggestion.text, isActive: true ) @@ -31,7 +31,7 @@ struct StepQuizCodeBlanksSuggestionsView: View { ) .pulseEffect( shape: RoundedRectangle( - cornerRadius: StepQuizCodeBlanksOptionView.Appearance.cornerRadius + cornerRadius: StepQuizCodeBlanksCodeBlockChildTextView.Appearance.cornerRadius ), isActive: isAnimationEffectActive ) @@ -42,7 +42,7 @@ struct StepQuizCodeBlanksSuggestionsView: View { // Preserve height to avoid layout jumps if suggestions.isEmpty { - StepQuizCodeBlanksOptionView(text: "", isActive: false) + StepQuizCodeBlanksCodeBlockChildTextView(text: "", isActive: false) .hidden() } } @@ -50,6 +50,13 @@ struct StepQuizCodeBlanksSuggestionsView: View { } } +extension StepQuizCodeBlanksSuggestionsView: Equatable { + static func == (lhs: StepQuizCodeBlanksSuggestionsView, rhs: StepQuizCodeBlanksSuggestionsView) -> Bool { + lhs.isAnimationEffectActive == rhs.isAnimationEffectActive && + lhs.suggestions.map(\.text) == rhs.suggestions.map(\.text) + } +} + #if DEBUG #Preview { VStack { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Systems/PurchaseManager.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Systems/PurchaseManager.swift index d898567e7c..53d68a0e90 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Systems/PurchaseManager.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Systems/PurchaseManager.swift @@ -67,89 +67,81 @@ created: \(created) } func purchase( - productId: String, + storeProduct: StoreProduct, platformPurchaseParams: PlatformPurchaseParams, completionHandler: @escaping (SwiftyResult?, Error?) -> Void ) { #if DEBUG - print("PurchaseManager: purchase \(productId)...") + print("PurchaseManager: purchase \(storeProduct.productIdentifier)...") #endif - getProduct(id: productId) { storeProduct in - guard let storeProduct else { + Purchases.shared.purchase( + product: storeProduct + ) { storeTransaction, customerInfo, error, userCancelled in + if userCancelled { #if DEBUG - print("PurchaseManager: purchase \(productId) failed, no product found") + print("PurchaseManager: purchase \(storeProduct.productIdentifier) cancelled by user") #endif let result = SwiftyResultSuccess( - value: PurchaseResultErrorNoProductFound(productId: productId) + value: PurchaseResultCancelledByUser() ) return completionHandler(result, nil) } - Purchases.shared.purchase( - product: storeProduct - ) { storeTransaction, customerInfo, error, userCancelled in - if userCancelled { - #if DEBUG - print("PurchaseManager: purchase \(productId) cancelled by user") - #endif + if let error { + let purchaseResult = error.asSharedPurchaseResult() - let result = SwiftyResultSuccess( - value: PurchaseResultCancelledByUser() - ) + #if DEBUG + print(""" +PurchaseManager: purchase \(storeProduct.productIdentifier) failed, \ +error: \(error), \ +purchaseResult: \(purchaseResult) +""") + #endif - return completionHandler(result, nil) - } + let result = SwiftyResultSuccess( + value: purchaseResult + ) - if let error { - let purchaseResult = error.asSharedPurchaseResult() + return completionHandler(result, nil) + } - #if DEBUG - print(""" -PurchaseManager: purchase \(productId) failed, error: \(error), purchaseResult: \(purchaseResult) + if let storeTransaction, let customerInfo { + #if DEBUG + print(""" +PurchaseManager: purchase \(storeProduct.productIdentifier) succeeded, \ +storeTransaction: \(storeTransaction), \ +customerInfo: \(customerInfo) """) - #endif - - let result = SwiftyResultSuccess( - value: purchaseResult - ) + #endif - return completionHandler(result, nil) - } + let purchaseResult = PurchaseResultSucceed( + orderId: storeTransaction.transactionIdentifier, + productIds: [storeTransaction.productIdentifier] + ) + let result = SwiftyResultSuccess( + value: purchaseResult + ) - if let storeTransaction, let customerInfo { - #if DEBUG - print(""" -PurchaseManager: purchase \(productId) succeeded, storeTransaction: \(storeTransaction), customerInfo: \(customerInfo) + completionHandler(result, nil) + } else { + #if DEBUG + print(""" +PurchaseManager: purchase \(storeProduct.productIdentifier) failed, no storeTransaction or customerInfo """) - #endif - - let purchaseResult = PurchaseResultSucceed( - orderId: storeTransaction.transactionIdentifier, - productIds: [storeTransaction.productIdentifier] - ) - let result = SwiftyResultSuccess( - value: purchaseResult - ) - - completionHandler(result, nil) - } else { - #if DEBUG - print("PurchaseManager: purchase \(productId) failed, no storeTransaction or customerInfo") - #endif - - let purchaseResult = PurchaseResultErrorOtherError( - message: "No storeTransaction or customerInfo found for \(productId) purchase", - underlyingErrorMessage: nil - ) - let result = SwiftyResultSuccess( - value: purchaseResult - ) - - completionHandler(result, nil) - } + #endif + + let purchaseResult = PurchaseResultErrorOtherError( + message: "No storeTransaction or customerInfo found for \(storeProduct.productIdentifier) purchase", + underlyingErrorMessage: nil + ) + let result = SwiftyResultSuccess( + value: purchaseResult + ) + + completionHandler(result, nil) } } } @@ -188,57 +180,6 @@ PurchaseManager: get management URL succeeded, managementURL: \(String(describin } } } - - func getFormattedProductPrice( - productId: String, - completionHandler: @escaping (String?, Error?) -> Void - ) { - #if DEBUG - print("PurchaseManager: get formatted product price for \(productId)...") - #endif - - getProduct(id: productId) { storeProduct in - if let storeProduct { - #if DEBUG - print(""" -PurchaseManager: get formatted product price for \(productId) succeeded, \ -localizedPriceString: \(storeProduct.localizedPriceString) -""") - #endif - completionHandler(storeProduct.localizedPriceString, nil) - } else { - #if DEBUG - print("PurchaseManager: get formatted product price for \(productId) failed") - #endif - completionHandler(nil, nil) - } - } - } - - func checkTrialOrIntroDiscountEligibility( - productId: String, - completionHandler: @escaping (KotlinBoolean?, (any Error)?) -> Void - ) { - Purchases.shared.checkTrialOrIntroDiscountEligibility( - productIdentifiers: [productId] - ) { eligibilities in - if let eligibility = eligibilities[productId] { - let isEligible = eligibility.status == .eligible - completionHandler(KotlinBoolean(value: isEligible), nil) - } else { - completionHandler(KotlinBoolean(value: false), nil) - } - } - } - - private func getProduct( - id: String, - completionHandler: @escaping (StoreProduct?) -> Void - ) { - Purchases.shared.getProducts([id]) { storeProducts in - completionHandler(storeProducts.first) - } - } } // MARK: - RevenueCat.PublicError (shared.PurchaseResult) - diff --git a/iosHyperskillApp/iosHyperskillAppTests/Info.plist b/iosHyperskillApp/iosHyperskillAppTests/Info.plist index 202cd0442f..8348e40f15 100644 --- a/iosHyperskillApp/iosHyperskillAppTests/Info.plist +++ b/iosHyperskillApp/iosHyperskillAppTests/Info.plist @@ -13,8 +13,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.70 + 1.71 CFBundleVersion - 570 + 576 diff --git a/iosHyperskillApp/iosHyperskillAppUITests/Info.plist b/iosHyperskillApp/iosHyperskillAppUITests/Info.plist index 82b15ddbb8..47e97daaa7 100644 --- a/iosHyperskillApp/iosHyperskillAppUITests/Info.plist +++ b/iosHyperskillApp/iosHyperskillAppUITests/Info.plist @@ -13,8 +13,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.70 + 1.71 CFBundleVersion - 570 + 576 diff --git a/resources/screenshots/01.webp b/resources/screenshots/01.webp index f6b2e95236..af99a53400 100644 Binary files a/resources/screenshots/01.webp and b/resources/screenshots/01.webp differ diff --git a/resources/screenshots/02.webp b/resources/screenshots/02.webp index cfad44e8f4..8d048954da 100644 Binary files a/resources/screenshots/02.webp and b/resources/screenshots/02.webp differ diff --git a/resources/screenshots/03.webp b/resources/screenshots/03.webp index 8b9abc2fb8..b5d97d2e19 100644 Binary files a/resources/screenshots/03.webp and b/resources/screenshots/03.webp differ diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index b235949454..de1b2e37ca 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -53,6 +53,17 @@ kotlin { // Add export declarations to use moko-resources iOS extensions from Swift side export(libs.mokoResources.main) } + pod("RevenueCat") { + version = "4.41.1" + extraOpts += listOf("-compiler-option", "-fmodules") + } + } + sourceSets { + all { + languageSettings { + optIn("kotlinx.cinterop.ExperimentalForeignApi") + } + } } } diff --git a/shared/shared.podspec b/shared/shared.podspec index d4f1710c74..f940d8e731 100644 --- a/shared/shared.podspec +++ b/shared/shared.podspec @@ -9,7 +9,7 @@ Pod::Spec.new do |spec| spec.vendored_frameworks = 'build/cocoapods/framework/shared.framework' spec.libraries = 'c++' spec.ios.deployment_target = '14.0' - + spec.dependency 'RevenueCat', '4.41.1' if !Dir.exist?('build/cocoapods/framework/shared.framework') || Dir.empty?('build/cocoapods/framework/shared.framework') raise " diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallViewModel.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallViewModel.kt index 22db1c05e7..3e71d6c1a9 100644 --- a/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallViewModel.kt +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallViewModel.kt @@ -16,6 +16,12 @@ class PaywallViewModel( onNewMessage(Message.Initialize) } + fun onOptionClick(productId: String) { + onNewMessage( + Message.ProductClicked(productId) + ) + } + fun onBuySubscriptionClick(activity: Activity) { onNewMessage( Message.BuySubscriptionClicked( diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/AndroidPurchaseManager.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/AndroidPurchaseManager.kt index d410c64714..0676921780 100644 --- a/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/AndroidPurchaseManager.kt +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/AndroidPurchaseManager.kt @@ -1,26 +1,26 @@ package org.hyperskill.app.purchases.domain -import android.app.Activity import android.app.Application import com.revenuecat.purchases.LogLevel import com.revenuecat.purchases.PurchaseParams import com.revenuecat.purchases.Purchases import com.revenuecat.purchases.PurchasesConfiguration import com.revenuecat.purchases.PurchasesErrorCode -import com.revenuecat.purchases.PurchasesException import com.revenuecat.purchases.PurchasesTransactionException import com.revenuecat.purchases.awaitCustomerInfo -import com.revenuecat.purchases.awaitGetProducts import com.revenuecat.purchases.awaitLogIn +import com.revenuecat.purchases.awaitOfferings import com.revenuecat.purchases.awaitPurchase -import com.revenuecat.purchases.models.StoreProduct import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine import org.hyperskill.app.BuildConfig +import org.hyperskill.app.purchases.domain.model.HyperskillStoreProduct import org.hyperskill.app.purchases.domain.model.PlatformPurchaseParams import org.hyperskill.app.purchases.domain.model.PurchaseManager import org.hyperskill.app.purchases.domain.model.PurchaseResult +import org.hyperskill.app.purchases.domain.model.SubscriptionPeriod +import org.hyperskill.app.purchases.domain.model.SubscriptionProduct class AndroidPurchaseManager( private val application: Application, @@ -63,37 +63,22 @@ class AndroidPurchaseManager( } override suspend fun purchase( - productId: String, + storeProduct: HyperskillStoreProduct, platformPurchaseParams: PlatformPurchaseParams ): Result = runCatching { - val product = try { - fetchProduct(productId) ?: return@runCatching PurchaseResult.Error.NoProductFound(productId) - } catch (e: PurchasesException) { - return@runCatching mapProductFetchException(productId, e) - } val activity = platformPurchaseParams.activity - purchase(activity, product) - } - - private fun mapProductFetchException(productId: String, e: PurchasesException): PurchaseResult = - PurchaseResult.Error.ErrorWhileFetchingProduct( - productId = productId, - originMessage = e.message, - underlyingErrorMessage = e.error.underlyingErrorMessage - ) - - private suspend fun purchase(activity: Activity, product: StoreProduct): PurchaseResult = - try { - val purchaseResult = Purchases.sharedInstance.awaitPurchase( - PurchaseParams.Builder(activity, product).build() - ) - PurchaseResult.Succeed( - orderId = purchaseResult.storeTransaction.orderId, - productIds = purchaseResult.storeTransaction.productIds - ) - } catch (e: PurchasesTransactionException) { - mapException(e) + try { + val purchaseResult = Purchases.sharedInstance.awaitPurchase( + PurchaseParams.Builder(activity, storeProduct.revenueCatSubscriptionOption).build() + ) + PurchaseResult.Succeed( + orderId = purchaseResult.storeTransaction.orderId, + productIds = purchaseResult.storeTransaction.productIds + ) + } catch (e: PurchasesTransactionException) { + mapException(e) + } } private fun mapException(e: PurchasesTransactionException): PurchaseResult { @@ -118,15 +103,25 @@ class AndroidPurchaseManager( Purchases.sharedInstance.awaitCustomerInfo().managementURL?.toString() } - override suspend fun getFormattedProductPrice(productId: String): Result = + override suspend fun getSubscriptionProducts(): Result> = kotlin.runCatching { - fetchProduct(productId)?.price?.formatted + val currentOffering = Purchases.sharedInstance.awaitOfferings().current + ?: return@runCatching emptyList() + currentOffering.availablePackages.mapNotNull { + val product = it.product + SubscriptionProduct( + id = product.id, + period = when (product.period?.unit) { + com.revenuecat.purchases.models.Period.Unit.MONTH -> SubscriptionPeriod.MONTH + com.revenuecat.purchases.models.Period.Unit.YEAR -> SubscriptionPeriod.YEAR + else -> return@mapNotNull null + }, + formattedPrice = product.price.formatted, + formattedPricePerMonth = product.formattedPricePerMonth() ?: return@mapNotNull null, + storeProduct = HyperskillStoreProduct( + requireNotNull(product.subscriptionOptions).first() + ) + ) + } } - - override suspend fun checkTrialEligibility(productId: String): Boolean = false - - private suspend fun fetchProduct(productId: String): StoreProduct? = - Purchases.sharedInstance - .awaitGetProducts(listOf(productId)) - .firstOrNull() } \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/model/HyperskillStoreProduct.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/model/HyperskillStoreProduct.kt new file mode 100644 index 0000000000..86a51116f8 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/model/HyperskillStoreProduct.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.purchases.domain.model + +import com.revenuecat.purchases.models.SubscriptionOption + +actual class HyperskillStoreProduct( + val revenueCatSubscriptionOption: SubscriptionOption +) \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformProductIdentifiers.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformProductIdentifiers.kt index 6d259999b9..3fee27de86 100644 --- a/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformProductIdentifiers.kt +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformProductIdentifiers.kt @@ -1,5 +1,5 @@ package org.hyperskill.app.purchases.domain.model internal actual object PlatformProductIdentifiers { - actual const val MOBILE_ONLY_SUBSCRIPTION: String = "premium_mobile" + actual const val MOBILE_ONLY_MONTHLY_SUBSCRIPTION: String = "premium_mobile" } \ No newline at end of file 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 da52ad1af4..8b0a7e01a8 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 @@ -21,6 +21,7 @@ enum class HyperskillAnalyticTarget(val targetName: String) { DELETE("delete"), ENTER("enter"), SPACE("space"), + DECREASE_INDENT_LEVEL("decrease_indent_level"), DONE("done"), YES("yes"), NO("no"), @@ -122,6 +123,7 @@ enum class HyperskillAnalyticTarget(val targetName: String) { ACTIVE_SUBSCRIPTION_DETAILS("active_subscription_details"), SUBSCRIPTION_SUGGESTION_DETAILS("subscription_suggestion_details"), BUY_SUBSCRIPTION("buy_subscription"), + STORE_PRODUCT("store_product"), UNLOCK_UNLIMITED_PROBLEMS("unlock_unlimited_problems"), MANAGE_SUBSCRIPTION("manage_subscription"), RENEW_SUBSCRIPTION("renew_subscription"), diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallAnalyticParams.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallAnalyticParams.kt index f702014415..14bd6ed878 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallAnalyticParams.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallAnalyticParams.kt @@ -2,4 +2,5 @@ package org.hyperskill.app.paywall.domain.analytic internal object PaywallAnalyticParams { const val PARAM_TRANSITION_SOURCE: String = "source" + const val STORE_PRODUCT: String = "store_product" } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedProductHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedProductHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..cbfaeb93e0 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedProductHyperskillAnalyticEvent.kt @@ -0,0 +1,42 @@ +package org.hyperskill.app.paywall.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 org.hyperskill.app.paywall.domain.model.PaywallTransitionSource + +/** + * Represents a click analytic event of the store product. + * + * JSON payload: + * ``` + * { + * "route": "/paywall", + * "action": "click", + * "part": "main", + * "target": "store_product", + * "context": + * { + * "source": "login", + * "store_product": "premium_mobile:premium-mobile-monthly" + * } + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class PaywallClickedProductHyperskillAnalyticEvent( + paywallTransitionSource: PaywallTransitionSource, + productId: String +) : HyperskillAnalyticEvent( + route = HyperskillAnalyticRoute.Paywall, + action = HyperskillAnalyticAction.CLICK, + part = HyperskillAnalyticPart.MAIN, + target = HyperskillAnalyticTarget.STORE_PRODUCT, + context = mapOf( + PaywallAnalyticParams.PARAM_TRANSITION_SOURCE to paywallTransitionSource.analyticName, + PaywallAnalyticParams.STORE_PRODUCT to productId + ) +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallComponentImpl.kt index eb217f3574..f3ee7c9ab8 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallComponentImpl.kt @@ -20,7 +20,6 @@ internal class PaywallComponentImpl( subscriptionsRepository = appGraph.subscriptionDataComponent.subscriptionsRepository, sentryInteractor = appGraph.sentryComponent.sentryInteractor, currentSubscriptionStateRepository = appGraph.stateRepositoriesComponent.currentSubscriptionStateRepository, - platformType = appGraph.commonComponent.platform.platformType, logger = appGraph.loggerComponent.logger, buildVariant = appGraph.commonComponent.buildKonfig.buildVariant ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallFeatureBuilder.kt index 45dee1c6f2..5d236a16be 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallFeatureBuilder.kt @@ -4,7 +4,6 @@ import co.touchlab.kermit.Logger import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.analytic.presentation.wrapWithBatchAnalyticLogger import org.hyperskill.app.core.domain.BuildVariant -import org.hyperskill.app.core.domain.platform.PlatformType import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.core.presentation.transformState import org.hyperskill.app.core.view.mapper.ResourceProvider @@ -35,7 +34,6 @@ internal object PaywallFeatureBuilder { resourceProvider: ResourceProvider, subscriptionsRepository: SubscriptionsRepository, currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, - platformType: PlatformType, sentryInteractor: SentryInteractor, logger: Logger, buildVariant: BuildVariant @@ -55,7 +53,7 @@ internal object PaywallFeatureBuilder { logger = logger.withTag(LOG_TAG) ) - val viewStateMapper = PaywallViewStateMapper(resourceProvider, platformType) + val viewStateMapper = PaywallViewStateMapper(resourceProvider) return ReduxFeature( initialState = PaywallFeature.State.Idle, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallActionDispatcher.kt index 7ada64ec02..b8044688f2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallActionDispatcher.kt @@ -1,8 +1,6 @@ package org.hyperskill.app.paywall.presentation import co.touchlab.kermit.Logger -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.paywall.presentation.PaywallFeature.Action import org.hyperskill.app.paywall.presentation.PaywallFeature.InternalAction @@ -29,8 +27,8 @@ internal class PaywallActionDispatcher( when (action) { is InternalAction.FetchMobileOnlyPrice -> handleFetchMobileOnlyPrice(::onNewMessage) - is InternalAction.StartMobileOnlySubscriptionPurchase -> - handleStartMobileOnlySubscriptionPurchase(action, ::onNewMessage) + is InternalAction.StartSubscriptionProductPurchase -> + handleStartSubscriptionProductPurchase(action, ::onNewMessage) is InternalAction.SyncSubscription -> handleSyncSubscription(::onNewMessage) is InternalAction.LogWrongSubscriptionTypeAfterSync -> @@ -46,35 +44,24 @@ internal class PaywallActionDispatcher( transaction = HyperskillSentryTransactionBuilder.buildPaywallFetchSubscriptionPrice(), onError = { e -> logger.e(e) { "Failed to load subscription price" } - InternalMessage.FetchMobileOnlyPriceError + InternalMessage.FetchSubscriptionProductsError } ) { - coroutineScope { - val priceDeferred = async { - purchaseInteractor.getFormattedMobileOnlySubscriptionPrice() - } - val trialEligibilityDeferred = async { - purchaseInteractor.checkTrialEligibilityForMobileOnlySubscription() - } - - val price = priceDeferred.await().getOrThrow() - val isTrialEligible = trialEligibilityDeferred.await() + val subscriptionProducts = purchaseInteractor + .getSubscriptionProducts() + .getOrThrow() - if (price != null) { - InternalMessage.FetchMobileOnlyPriceSuccess( - formattedPrice = price, - isTrialEligible = isTrialEligible - ) - } else { - logger.e { "Receive null instead of formatted mobile-only subscription price" } - InternalMessage.FetchMobileOnlyPriceError - } + if (subscriptionProducts.isNotEmpty()) { + InternalMessage.FetchSubscriptionProductsSuccess(subscriptionProducts) + } else { + logger.e { "Receive null instead of formatted mobile-only subscription price" } + InternalMessage.FetchSubscriptionProductsError } }.let(onNewMessage) } - private suspend fun handleStartMobileOnlySubscriptionPurchase( - action: InternalAction.StartMobileOnlySubscriptionPurchase, + private suspend fun handleStartSubscriptionProductPurchase( + action: InternalAction.StartSubscriptionProductPurchase, onNewMessage: (Message) -> Unit ) { sentryInteractor.withTransaction( @@ -85,7 +72,10 @@ internal class PaywallActionDispatcher( } ) { val purchaseResult = purchaseInteractor - .purchaseMobileOnlySubscription(action.purchaseParams) + .purchaseSubscriptionProduct( + storeProduct = action.storeProduct, + platformPurchaseParams = action.purchaseParams + ) .getOrThrow() if (purchaseResult is PurchaseResult.Error) { @@ -97,7 +87,11 @@ internal class PaywallActionDispatcher( } private fun getPurchaseErrorMessage(error: PurchaseResult.Error): String = - "Subscription purchase failed!\n${error.message}\n${error.underlyingErrorMessage}" + """ + Subscription purchase failed! + error message: ${error.message} + underlying error message: ${error.underlyingErrorMessage} + """.trimIndent() private suspend fun handleSyncSubscription(onNewMessage: (Message) -> Unit) { sentryInteractor.withTransaction( diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallFeature.kt index bef08a0fca..99247b4349 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallFeature.kt @@ -3,8 +3,10 @@ package org.hyperskill.app.paywall.presentation import dev.icerock.moko.resources.StringResource import org.hyperskill.app.SharedResources import org.hyperskill.app.analytic.domain.model.AnalyticEvent +import org.hyperskill.app.purchases.domain.model.HyperskillStoreProduct import org.hyperskill.app.purchases.domain.model.PlatformPurchaseParams import org.hyperskill.app.purchases.domain.model.PurchaseResult +import org.hyperskill.app.purchases.domain.model.SubscriptionProduct import org.hyperskill.app.subscriptions.domain.model.Subscription import org.hyperskill.app.subscriptions.domain.model.SubscriptionType @@ -14,8 +16,8 @@ object PaywallFeature { data object Loading : State data object Error : State data class Content( - val formattedPrice: String, - val isTrialEligible: Boolean, + val subscriptionProducts: List, + val selectedProductId: String, val isPurchaseSyncLoadingShowed: Boolean = false ) : State } @@ -30,12 +32,19 @@ object PaywallFeature { data object Loading : ViewStateContent data object Error : ViewStateContent data class Content( - val buyButtonText: String, - val priceText: String?, - val trialText: String? + val subscriptionProducts: List, + val buyButtonText: String ) : ViewStateContent data object SubscriptionSyncLoading : ViewStateContent + + data class SubscriptionProduct( + val productId: String, + val title: String, + val subtitle: String, + val isBestValue: Boolean, + val isSelected: Boolean + ) } sealed interface Message { @@ -45,6 +54,8 @@ object PaywallFeature { data object CloseClicked : Message + data class ProductClicked(val productId: String) : Message + data class BuySubscriptionClicked( val purchaseParams: PlatformPurchaseParams ) : Message @@ -58,10 +69,9 @@ object PaywallFeature { } internal sealed interface InternalMessage : Message { - data object FetchMobileOnlyPriceError : InternalMessage - data class FetchMobileOnlyPriceSuccess( - val formattedPrice: String, - val isTrialEligible: Boolean + data object FetchSubscriptionProductsError : InternalMessage + data class FetchSubscriptionProductsSuccess( + val subscriptionProducts: List ) : InternalMessage data object MobileOnlySubscriptionPurchaseError : InternalMessage @@ -103,7 +113,8 @@ object PaywallFeature { internal sealed interface InternalAction : Action { data object FetchMobileOnlyPrice : InternalAction - data class StartMobileOnlySubscriptionPurchase( + data class StartSubscriptionProductPurchase( + val storeProduct: HyperskillStoreProduct, val purchaseParams: PlatformPurchaseParams ) : InternalAction diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallReducer.kt index 633f11f703..bccf76740d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallReducer.kt @@ -4,6 +4,7 @@ import org.hyperskill.app.SharedResources import org.hyperskill.app.core.view.mapper.ResourceProvider import org.hyperskill.app.paywall.domain.analytic.PaywallClickedBuySubscriptionHyperskillAnalyticEvent import org.hyperskill.app.paywall.domain.analytic.PaywallClickedCloseButtonHyperskillAnalyticEvent +import org.hyperskill.app.paywall.domain.analytic.PaywallClickedProductHyperskillAnalyticEvent import org.hyperskill.app.paywall.domain.analytic.PaywallClickedRetryContentLoadingHyperskillAnalyticEvent import org.hyperskill.app.paywall.domain.analytic.PaywallClickedTermsOfServiceAndPrivacyPolicyHyperskillAnalyticEvent import org.hyperskill.app.paywall.domain.analytic.PaywallSubscriptionPurchasedAmplitudeAnalyticEvent @@ -36,12 +37,14 @@ internal class PaywallReducer( ) ) ) - is InternalMessage.FetchMobileOnlyPriceSuccess -> + is InternalMessage.FetchSubscriptionProductsSuccess -> handleFetchMobileOnlyPriceSuccess(message) - InternalMessage.FetchMobileOnlyPriceError -> + InternalMessage.FetchSubscriptionProductsError -> handleFetchMobileOnlyPriceError() Message.CloseClicked -> handleCloseClicked(state) + is Message.ProductClicked -> + handleProductClicked(state, message) is Message.BuySubscriptionClicked -> handleBuySubscriptionClicked(state, message) is InternalMessage.MobileOnlySubscriptionPurchaseSuccess -> @@ -66,11 +69,11 @@ internal class PaywallReducer( State.Loading to setOf(InternalAction.FetchMobileOnlyPrice) + actions private fun handleFetchMobileOnlyPriceSuccess( - message: InternalMessage.FetchMobileOnlyPriceSuccess + message: InternalMessage.FetchSubscriptionProductsSuccess ): ReducerResult = State.Content( - formattedPrice = message.formattedPrice, - isTrialEligible = message.isTrialEligible + subscriptionProducts = message.subscriptionProducts, + selectedProductId = message.subscriptionProducts.first().id ) to emptySet() private fun handleFetchMobileOnlyPriceError(): ReducerResult = @@ -88,18 +91,44 @@ internal class PaywallReducer( getTargetScreenNavigationAction(paywallTransitionSource) ) + private fun handleProductClicked( + state: State, + message: Message.ProductClicked + ): ReducerResult = + if (state is State.Content) { + state.copy( + selectedProductId = message.productId + ) to setOf( + InternalAction.LogAnalyticEvent( + PaywallClickedProductHyperskillAnalyticEvent( + productId = message.productId, + paywallTransitionSource = paywallTransitionSource + ) + ) + ) + } else { + state to emptySet() + } + private fun handleBuySubscriptionClicked( state: State, message: Message.BuySubscriptionClicked ): ReducerResult = - state to setOf( - InternalAction.LogAnalyticEvent( - PaywallClickedBuySubscriptionHyperskillAnalyticEvent( - paywallTransitionSource + if (state is State.Content) { + state to setOf( + InternalAction.LogAnalyticEvent( + PaywallClickedBuySubscriptionHyperskillAnalyticEvent(paywallTransitionSource) + ), + InternalAction.StartSubscriptionProductPurchase( + storeProduct = state.subscriptionProducts.first { + it.id == state.selectedProductId + }.storeProduct, + purchaseParams = message.purchaseParams ) - ), - InternalAction.StartMobileOnlySubscriptionPurchase(message.purchaseParams) - ) + ) + } else { + state to emptySet() + } private fun handleMobileOnlySubscriptionPurchaseSuccess( state: State, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/view/PaywallViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/view/PaywallViewStateMapper.kt index 93879904e3..449fbddbfa 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/view/PaywallViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/view/PaywallViewStateMapper.kt @@ -1,7 +1,6 @@ package org.hyperskill.app.paywall.view import org.hyperskill.app.SharedResources -import org.hyperskill.app.core.domain.platform.PlatformType import org.hyperskill.app.core.view.mapper.ResourceProvider import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource.APP_BECOMES_ACTIVE @@ -13,10 +12,11 @@ import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource.TOPIC_COM import org.hyperskill.app.paywall.presentation.PaywallFeature.State import org.hyperskill.app.paywall.presentation.PaywallFeature.ViewState import org.hyperskill.app.paywall.presentation.PaywallFeature.ViewStateContent +import org.hyperskill.app.purchases.domain.model.SubscriptionPeriod +import org.hyperskill.app.purchases.domain.model.SubscriptionProduct internal class PaywallViewStateMapper( - private val resourceProvider: ResourceProvider, - private val platformType: PlatformType + private val resourceProvider: ResourceProvider ) { fun map( state: State, @@ -38,44 +38,44 @@ internal class PaywallViewStateMapper( if (state.isPurchaseSyncLoadingShowed) { ViewStateContent.SubscriptionSyncLoading } else { - ViewStateContent.Content( - buyButtonText = getBuyButtonText(state), - priceText = if (platformType == PlatformType.ANDROID) { - resourceProvider.getString( - SharedResources.strings.paywall_android_explicit_subscription_price, - state.formattedPrice - ) - } else { - null - }, - trialText = if (platformType == PlatformType.IOS && state.isTrialEligible) { - resourceProvider.getString( - SharedResources.strings.paywall_ios_mobile_only_trial_description, - state.formattedPrice - ) - } else { - null - } - ) + getContentViewState(state) } } ) - private fun getBuyButtonText(state: State.Content): String = - when (platformType) { - PlatformType.IOS -> - if (state.isTrialEligible) { - resourceProvider.getString(SharedResources.strings.paywall_ios_mobile_only_trial_buy_btn) - } else { + private fun getContentViewState(state: State.Content): ViewStateContent.Content = + ViewStateContent.Content( + buyButtonText = resourceProvider.getString(SharedResources.strings.paywall_subscription_start_btn), + subscriptionProducts = state.subscriptionProducts.mapIndexed { i, product -> + mapSubscriptionProductToSubscriptionOption( + index = i, + product = product, + isSelected = product.id == state.selectedProductId + ) + } + ) + + private fun mapSubscriptionProductToSubscriptionOption( + index: Int, + product: SubscriptionProduct, + isSelected: Boolean + ): ViewStateContent.SubscriptionProduct = + ViewStateContent.SubscriptionProduct( + productId = product.id, + title = when (product.period) { + SubscriptionPeriod.MONTH -> + resourceProvider.getString(SharedResources.strings.paywall_subscription_duration_monthly) + SubscriptionPeriod.YEAR -> resourceProvider.getString( - SharedResources.strings.paywall_ios_mobile_only_buy_btn, - state.formattedPrice + SharedResources.strings.paywall_subscription_duration_annual, + product.formattedPrice ) - } - PlatformType.ANDROID -> - resourceProvider.getString( - SharedResources.strings.paywall_android_mobile_only_buy_btn, - state.formattedPrice - ) - } + }, + subtitle = resourceProvider.getString( + SharedResources.strings.paywall_subscription_month_price, + product.formattedPricePerMonth + ), + isBestValue = index == 0, + isSelected = isSelected + ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsActionDispatcher.kt index 6fc77b4d54..b7aee009ee 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsActionDispatcher.kt @@ -118,7 +118,11 @@ internal class ProfileSettingsActionDispatcher( currentSubscriptionStateRepository.getState(forceUpdate = true) } val priceDeferred = async { - purchaseInteractor.getFormattedMobileOnlySubscriptionPrice() + purchaseInteractor + .getSubscriptionProducts() + .map { subscriptionProducts -> + subscriptionProducts.firstOrNull()?.formattedPricePerMonth + } } Message.ProfileSettingsSuccess( profileSettings = profileSettingsInteractor.getProfileSettings(), diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsFeature.kt index e3aaf1d198..033114980d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsFeature.kt @@ -19,7 +19,7 @@ object ProfileSettingsFeature { data class Content( val profileSettings: ProfileSettings, val subscription: Subscription?, - val mobileOnlyFormattedPrice: String?, + val subscriptionFormattedPricePerMonth: String?, val isLoadingMagicLink: Boolean = false ) : State } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsReducer.kt index a76bd4b552..1c629961d6 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsReducer.kt @@ -32,7 +32,7 @@ internal class ProfileSettingsReducer : StateReducer { State.Content( profileSettings = message.profileSettings, subscription = message.subscription, - mobileOnlyFormattedPrice = message.mobileOnlyFormattedPrice + subscriptionFormattedPricePerMonth = message.mobileOnlyFormattedPrice ) to emptySet() is Message.OnSubscriptionChanged -> handleSubscriptionChanged(state, message) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/view/ProfileSettingsViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/view/ProfileSettingsViewStateMapper.kt index 87d585fc22..8303e1018e 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/view/ProfileSettingsViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/view/ProfileSettingsViewStateMapper.kt @@ -20,7 +20,7 @@ internal class ProfileSettingsViewStateMapper( ViewState.Content( profileSettings = state.profileSettings, isLoadingMagicLink = state.isLoadingMagicLink, - subscriptionState = if (state.subscription != null && state.mobileOnlyFormattedPrice != null) { + subscriptionState = if (state.subscription != null && state.subscriptionFormattedPricePerMonth != null) { when { state.subscription.type == SubscriptionType.MOBILE_ONLY -> { ViewState.Content.SubscriptionState( @@ -31,7 +31,7 @@ internal class ProfileSettingsViewStateMapper( ViewState.Content.SubscriptionState( resourceProvider.getString( SharedResources.strings.settings_subscription_mobile_only_suggestion, - state.mobileOnlyFormattedPrice + state.subscriptionFormattedPricePerMonth ) ) } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/interactor/PurchaseInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/interactor/PurchaseInteractor.kt index 35ff38bee4..e00728bbd5 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/interactor/PurchaseInteractor.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/interactor/PurchaseInteractor.kt @@ -2,10 +2,11 @@ package org.hyperskill.app.purchases.domain.interactor import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.analytic.domain.model.AnalyticKeys -import org.hyperskill.app.purchases.domain.model.PlatformProductIdentifiers +import org.hyperskill.app.purchases.domain.model.HyperskillStoreProduct import org.hyperskill.app.purchases.domain.model.PlatformPurchaseParams import org.hyperskill.app.purchases.domain.model.PurchaseManager import org.hyperskill.app.purchases.domain.model.PurchaseResult +import org.hyperskill.app.purchases.domain.model.SubscriptionProduct class PurchaseInteractor( private val purchaseManager: PurchaseManager, @@ -35,17 +36,18 @@ class PurchaseInteractor( suspend fun canMakePayments(): Result = purchaseManager.canMakePayments() - suspend fun purchaseMobileOnlySubscription( + suspend fun purchaseSubscriptionProduct( + storeProduct: HyperskillStoreProduct, platformPurchaseParams: PlatformPurchaseParams ): Result = - purchaseManager.purchase(PlatformProductIdentifiers.MOBILE_ONLY_SUBSCRIPTION, platformPurchaseParams) + purchaseManager.purchase( + storeProduct = storeProduct, + platformPurchaseParams = platformPurchaseParams + ) + + suspend fun getSubscriptionProducts(): Result> = + purchaseManager.getSubscriptionProducts() suspend fun getManagementUrl(): Result = purchaseManager.getManagementUrl() - - suspend fun getFormattedMobileOnlySubscriptionPrice(): Result = - purchaseManager.getFormattedProductPrice(PlatformProductIdentifiers.MOBILE_ONLY_SUBSCRIPTION) - - suspend fun checkTrialEligibilityForMobileOnlySubscription(): Boolean = - purchaseManager.checkTrialEligibility(PlatformProductIdentifiers.MOBILE_ONLY_SUBSCRIPTION) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/HyperskillStoreProduct.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/HyperskillStoreProduct.kt new file mode 100644 index 0000000000..de713fbfa2 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/HyperskillStoreProduct.kt @@ -0,0 +1,3 @@ +package org.hyperskill.app.purchases.domain.model + +expect class HyperskillStoreProduct \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformProductIdentifiers.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformProductIdentifiers.kt index 215546b417..94fb6e2016 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformProductIdentifiers.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformProductIdentifiers.kt @@ -1,5 +1,5 @@ package org.hyperskill.app.purchases.domain.model internal expect object PlatformProductIdentifiers { - val MOBILE_ONLY_SUBSCRIPTION: String + val MOBILE_ONLY_MONTHLY_SUBSCRIPTION: String } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PurchaseManager.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PurchaseManager.kt index a191214d3f..20e1ccc423 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PurchaseManager.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PurchaseManager.kt @@ -24,19 +24,11 @@ interface PurchaseManager { * Makes purchase of the product with [productId]. */ suspend fun purchase( - productId: String, + storeProduct: HyperskillStoreProduct, platformPurchaseParams: PlatformPurchaseParams ): Result suspend fun getManagementUrl(): Result - /** - * Returns formatted product price with currency by [productId] - */ - suspend fun getFormattedProductPrice(productId: String): Result - - /** - * Checks if user is eligible for trial for the product with [productId] - */ - suspend fun checkTrialEligibility(productId: String): Boolean + suspend fun getSubscriptionProducts(): Result> } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/SubscriptionPeriod.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/SubscriptionPeriod.kt new file mode 100644 index 0000000000..e0b6da05d2 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/SubscriptionPeriod.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.purchases.domain.model + +enum class SubscriptionPeriod { + MONTH, + YEAR +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/SubscriptionProduct.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/SubscriptionProduct.kt new file mode 100644 index 0000000000..14d72a1bba --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/SubscriptionProduct.kt @@ -0,0 +1,9 @@ +package org.hyperskill.app.purchases.domain.model + +data class SubscriptionProduct( + val id: String, + val period: SubscriptionPeriod, + val formattedPrice: String, + val formattedPricePerMonth: String, + val storeProduct: HyperskillStoreProduct +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/Block.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/Block.kt index 4495d830b5..17043a89e8 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/Block.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/Block.kt @@ -3,6 +3,7 @@ package org.hyperskill.app.step.domain.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.hyperskill.app.code.domain.model.ProgrammingLanguage +import org.hyperskill.app.step_quiz_code_blanks.domain.model.template.CodeBlockTemplateEntry @Serializable data class Block( @@ -36,7 +37,9 @@ data class Block( @SerialName("code_blanks_operations") val codeBlanksOperations: List? = null, @SerialName("code_blanks_enabled") - val codeBlanksEnabled: Boolean? = null + val codeBlanksEnabled: Boolean? = null, + @SerialName("code_blanks_template") + val codeBlanksTemplate: List? = null ) { @Serializable data class File( diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedDecreaseIndentLevelHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedDecreaseIndentLevelHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..9875f2f6cd --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedDecreaseIndentLevelHyperskillAnalyticEvent.kt @@ -0,0 +1,41 @@ +package org.hyperskill.app.step_quiz_code_blanks.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 org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import ru.nobird.app.core.model.mapOfNotNull + +/** + * Represents click on the "Decrease indent level" button in the code block analytic event. + * + * JSON payload: + * ``` + * { + * "route": "/learn/step/1", + * "action": "click", + * "part": "code_blanks", + * "target": "decrease_indent_level", + * "context": + * { + * "code_block": "Print(isActive=true, suggestions=[ConstantString(text=suggestion)], selectedSuggestion=null)" + * } + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class StepQuizCodeBlanksClickedDecreaseIndentLevelHyperskillAnalyticEvent( + route: HyperskillAnalyticRoute, + codeBlock: CodeBlock? +) : HyperskillAnalyticEvent( + route = route, + action = HyperskillAnalyticAction.CLICK, + part = HyperskillAnalyticPart.CODE_BLANKS, + target = HyperskillAnalyticTarget.DECREASE_INDENT_LEVEL, + context = mapOfNotNull( + StepQuizCodeBlanksAnalyticParams.PARAM_CODE_BLOCK to codeBlock?.analyticRepresentation + ) +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/CodeBlock.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/CodeBlock.kt index 8faf3aced7..e304cea392 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/CodeBlock.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/CodeBlock.kt @@ -1,12 +1,17 @@ package org.hyperskill.app.step_quiz_code_blanks.domain.model import org.hyperskill.app.core.utils.indexOfFirstOrNull +import ru.nobird.app.core.model.cast sealed class CodeBlock { companion object; internal abstract val isActive: Boolean + internal abstract val indentLevel: Int + + internal abstract val isDeleteForbidden: Boolean + internal abstract val suggestions: List internal abstract val children: List @@ -21,19 +26,35 @@ sealed class CodeBlock { internal fun activeChildIndex(): Int? = children.indexOfFirstOrNull { it.isActive } + internal fun areAllChildrenUnselected(): Boolean = + children.all { it is CodeBlockChild.SelectSuggestion && it.selectedSuggestion == null } + + internal fun areAllChildrenSelected(): Boolean = + children.all { it is CodeBlockChild.SelectSuggestion && it.selectedSuggestion != null } + + internal fun hasAnySelectedChild(): Boolean = + children.any { it is CodeBlockChild.SelectSuggestion && it.selectedSuggestion != null } + + internal fun hasAnyUnselectedChild(): Boolean = + children.any { it is CodeBlockChild.SelectSuggestion && it.selectedSuggestion == null } + internal data class Blank( override val isActive: Boolean, + override val indentLevel: Int = 0, + override val isDeleteForbidden: Boolean = false, override val suggestions: List ) : CodeBlock() { override val children: List = emptyList() override val analyticRepresentation: String - get() = "Blank(isActive=$isActive, suggestions=$suggestions)" + get() = "Blank(isActive=$isActive, indentLevel=$indentLevel, suggestions=$suggestions)" override fun toReplyString(): String = "" } internal data class Print( + override val indentLevel: Int = 0, + override val isDeleteForbidden: Boolean = false, override val children: List ) : CodeBlock() { override val isActive: Boolean = false @@ -41,20 +62,23 @@ sealed class CodeBlock { override val suggestions: List = emptyList() override val analyticRepresentation: String = - "Print(children=$children)" + "Print(indentLevel=$indentLevel, children=$children)" override fun toReplyString(): String = buildString { + append(buildIndentString(indentLevel)) append("print(") append(joinChildrenToReplyString(children)) append(")") } override fun toString(): String = - "Print(children=$children)" + "Print(indentLevel=$indentLevel, children=$children)" } internal data class Variable( + override val indentLevel: Int = 0, + override val isDeleteForbidden: Boolean = false, override val children: List ) : CodeBlock() { val name: CodeBlockChild.SelectSuggestion? @@ -68,20 +92,89 @@ sealed class CodeBlock { override val suggestions: List = emptyList() override val analyticRepresentation: String - get() = "Variable(children=$children)" + get() = "Variable(indentLevel=$indentLevel, children=$children)" override fun toReplyString(): String = buildString { + append(buildIndentString(indentLevel)) append(name?.toReplyString() ?: "") append(" = ") append(joinChildrenToReplyString(values)) } override fun toString(): String = - "Variable(children=$children)" + "Variable(indentLevel=$indentLevel, children=$children)" + } + + internal data class IfStatement( + override val indentLevel: Int = 0, + override val isDeleteForbidden: Boolean = false, + override val children: List + ) : CodeBlock() { + override val isActive: Boolean = false + + override val suggestions: List = emptyList() + + override val analyticRepresentation: String + get() = "IfStatement(indentLevel=$indentLevel, children=$children)" + + override fun toReplyString(): String = + buildString { + append(buildIndentString(indentLevel)) + append("if ") + append(joinChildrenToReplyString(children)) + append(":") + } + + override fun toString(): String = + "IfStatement(indentLevel=$indentLevel, children=$children)" + } + + internal data class ElifStatement( + override val indentLevel: Int = 0, + override val isDeleteForbidden: Boolean = false, + override val children: List + ) : CodeBlock() { + override val isActive: Boolean = false + + override val suggestions: List = emptyList() + + override val analyticRepresentation: String + get() = "ElifStatement(indentLevel=$indentLevel, children=$children)" + + override fun toReplyString(): String = + buildString { + append("elif ") + append(joinChildrenToReplyString(children)) + append(":") + } + + override fun toString(): String = + "ElifStatement(indentLevel=$indentLevel, children=$children)" + } + + internal data class ElseStatement( + override val isActive: Boolean, + override val indentLevel: Int = 0, + override val isDeleteForbidden: Boolean = false + ) : CodeBlock() { + override val suggestions: List = emptyList() + + override val children: List = emptyList() + + override val analyticRepresentation: String + get() = "ElseStatement(isActive=$isActive, indentLevel=$indentLevel)" + + override fun toReplyString(): String = "else:" + + override fun toString(): String = + "ElseStatement(isActive=$isActive, indentLevel=$indentLevel)" } } +internal fun CodeBlock.Companion.buildIndentString(indentLevel: Int): String = + "\t".repeat(indentLevel) + internal fun CodeBlock.Companion.joinChildrenToReplyString(children: List): String = buildString { children.forEachIndexed { index, child -> @@ -100,4 +193,24 @@ internal fun CodeBlock.Companion.joinChildrenToReplyString(children: List): CodeBlock = + when (this) { + is CodeBlock.Blank, + is CodeBlock.ElseStatement -> this + is CodeBlock.Print -> copy(children = children.cast()) + is CodeBlock.Variable -> copy(children = children.cast()) + is CodeBlock.IfStatement -> copy(children = children.cast()) + is CodeBlock.ElifStatement -> copy(children = children.cast()) + } + +internal fun CodeBlock.updatedIndentLevel(indentLevel: Int): CodeBlock = + when (this) { + is CodeBlock.Blank -> copy(indentLevel = indentLevel) + is CodeBlock.Print -> copy(indentLevel = indentLevel) + is CodeBlock.Variable -> copy(indentLevel = indentLevel) + is CodeBlock.IfStatement -> copy(indentLevel = indentLevel) + is CodeBlock.ElifStatement -> copy(indentLevel = indentLevel) + is CodeBlock.ElseStatement -> copy(indentLevel = indentLevel) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/Suggestion.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/Suggestion.kt index 219daf9b49..9ff56fa42e 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/Suggestion.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/Suggestion.kt @@ -19,6 +19,27 @@ sealed class Suggestion { "Variable(text='$text')" } + data object IfStatement : Suggestion() { + override val text: String = "if" + + override val analyticRepresentation: String = + "IfStatement(text='$text')" + } + + data object ElifStatement : Suggestion() { + override val text: String = "elif" + + override val analyticRepresentation: String = + "ElifStatement(text='$text')" + } + + data object ElseStatement : Suggestion() { + override val text: String = "else" + + override val analyticRepresentation: String = + "ElseStatement(text='$text')" + } + data class ConstantString( override val text: String ) : Suggestion() { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/template/CodeBlanksTemplateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/template/CodeBlanksTemplateMapper.kt new file mode 100644 index 0000000000..a9ad516116 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/template/CodeBlanksTemplateMapper.kt @@ -0,0 +1,197 @@ +package org.hyperskill.app.step_quiz_code_blanks.domain.model.template + +import kotlin.math.max +import org.hyperskill.app.step.domain.model.Step +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksResolver +import org.hyperskill.app.step_quiz_code_blanks.presentation.codeBlanksOperationsSuggestions +import org.hyperskill.app.step_quiz_code_blanks.presentation.codeBlanksStringsSuggestions +import org.hyperskill.app.step_quiz_code_blanks.presentation.codeBlanksVariablesSuggestions + +internal object CodeBlanksTemplateMapper { + private const val MATH_EXPRESSIONS_TEMPLATE_STEP_ID = 47580L // ALTAPPS-1324 + + fun map(step: Step): List = + when { + step.id == MATH_EXPRESSIONS_TEMPLATE_STEP_ID -> createMathExpressionsCodeBlocks(step) + isCodeBlanksTemplateAvailable(step) -> parseCodeBlanksTemplate(step) + else -> emptyList() + } + + private fun isCodeBlanksTemplateAvailable(step: Step): Boolean { + val codeBlockTemplateEntries = step.block.options.codeBlanksTemplate ?: return false + return codeBlockTemplateEntries.none { it.type == CodeBlockTemplateEntryType.UNKNOWN } + } + + private fun parseCodeBlanksTemplate(step: Step): List { + val codeBlockTemplateEntries = step.block.options.codeBlanksTemplate + ?.filter { it.type != CodeBlockTemplateEntryType.UNKNOWN } + return if (codeBlockTemplateEntries.isNullOrEmpty()) { + emptyList() + } else { + codeBlockTemplateEntries.map { mapCodeBlockTemplateEntry(entry = it, step = step) } + } + } + + private fun mapCodeBlockTemplateEntry( + entry: CodeBlockTemplateEntry, + step: Step + ): CodeBlock = + when (entry.type) { + CodeBlockTemplateEntryType.BLANK -> + CodeBlock.Blank( + isActive = entry.isActive, + indentLevel = entry.indentLevel, + isDeleteForbidden = entry.isDeleteForbidden, + suggestions = StepQuizCodeBlanksResolver.getSuggestionsForBlankCodeBlock( + isVariableSuggestionAvailable = StepQuizCodeBlanksResolver.isVariableSuggestionsAvailable(step) + ) + ) + CodeBlockTemplateEntryType.PRINT -> + CodeBlock.Print( + indentLevel = entry.indentLevel, + isDeleteForbidden = entry.isDeleteForbidden, + children = mapCodeBlockTemplateEntryChildren( + entry = entry, + suggestions = getChildrenSuggestions(step), + minimumRequiredChildrenCount = 1 + ) + ) + CodeBlockTemplateEntryType.VARIABLE -> + CodeBlock.Variable( + indentLevel = entry.indentLevel, + isDeleteForbidden = entry.isDeleteForbidden, + children = mapCodeBlockTemplateEntryChildren( + entry = entry, + suggestions = getChildrenSuggestions(step), + minimumRequiredChildrenCount = 2 + ) + ) + CodeBlockTemplateEntryType.IF -> + CodeBlock.IfStatement( + indentLevel = entry.indentLevel, + isDeleteForbidden = entry.isDeleteForbidden, + children = mapCodeBlockTemplateEntryChildren( + entry = entry, + suggestions = getChildrenSuggestions(step), + minimumRequiredChildrenCount = 1 + ) + ) + CodeBlockTemplateEntryType.ELIF -> + CodeBlock.ElifStatement( + indentLevel = entry.indentLevel, + isDeleteForbidden = entry.isDeleteForbidden, + children = mapCodeBlockTemplateEntryChildren( + entry = entry, + suggestions = getChildrenSuggestions(step), + minimumRequiredChildrenCount = 1 + ) + ) + CodeBlockTemplateEntryType.ELSE -> + CodeBlock.ElseStatement( + isActive = entry.isActive, + indentLevel = entry.indentLevel, + isDeleteForbidden = entry.isDeleteForbidden, + ) + CodeBlockTemplateEntryType.UNKNOWN -> error("Unknown code block template entry type") + } + + private fun getChildrenSuggestions(step: Step): List = + step.codeBlanksVariablesSuggestions() + step.codeBlanksStringsSuggestions() + + step.codeBlanksOperationsSuggestions() + + private fun mapCodeBlockTemplateEntryChildren( + entry: CodeBlockTemplateEntry, + suggestions: List, + minimumRequiredChildrenCount: Int + ): List { + val mappedChildren = entry.children.map { text -> + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = suggestions, + selectedSuggestion = Suggestion.ConstantString(text) + ) + } + + val missingChildrenCount = max(0, minimumRequiredChildrenCount - mappedChildren.size) + val missingChildren = List(missingChildrenCount) { + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = suggestions, + selectedSuggestion = null + ) + } + + val completeChildren = mappedChildren + missingChildren + + return if (entry.isActive) { + completeChildren.mapIndexed { index, child -> + child.copy(isActive = entry.isActive && index == 0) + } + } else { + completeChildren + } + } + + private fun createMathExpressionsCodeBlocks(step: Step): List = + listOf( + CodeBlock.Variable( + indentLevel = 0, + isDeleteForbidden = true, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = step.codeBlanksVariablesSuggestions(), + selectedSuggestion = Suggestion.ConstantString("x") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = step.codeBlanksStringsSuggestions(), + selectedSuggestion = Suggestion.ConstantString("1000") + ) + ) + ), + CodeBlock.Variable( + indentLevel = 0, + isDeleteForbidden = true, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = step.codeBlanksVariablesSuggestions(), + selectedSuggestion = Suggestion.ConstantString("r") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = step.codeBlanksStringsSuggestions(), + selectedSuggestion = Suggestion.ConstantString("5") + ) + ) + ), + CodeBlock.Variable( + indentLevel = 0, + isDeleteForbidden = true, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = step.codeBlanksVariablesSuggestions(), + selectedSuggestion = Suggestion.ConstantString("y") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = step.codeBlanksStringsSuggestions(), + selectedSuggestion = Suggestion.ConstantString("10") + ) + ) + ), + CodeBlock.Blank( + isActive = true, + indentLevel = 0, + isDeleteForbidden = false, + suggestions = StepQuizCodeBlanksResolver.getSuggestionsForBlankCodeBlock( + isVariableSuggestionAvailable = StepQuizCodeBlanksResolver.isVariableSuggestionsAvailable(step) + ) + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/template/CodeBlockTemplateEntry.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/template/CodeBlockTemplateEntry.kt new file mode 100644 index 0000000000..c0623c7d13 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/template/CodeBlockTemplateEntry.kt @@ -0,0 +1,18 @@ +package org.hyperskill.app.step_quiz_code_blanks.domain.model.template + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CodeBlockTemplateEntry( + @SerialName("type") + val type: CodeBlockTemplateEntryType = CodeBlockTemplateEntryType.UNKNOWN, + @SerialName("indent_level") + val indentLevel: Int = 0, + @SerialName("is_active") + val isActive: Boolean = false, + @SerialName("delete_forbidden") + val isDeleteForbidden: Boolean = false, + @SerialName("children") + val children: List = emptyList() +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/template/CodeBlockTemplateEntryType.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/template/CodeBlockTemplateEntryType.kt new file mode 100644 index 0000000000..33ca85bc46 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/template/CodeBlockTemplateEntryType.kt @@ -0,0 +1,22 @@ +package org.hyperskill.app.step_quiz_code_blanks.domain.model.template + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class CodeBlockTemplateEntryType { + @SerialName("blank") + BLANK, + @SerialName("print") + PRINT, + @SerialName("variable") + VARIABLE, + @SerialName("if") + IF, + @SerialName("elif") + ELIF, + @SerialName("else") + ELSE, + + UNKNOWN +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksFeature.kt index 0e1c8f4025..6e75accad5 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksFeature.kt @@ -8,17 +8,9 @@ import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksF import org.hyperskill.app.step_quiz_code_blanks.view.model.StepQuizCodeBlanksViewState object StepQuizCodeBlanksFeature { - private const val ONBOARDING_STEP_ID = 47329L - internal fun isCodeBlanksFeatureAvailable(step: Step): Boolean = step.block.options.codeBlanksEnabled == true - internal fun isVariableSuggestionsAvailable(step: Step): Boolean = - step.block.options.codeBlanksVariables?.isNotEmpty() == true - - internal fun isOnboardingAvailable(step: Step): Boolean = - step.id == ONBOARDING_STEP_ID - internal fun initialState(): State = State.Idle sealed interface State { @@ -29,12 +21,17 @@ object StepQuizCodeBlanksFeature { val codeBlocks: List, val onboardingState: OnboardingState = OnboardingState.Unavailable ) : State { + companion object; + internal val codeBlanksStringsSuggestions: List = step.codeBlanksStringsSuggestions() internal val codeBlanksVariablesSuggestions: List = step.codeBlanksVariablesSuggestions() + internal val codeBlanksVariablesAndStringsSuggestions: List = + codeBlanksVariablesSuggestions + codeBlanksStringsSuggestions + internal val codeBlanksOperationsSuggestions: List = step.codeBlanksOperationsSuggestions() } @@ -58,6 +55,7 @@ object StepQuizCodeBlanksFeature { data object DeleteButtonClicked : Message data object EnterButtonClicked : Message data object SpaceButtonClicked : Message + data object DecreaseIndentLevelButtonClicked : Message } internal sealed interface InternalMessage : Message { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducer.kt index 8c7884ffbe..afb5196dac 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducer.kt @@ -5,6 +5,7 @@ import org.hyperskill.app.step.domain.model.Step import org.hyperskill.app.step.domain.model.StepRoute import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedCodeBlockChildHyperskillAnalyticEvent import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent +import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedDecreaseIndentLevelHyperskillAnalyticEvent import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedEnterHyperskillAnalyticEvent import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedSpaceHyperskillAnalyticEvent @@ -12,13 +13,15 @@ import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlan import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.domain.model.template.CodeBlanksTemplateMapper +import org.hyperskill.app.step_quiz_code_blanks.domain.model.updatedChildren +import org.hyperskill.app.step_quiz_code_blanks.domain.model.updatedIndentLevel import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature.Action import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature.InternalAction import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature.InternalMessage import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature.Message import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature.OnboardingState import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature.State -import ru.nobird.app.core.model.cast import ru.nobird.app.core.model.mutate import ru.nobird.app.presentation.redux.reducer.StateReducer @@ -36,6 +39,7 @@ class StepQuizCodeBlanksReducer( Message.DeleteButtonClicked -> handleDeleteButtonClicked(state) Message.EnterButtonClicked -> handleEnterButtonClicked(state) Message.SpaceButtonClicked -> handleSpaceButtonClicked(state) + Message.DecreaseIndentLevelButtonClicked -> handleDecreaseIndentLevelButtonClicked(state) } ?: (state to emptySet()) private fun initialize( @@ -44,7 +48,7 @@ class StepQuizCodeBlanksReducer( State.Content( step = message.step, codeBlocks = createInitialCodeBlocks(step = message.step), - onboardingState = if (StepQuizCodeBlanksFeature.isOnboardingAvailable(message.step)) { + onboardingState = if (StepQuizCodeBlanksResolver.isOnboardingAvailable(message.step)) { OnboardingState.HighlightSuggestions } else { OnboardingState.Unavailable @@ -60,38 +64,39 @@ class StepQuizCodeBlanksReducer( } val activeCodeBlockIndex = state.activeCodeBlockIndex() + val activeCodeBlock = activeCodeBlockIndex?.let { state.codeBlocks[it] } val actions = setOf( InternalAction.LogAnalyticEvent( StepQuizCodeBlanksClickedSuggestionHyperskillAnalyticEvent( route = stepRoute.analyticRoute, - codeBlock = activeCodeBlockIndex?.let { state.codeBlocks[it] }, + codeBlock = activeCodeBlock, suggestion = message.suggestion ) ) ) - if (activeCodeBlockIndex == null) { + if (activeCodeBlock == null) { return state to actions } - val activeCodeBlock = state.codeBlocks[activeCodeBlockIndex] val newCodeBlock = when (activeCodeBlock) { is CodeBlock.Blank -> when (message.suggestion) { Suggestion.Print -> CodeBlock.Print( + indentLevel = activeCodeBlock.indentLevel, children = listOf( CodeBlockChild.SelectSuggestion( isActive = true, - suggestions = state.codeBlanksVariablesSuggestions + - state.codeBlanksStringsSuggestions, + suggestions = state.codeBlanksVariablesAndStringsSuggestions, selectedSuggestion = null ) ) ) Suggestion.Variable -> CodeBlock.Variable( + indentLevel = activeCodeBlock.indentLevel, children = listOf( CodeBlockChild.SelectSuggestion( isActive = true, @@ -105,22 +110,54 @@ class StepQuizCodeBlanksReducer( ) ) ) - else -> activeCodeBlock + Suggestion.IfStatement -> + CodeBlock.IfStatement( + indentLevel = activeCodeBlock.indentLevel, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = state.codeBlanksVariablesAndStringsSuggestions, + selectedSuggestion = null + ) + ) + ) + Suggestion.ElifStatement -> + CodeBlock.ElifStatement( + indentLevel = activeCodeBlock.indentLevel, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = state.codeBlanksVariablesAndStringsSuggestions, + selectedSuggestion = null + ) + ) + ) + Suggestion.ElseStatement -> + CodeBlock.ElseStatement( + isActive = false, + indentLevel = activeCodeBlock.indentLevel + ) + is Suggestion.ConstantString -> activeCodeBlock } - is CodeBlock.Print -> { + + is CodeBlock.Print, + is CodeBlock.IfStatement, + is CodeBlock.ElifStatement -> { activeCodeBlock.activeChildIndex()?.let { activeChildIndex -> - activeCodeBlock.copy( - children = activeCodeBlock.children.mutate { + val activeChild = activeCodeBlock.children[activeChildIndex] as CodeBlockChild.SelectSuggestion + val newChildren = activeCodeBlock.children + .mutate { set( activeChildIndex, - activeCodeBlock.children[activeChildIndex].copy( + activeChild.copy( selectedSuggestion = message.suggestion as? Suggestion.ConstantString ) ) } - ) + activeCodeBlock.updatedChildren(newChildren) } ?: activeCodeBlock } + is CodeBlock.Variable -> { activeCodeBlock.activeChildIndex()?.let { activeChildIndex -> activeCodeBlock.copy( @@ -147,13 +184,35 @@ class StepQuizCodeBlanksReducer( ) } ?: activeCodeBlock } + + is CodeBlock.ElseStatement -> activeCodeBlock } - val newCodeBlocks = state.codeBlocks.mutate { set(activeCodeBlockIndex, newCodeBlock) } + val newCodeBlocks = state.codeBlocks.mutate { + set(activeCodeBlockIndex, newCodeBlock) + + if (newCodeBlock is CodeBlock.ElseStatement && activeCodeBlock !== newCodeBlock) { + val blankInsertIndex = activeCodeBlockIndex + 1 + val blankIndentLevel = newCodeBlock.indentLevel + 1 + add( + blankInsertIndex, + CodeBlock.Blank( + isActive = true, + indentLevel = blankIndentLevel, + suggestions = StepQuizCodeBlanksResolver.getSuggestionsForBlankCodeBlock( + index = blankInsertIndex, + indentLevel = blankIndentLevel, + codeBlocks = this, + isVariableSuggestionAvailable = state.isVariableSuggestionsAvailable + ) + ) + ) + } + } val isFulfilledOnboardingPrintCodeBlock = state.onboardingState is OnboardingState.HighlightSuggestions && - activeCodeBlock is CodeBlock.Print && activeCodeBlock.children.any { it.selectedSuggestion == null } && - newCodeBlock is CodeBlock.Print && newCodeBlock.children.all { it.selectedSuggestion != null } + activeCodeBlock is CodeBlock.Print && activeCodeBlock.hasAnyUnselectedChild() && + newCodeBlock is CodeBlock.Print && newCodeBlock.areAllChildrenSelected() val (onboardingState, onboardingActions) = if (isFulfilledOnboardingPrintCodeBlock) { OnboardingState.HighlightCallToActionButton to @@ -228,7 +287,9 @@ class StepQuizCodeBlanksReducer( val newChildren = when (targetCodeBlock) { is CodeBlock.Print, - is CodeBlock.Variable -> { + is CodeBlock.Variable, + is CodeBlock.IfStatement, + is CodeBlock.ElifStatement -> { targetCodeBlock.children.mapIndexed { index, child -> require(child is CodeBlockChild.SelectSuggestion) if (index == message.codeBlockChildItem.id) { @@ -238,7 +299,9 @@ class StepQuizCodeBlanksReducer( } } } - else -> null + null, + is CodeBlock.Blank, + is CodeBlock.ElseStatement -> null } val newCodeBlocks = state.codeBlocks.mutate { @@ -250,11 +313,7 @@ class StepQuizCodeBlanksReducer( targetCodeBlock?.let { targetCodeBlock -> set( targetCodeBlockIndex, - when (targetCodeBlock) { - is CodeBlock.Print -> targetCodeBlock.copy(children = newChildren) - is CodeBlock.Variable -> targetCodeBlock.copy(children = newChildren) - else -> targetCodeBlock - } + targetCodeBlock.updatedChildren(newChildren) ) } } @@ -271,17 +330,18 @@ class StepQuizCodeBlanksReducer( } val activeCodeBlockIndex = state.activeCodeBlockIndex() + val activeCodeBlock = activeCodeBlockIndex?.let { state.codeBlocks[it] } val actions = setOf( InternalAction.LogAnalyticEvent( StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent( route = stepRoute.analyticRoute, - codeBlock = activeCodeBlockIndex?.let { state.codeBlocks[it] } + codeBlock = activeCodeBlock ) ) ) - if (activeCodeBlockIndex == null) { + if (activeCodeBlock == null || activeCodeBlock.isDeleteForbidden) { return state to actions } @@ -298,17 +358,28 @@ class StepQuizCodeBlanksReducer( } removeAt(activeCodeBlockIndex) } - val replaceActiveCodeWithBlank = { + val replaceActiveCodeBlockWithBlank = { set( activeCodeBlockIndex, - createBlankCodeBlock( + CodeBlock.Blank( isActive = true, - isVariableSuggestionAvailable = state.isVariableSuggestionsAvailable + indentLevel = activeCodeBlock.indentLevel, + suggestions = StepQuizCodeBlanksResolver.getSuggestionsForBlankCodeBlock( + index = activeCodeBlockIndex, + indentLevel = activeCodeBlock.indentLevel, + codeBlocks = this, + isVariableSuggestionAvailable = state.isVariableSuggestionsAvailable + ) ) ) } - when (val activeCodeBlock = state.codeBlocks[activeCodeBlockIndex]) { + val isNextCodeBlockHasSameIndentLevelOrTrue = state.codeBlocks + .getOrNull(activeCodeBlockIndex + 1) + ?.let { it.indentLevel == activeCodeBlock.indentLevel } + ?: true + + when (activeCodeBlock) { is CodeBlock.Blank -> { if (state.codeBlocks.size > 1) { removeActiveCodeBlockAndSetNextActive() @@ -350,7 +421,7 @@ class StepQuizCodeBlanksReducer( removeActiveCodeBlockAndSetNextActive() else -> - replaceActiveCodeWithBlank() + replaceActiveCodeBlockWithBlank() } } is CodeBlock.Variable -> { @@ -385,12 +456,67 @@ class StepQuizCodeBlanksReducer( ) ) - activeCodeBlock.children.all { it.selectedSuggestion == null } -> + activeChildIndex == 0 || activeCodeBlock.areAllChildrenUnselected() -> + if (state.codeBlocks.size > 1) { + removeActiveCodeBlockAndSetNextActive() + } else { + replaceActiveCodeBlockWithBlank() + } + } + } + is CodeBlock.IfStatement, + is CodeBlock.ElifStatement -> { + val activeChildIndex = activeCodeBlock.activeChildIndex() ?: return@mutate + val activeChild = activeCodeBlock.children[activeChildIndex] as CodeBlockChild.SelectSuggestion + + when { + activeChild.selectedSuggestion != null -> { + val newChildren = activeCodeBlock.children.mutate { + set( + activeChildIndex, + activeChild.copy(selectedSuggestion = null) + ) + } + set( + activeCodeBlockIndex, + activeCodeBlock.updatedChildren(newChildren) + ) + } + + activeChildIndex > 0 -> { + val newChildren = activeCodeBlock.children.mutate { + val previousChildIndex = activeChildIndex - 1 + val previousChild = this[previousChildIndex] as CodeBlockChild.SelectSuggestion + set( + previousChildIndex, + previousChild.copy(isActive = true) + ) + + removeAt(activeChildIndex) + } + set( + activeCodeBlockIndex, + activeCodeBlock.updatedChildren(newChildren) + ) + } + + (activeChildIndex == 0 || activeCodeBlock.areAllChildrenUnselected()) && + isNextCodeBlockHasSameIndentLevelOrTrue -> { if (state.codeBlocks.size > 1) { removeActiveCodeBlockAndSetNextActive() } else { - replaceActiveCodeWithBlank() + replaceActiveCodeBlockWithBlank() } + } + } + } + is CodeBlock.ElseStatement -> { + if (isNextCodeBlockHasSameIndentLevelOrTrue) { + if (state.codeBlocks.size > 1) { + removeActiveCodeBlockAndSetNextActive() + } else { + replaceActiveCodeBlockWithBlank() + } } } } @@ -407,30 +533,48 @@ class StepQuizCodeBlanksReducer( } val activeCodeBlockIndex = state.activeCodeBlockIndex() + val activeCodeBlock = activeCodeBlockIndex?.let { state.codeBlocks[it] } val actions = setOf( InternalAction.LogAnalyticEvent( StepQuizCodeBlanksClickedEnterHyperskillAnalyticEvent( route = stepRoute.analyticRoute, - codeBlock = activeCodeBlockIndex?.let { state.codeBlocks[it] } + codeBlock = activeCodeBlock ) ) ) - return if (activeCodeBlockIndex != null) { + return if (activeCodeBlock != null) { + val indentLevel = + when (activeCodeBlock) { + is CodeBlock.IfStatement, + is CodeBlock.ElifStatement, + is CodeBlock.ElseStatement -> activeCodeBlock.indentLevel + 1 + else -> activeCodeBlock.indentLevel + } + val newCodeBlocks = state.codeBlocks.mutate { set( activeCodeBlockIndex, setCodeBlockIsActive(codeBlock = state.codeBlocks[activeCodeBlockIndex], isActive = false) ) + + val insertIndex = activeCodeBlockIndex + 1 add( - activeCodeBlockIndex + 1, - createBlankCodeBlock( + insertIndex, + CodeBlock.Blank( isActive = true, - isVariableSuggestionAvailable = state.isVariableSuggestionsAvailable + indentLevel = indentLevel, + suggestions = StepQuizCodeBlanksResolver.getSuggestionsForBlankCodeBlock( + index = insertIndex, + indentLevel = indentLevel, + codeBlocks = this, + isVariableSuggestionAvailable = state.isVariableSuggestionsAvailable + ) ) ) } + state.copy(codeBlocks = newCodeBlocks) to actions } else { state to actions @@ -445,24 +589,26 @@ class StepQuizCodeBlanksReducer( } val activeCodeBlockIndex = state.activeCodeBlockIndex() + val activeCodeBlock = activeCodeBlockIndex?.let { state.codeBlocks[it] } val actions = setOf( InternalAction.LogAnalyticEvent( StepQuizCodeBlanksClickedSpaceHyperskillAnalyticEvent( route = stepRoute.analyticRoute, - codeBlock = activeCodeBlockIndex?.let { state.codeBlocks[it] } + codeBlock = activeCodeBlock ) ) ) - if (activeCodeBlockIndex == null) { + if (activeCodeBlock == null) { return state to actions } - val activeCodeBlock = state.codeBlocks[activeCodeBlockIndex] val newChildren = when (activeCodeBlock) { is CodeBlock.Print, - is CodeBlock.Variable -> { + is CodeBlock.Variable, + is CodeBlock.IfStatement, + is CodeBlock.ElifStatement -> { activeCodeBlock.activeChildIndex()?.let { activeChildIndex -> val activeChild = activeCodeBlock.children[activeChildIndex] as CodeBlockChild.SelectSuggestion @@ -489,26 +635,21 @@ class StepQuizCodeBlanksReducer( selectedSuggestion = null ) - activeCodeBlock.children - .mutate { - set(activeChildIndex, activeChild.copy(isActive = false)) - add(activeChildIndex + 1, newChild) - } - .cast>() + activeCodeBlock.children.mutate { + set(activeChildIndex, activeChild.copy(isActive = false)) + add(activeChildIndex + 1, newChild) + } } } - else -> null + is CodeBlock.Blank, + is CodeBlock.ElseStatement -> null } val newCodeBlocks = state.codeBlocks.mutate { newChildren?.let { set( activeCodeBlockIndex, - when (activeCodeBlock) { - is CodeBlock.Print -> activeCodeBlock.copy(children = newChildren) - is CodeBlock.Variable -> activeCodeBlock.copy(children = newChildren) - else -> activeCodeBlock - } + activeCodeBlock.updatedChildren(newChildren) ) } } @@ -516,11 +657,59 @@ class StepQuizCodeBlanksReducer( return state.copy(codeBlocks = newCodeBlocks) to actions } + private fun handleDecreaseIndentLevelButtonClicked( + state: State + ): StepQuizCodeBlanksReducerResult? { + if (state !is State.Content) { + return null + } + + val activeCodeBlockIndex = state.activeCodeBlockIndex() + val activeCodeBlock = activeCodeBlockIndex?.let { state.codeBlocks[it] } + + val actions = setOf( + InternalAction.LogAnalyticEvent( + StepQuizCodeBlanksClickedDecreaseIndentLevelHyperskillAnalyticEvent( + route = stepRoute.analyticRoute, + codeBlock = activeCodeBlock + ) + ) + ) + + if (activeCodeBlock == null || activeCodeBlock.indentLevel < 1) { + return state to actions + } + val newIndentLevel = activeCodeBlock.indentLevel - 1 + + return state.copy( + codeBlocks = state.codeBlocks.mutate { + set( + activeCodeBlockIndex, + when (activeCodeBlock) { + is CodeBlock.Blank -> activeCodeBlock.copy( + indentLevel = newIndentLevel, + suggestions = StepQuizCodeBlanksResolver.getSuggestionsForBlankCodeBlock( + index = activeCodeBlockIndex, + indentLevel = newIndentLevel, + codeBlocks = this, + isVariableSuggestionAvailable = state.isVariableSuggestionsAvailable + ) + ) + else -> activeCodeBlock.updatedIndentLevel(newIndentLevel) + } + ) + } + ) to actions + } + private fun setCodeBlockIsActive(codeBlock: CodeBlock, isActive: Boolean): CodeBlock = when (codeBlock) { is CodeBlock.Blank -> codeBlock.copy(isActive = isActive) + is CodeBlock.ElseStatement -> codeBlock.copy(isActive = isActive) + is CodeBlock.Print, is CodeBlock.Variable, - is CodeBlock.Print -> { + is CodeBlock.IfStatement, + is CodeBlock.ElifStatement -> { if (isActive) { if (codeBlock.activeChild() != null) { codeBlock @@ -533,95 +722,30 @@ class StepQuizCodeBlanksReducer( child.copy(isActive = false) } } - when (codeBlock) { - is CodeBlock.Print -> codeBlock.copy(children = newChildren) - is CodeBlock.Variable -> codeBlock.copy(children = newChildren) - else -> codeBlock - } + codeBlock.updatedChildren(newChildren) } } else { val newChildren = codeBlock.children.map { child -> require(child is CodeBlockChild.SelectSuggestion) child.copy(isActive = false) } - when (codeBlock) { - is CodeBlock.Print -> codeBlock.copy(children = newChildren) - is CodeBlock.Variable -> codeBlock.copy(children = newChildren) - else -> codeBlock - } + codeBlock.updatedChildren(newChildren) } } } - private fun createBlankCodeBlock( - isActive: Boolean, - isVariableSuggestionAvailable: Boolean - ): CodeBlock.Blank = - CodeBlock.Blank( - isActive = isActive, - suggestions = if (isVariableSuggestionAvailable) { - listOf(Suggestion.Print, Suggestion.Variable) - } else { - listOf(Suggestion.Print) - } - ) - - private fun createInitialCodeBlocks(step: Step): List = - if (step.id == 47580L) { - listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = step.codeBlanksVariablesSuggestions(), - selectedSuggestion = Suggestion.ConstantString("x") - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = step.codeBlanksStringsSuggestions(), - selectedSuggestion = Suggestion.ConstantString("1000") - ) - ) - ), - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = step.codeBlanksVariablesSuggestions(), - selectedSuggestion = Suggestion.ConstantString("r") - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = step.codeBlanksStringsSuggestions(), - selectedSuggestion = Suggestion.ConstantString("5") - ) - ) - ), - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = step.codeBlanksVariablesSuggestions(), - selectedSuggestion = Suggestion.ConstantString("y") - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = step.codeBlanksStringsSuggestions(), - selectedSuggestion = Suggestion.ConstantString("10") - ) - ) - ), - createBlankCodeBlock( - isActive = true, - isVariableSuggestionAvailable = StepQuizCodeBlanksFeature.isVariableSuggestionsAvailable(step) - ) - ) - } else { + private fun createInitialCodeBlocks(step: Step): List { + val templateCodeBlocks = CodeBlanksTemplateMapper.map(step) + return templateCodeBlocks.ifEmpty { listOf( - createBlankCodeBlock( + CodeBlock.Blank( isActive = true, - isVariableSuggestionAvailable = StepQuizCodeBlanksFeature.isVariableSuggestionsAvailable(step) + indentLevel = 0, + suggestions = StepQuizCodeBlanksResolver.getSuggestionsForBlankCodeBlock( + isVariableSuggestionAvailable = StepQuizCodeBlanksResolver.isVariableSuggestionsAvailable(step) + ) ) ) } + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksResolver.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksResolver.kt new file mode 100644 index 0000000000..6e5e877c29 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksResolver.kt @@ -0,0 +1,56 @@ +package org.hyperskill.app.step_quiz_code_blanks.presentation + +import org.hyperskill.app.step.domain.model.Step +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import ru.nobird.app.core.model.slice + +internal object StepQuizCodeBlanksResolver { + private const val ONBOARDING_STEP_ID = 47329L + + private const val MINIMUM_POSSIBLE_INDEX_FOR_ELIF_AND_ELSE_STATEMENTS = 2 + + fun isOnboardingAvailable(step: Step): Boolean = + step.id == ONBOARDING_STEP_ID + + fun isVariableSuggestionsAvailable(step: Step): Boolean = + step.block.options.codeBlanksVariables?.isNotEmpty() == true + + fun getSuggestionsForBlankCodeBlock( + index: Int = -1, + indentLevel: Int = 0, + codeBlocks: List = emptyList(), + isVariableSuggestionAvailable: Boolean + ): List = + when { + areElifAndElseStatementsSuggestionsAvailable(index, indentLevel, codeBlocks) -> + listOf(Suggestion.Print, Suggestion.Variable, Suggestion.ElifStatement, Suggestion.ElseStatement) + + isVariableSuggestionAvailable -> + listOf(Suggestion.Print, Suggestion.Variable, Suggestion.IfStatement) + + else -> + listOf(Suggestion.Print) + } + + fun areElifAndElseStatementsSuggestionsAvailable( + index: Int, + indentLevel: Int, + codeBlocks: List + ): Boolean { + if (index < MINIMUM_POSSIBLE_INDEX_FOR_ELIF_AND_ELSE_STATEMENTS || codeBlocks.isEmpty()) { + return false + } + + val previousCodeBlock = codeBlocks + .slice(to = index) + .reversed() + .firstOrNull { it.indentLevel == indentLevel } + + return when (previousCodeBlock) { + is CodeBlock.IfStatement, + is CodeBlock.ElifStatement -> true + else -> false + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksStateExtensions.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksStateExtensions.kt index 46710ef6d1..a55f0f21c9 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksStateExtensions.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksStateExtensions.kt @@ -10,7 +10,7 @@ internal fun StepQuizCodeBlanksFeature.State.Content.activeCodeBlockIndex(): Int internal val StepQuizCodeBlanksFeature.State.isVariableSuggestionsAvailable: Boolean get() = (this as? StepQuizCodeBlanksFeature.State.Content)?.step?.let { - StepQuizCodeBlanksFeature.isVariableSuggestionsAvailable(it) + StepQuizCodeBlanksResolver.isVariableSuggestionsAvailable(it) } ?: false fun StepQuizCodeBlanksFeature.State.Content.createReply(): Reply = diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/view/mapper/StepQuizCodeBlanksViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/view/mapper/StepQuizCodeBlanksViewStateMapper.kt index fc176599a1..60fab28ceb 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/view/mapper/StepQuizCodeBlanksViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/view/mapper/StepQuizCodeBlanksViewStateMapper.kt @@ -17,13 +17,19 @@ object StepQuizCodeBlanksViewStateMapper { state: StepQuizCodeBlanksFeature.State.Content ): StepQuizCodeBlanksViewState.Content { val codeBlocks = state.codeBlocks.mapIndexed(::mapCodeBlock) - val activeCodeBlock = state.activeCodeBlockIndex()?.let { state.codeBlocks[it] } + + val activeCodeBlockIndex = state.activeCodeBlockIndex() + val activeCodeBlock = activeCodeBlockIndex?.let { state.codeBlocks[it] } val suggestions = when (activeCodeBlock) { - is CodeBlock.Blank -> activeCodeBlock.suggestions + is CodeBlock.Blank -> + activeCodeBlock.suggestions + is CodeBlock.Print, - is CodeBlock.Variable -> + is CodeBlock.Variable, + is CodeBlock.IfStatement, + is CodeBlock.ElifStatement -> (activeCodeBlock.activeChild() as? CodeBlockChild.SelectSuggestion)?.let { if (it.selectedSuggestion == null) { it.suggestions @@ -31,33 +37,58 @@ object StepQuizCodeBlanksViewStateMapper { emptyList() } } - null -> emptyList() + + null, + is CodeBlock.ElseStatement -> emptyList() } ?: emptyList() - val isDeleteButtonEnabled = + val isDeleteButtonEnabled = activeCodeBlock?.isDeleteForbidden == false && when (activeCodeBlock) { is CodeBlock.Blank -> codeBlocks.size > 1 is CodeBlock.Print -> true is CodeBlock.Variable -> { activeCodeBlock.activeChildIndex()?.let { activeChildIndex -> when { - activeChildIndex > 1 -> + activeChildIndex == 0 || activeChildIndex > 1 -> true activeCodeBlock.children[activeChildIndex].selectedSuggestion == null && - activeCodeBlock.children.any { it.selectedSuggestion != null } -> + activeCodeBlock.hasAnySelectedChild() -> false else -> true } } ?: false } + is CodeBlock.IfStatement, + is CodeBlock.ElifStatement -> { + activeCodeBlock.activeChildIndex()?.let { activeChildIndex -> + val activeChild = activeCodeBlock.children[activeChildIndex] as CodeBlockChild.SelectSuggestion + + when { + activeChildIndex > 0 -> + true + + activeChild.selectedSuggestion != null -> + true + + else -> + codeBlocks.getOrNull(activeCodeBlockIndex + 1) + ?.let { it.indentLevel == activeCodeBlock.indentLevel } ?: true + } + } ?: false + } + is CodeBlock.ElseStatement -> + codeBlocks.getOrNull(activeCodeBlockIndex + 1) + ?.let { it.indentLevel == activeCodeBlock.indentLevel } ?: true null -> false } val isSpaceButtonHidden = if (state.codeBlanksOperationsSuggestions.isNotEmpty()) { when (activeCodeBlock) { - is CodeBlock.Print -> { + is CodeBlock.Print, + is CodeBlock.IfStatement, + is CodeBlock.ElifStatement -> { val activeChild = activeCodeBlock.activeChild() as? CodeBlockChild.SelectSuggestion activeChild?.selectedSuggestion == null } @@ -69,17 +100,35 @@ object StepQuizCodeBlanksViewStateMapper { true } } - else -> true + null, + is CodeBlock.Blank, + is CodeBlock.ElseStatement -> true } } else { true } + val isPreviousCodeBlockCondition = + when (activeCodeBlockIndex?.let { state.codeBlocks.getOrNull(it - 1) }) { + is CodeBlock.IfStatement, + is CodeBlock.ElifStatement, + is CodeBlock.ElseStatement -> true + else -> false + } + val isDecreaseIndentLevelButtonHidden = + when { + activeCodeBlock == null -> true + activeCodeBlock.indentLevel < 1 -> true + isPreviousCodeBlockCondition -> true + else -> false + } + return StepQuizCodeBlanksViewState.Content( codeBlocks = codeBlocks, suggestions = suggestions, isDeleteButtonEnabled = isDeleteButtonEnabled, isSpaceButtonHidden = isSpaceButtonHidden, + isDecreaseIndentLevelButtonHidden = isDecreaseIndentLevelButtonHidden, onboardingState = state.onboardingState ) } @@ -90,17 +139,41 @@ object StepQuizCodeBlanksViewStateMapper { ): StepQuizCodeBlanksViewState.CodeBlockItem = when (codeBlock) { is CodeBlock.Blank -> - StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = index, isActive = codeBlock.isActive) + StepQuizCodeBlanksViewState.CodeBlockItem.Blank( + id = index, + indentLevel = codeBlock.indentLevel, + isActive = codeBlock.isActive + ) is CodeBlock.Print -> StepQuizCodeBlanksViewState.CodeBlockItem.Print( id = index, + indentLevel = codeBlock.indentLevel, children = codeBlock.children.mapIndexed(::mapCodeBlockChild) ) is CodeBlock.Variable -> StepQuizCodeBlanksViewState.CodeBlockItem.Variable( id = index, + indentLevel = codeBlock.indentLevel, children = codeBlock.children.mapIndexed(::mapCodeBlockChild) ) + is CodeBlock.IfStatement -> + StepQuizCodeBlanksViewState.CodeBlockItem.IfStatement( + id = index, + indentLevel = codeBlock.indentLevel, + children = codeBlock.children.mapIndexed(::mapCodeBlockChild) + ) + is CodeBlock.ElifStatement -> + StepQuizCodeBlanksViewState.CodeBlockItem.ElifStatement( + id = index, + indentLevel = codeBlock.indentLevel, + children = codeBlock.children.mapIndexed(::mapCodeBlockChild) + ) + is CodeBlock.ElseStatement -> + StepQuizCodeBlanksViewState.CodeBlockItem.ElseStatement( + id = index, + indentLevel = codeBlock.indentLevel, + isActive = codeBlock.isActive + ) } private fun mapCodeBlockChild( diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/view/model/StepQuizCodeBlanksViewState.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/view/model/StepQuizCodeBlanksViewState.kt index 5221faeee7..2bf809df50 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/view/model/StepQuizCodeBlanksViewState.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/view/model/StepQuizCodeBlanksViewState.kt @@ -11,6 +11,7 @@ sealed interface StepQuizCodeBlanksViewState { val suggestions: List, val isDeleteButtonEnabled: Boolean, val isSpaceButtonHidden: Boolean, + val isDecreaseIndentLevelButtonHidden: Boolean, internal val onboardingState: OnboardingState = OnboardingState.Unavailable ) : StepQuizCodeBlanksViewState { val isActionButtonsHidden: Boolean @@ -23,10 +24,13 @@ sealed interface StepQuizCodeBlanksViewState { sealed interface CodeBlockItem { val id: Int + val indentLevel: Int + val children: List data class Blank( override val id: Int, + override val indentLevel: Int = 0, val isActive: Boolean ) : CodeBlockItem { override val children: List = emptyList() @@ -34,11 +38,13 @@ sealed interface StepQuizCodeBlanksViewState { data class Print( override val id: Int, + override val indentLevel: Int = 0, override val children: List ) : CodeBlockItem data class Variable( override val id: Int, + override val indentLevel: Int = 0, override val children: List ) : CodeBlockItem { val name: CodeBlockChildItem? @@ -47,6 +53,26 @@ sealed interface StepQuizCodeBlanksViewState { val values: List get() = children.drop(1) } + + data class IfStatement( + override val id: Int, + override val indentLevel: Int = 0, + override val children: List + ) : CodeBlockItem + + data class ElifStatement( + override val id: Int, + override val indentLevel: Int = 0, + override val children: List + ) : CodeBlockItem + + data class ElseStatement( + override val id: Int, + override val indentLevel: Int = 0, + val isActive: Boolean + ) : CodeBlockItem { + override val children: List = emptyList() + } } data class CodeBlockChildItem( diff --git a/shared/src/commonMain/moko-resources/base/strings.xml b/shared/src/commonMain/moko-resources/base/strings.xml index b5c9718f85..d6fdb269b5 100644 --- a/shared/src/commonMain/moko-resources/base/strings.xml +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -337,7 +337,7 @@ https://apps.apple.com/app/id1637230833?action=write-review Subscription Mobile only - Try Mobile only plan for %s + Try Mobile only plan for %s / month Details Rate us on the Play Store https://play.google.com/store/apps/details?id=org.hyperskill.app.android @@ -695,11 +695,7 @@ No in-app purchases or ads - Unlock the full\nlearning experience - Subscribe for %s / month - Start with a 1 week free trial - Then %s per month - Subscribe + Get the full experience Oops! We were unable to load the subscriptions data. Subscription Purchase failed. Please try again. @@ -708,8 +704,11 @@ The app is updating. Please wait a moment. Hyperskill Terms of Service and Privacy Policy https://hi.hyperskill.org/terms - Monthly - %s / month, cancel any time + Monthly + Annual %s + %s / month + Start now + Best value Subscription diff --git a/shared/src/commonTest/kotlin/org/hyperskill/paywall/PaywallTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/paywall/PaywallTest.kt index 622d772838..4d509f13d4 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/paywall/PaywallTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/paywall/PaywallTest.kt @@ -22,8 +22,8 @@ class PaywallTest { fun `Success subscription purchase should log analytic event`() { val (_, actions) = reducer.reduce( PaywallFeature.State.Content( - formattedPrice = "", - isTrialEligible = false, + subscriptionProducts = emptyList(), + selectedProductId = "", isPurchaseSyncLoadingShowed = false ), InternalMessage.MobileOnlySubscriptionPurchaseSuccess( diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksViewStateMapperTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksViewStateMapperTest.kt deleted file mode 100644 index bbaa7e8967..0000000000 --- a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksViewStateMapperTest.kt +++ /dev/null @@ -1,596 +0,0 @@ -package org.hyperskill.step_quiz_code_blanks - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import org.hyperskill.app.step.domain.model.Step -import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock -import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild -import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion -import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature -import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature.OnboardingState -import org.hyperskill.app.step_quiz_code_blanks.view.mapper.StepQuizCodeBlanksViewStateMapper -import org.hyperskill.app.step_quiz_code_blanks.view.model.StepQuizCodeBlanksViewState -import org.hyperskill.step.domain.model.stub - -class StepQuizCodeBlanksViewStateMapperTest { - @Test - fun `map should return Idle view state for Idle state`() { - val state = StepQuizCodeBlanksFeature.State.Idle - val viewState = StepQuizCodeBlanksViewStateMapper.map(state) - assertEquals(StepQuizCodeBlanksViewState.Idle, viewState) - } - - @Test - fun `Content with print suggestion and disabled delete button when active code block is Blank`() { - val state = stubState( - codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print))) - ) - val expectedViewState = StepQuizCodeBlanksViewState.Content( - codeBlocks = listOf(StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 0, isActive = true)), - suggestions = listOf(Suggestion.Print), - isDeleteButtonEnabled = false, - isSpaceButtonHidden = true - ) - - val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertEquals(expectedViewState, actualViewState) - } - - @Test - fun `Content with suggestions and enabled delete button when active code block is Print`() { - val suggestions = listOf( - Suggestion.ConstantString("1"), - Suggestion.ConstantString("2") - ) - val state = stubState( - codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = suggestions, - selectedSuggestion = null - ) - ) - ) - ) - ) - val expectedViewState = StepQuizCodeBlanksViewState.Content( - codeBlocks = listOf( - StepQuizCodeBlanksViewState.CodeBlockItem.Print( - id = 0, - children = listOf( - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 0, - isActive = true, - value = null - ) - ) - ) - ), - suggestions = suggestions, - isDeleteButtonEnabled = true, - isSpaceButtonHidden = true - ) - - val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertEquals(expectedViewState, actualViewState) - } - - @Test - fun `Content with sequence of filled Print and active Blank`() { - val printSuggestions = listOf( - Suggestion.ConstantString("1"), - Suggestion.ConstantString("2") - ) - val state = stubState( - codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = printSuggestions, - selectedSuggestion = printSuggestions[0] - ) - ) - ), - CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print)) - ) - ) - val expectedViewState = StepQuizCodeBlanksViewState.Content( - codeBlocks = listOf( - StepQuizCodeBlanksViewState.CodeBlockItem.Print( - id = 0, - children = listOf( - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 0, - isActive = false, - value = printSuggestions[0].text - ) - ) - ), - StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 1, isActive = true) - ), - suggestions = listOf(Suggestion.Print), - isDeleteButtonEnabled = true, - isSpaceButtonHidden = true - ) - - val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertEquals(expectedViewState, actualViewState) - } - - @Test - fun `Content with sequence of filled Print and active not filled Print`() { - val printSuggestions = listOf( - Suggestion.ConstantString("1"), - Suggestion.ConstantString("2") - ) - val state = stubState( - codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = printSuggestions, - selectedSuggestion = printSuggestions[0] - ) - ) - ), - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = printSuggestions, - selectedSuggestion = null - ) - ) - ) - ) - ) - val expectedViewState = StepQuizCodeBlanksViewState.Content( - codeBlocks = listOf( - StepQuizCodeBlanksViewState.CodeBlockItem.Print( - id = 0, - children = listOf( - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 0, - isActive = false, - value = printSuggestions[0].text - ) - ) - ), - StepQuizCodeBlanksViewState.CodeBlockItem.Print( - id = 1, - children = listOf( - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 0, - isActive = true, - value = null - ) - ) - ) - ), - suggestions = printSuggestions, - isDeleteButtonEnabled = true, - isSpaceButtonHidden = true - ) - - val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertEquals(expectedViewState, actualViewState) - } - - @Test - fun `Content with active Variable and disabled delete button`() { - val suggestions = listOf( - Suggestion.ConstantString("1"), - Suggestion.ConstantString("2") - ) - val state = stubState( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = suggestions, - selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = suggestions, - selectedSuggestion = suggestions[0] - ) - ) - ) - ) - ) - val expectedViewState = StepQuizCodeBlanksViewState.Content( - codeBlocks = listOf( - StepQuizCodeBlanksViewState.CodeBlockItem.Variable( - id = 0, - children = listOf( - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 0, - isActive = true, - value = null - ), - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 1, - isActive = false, - value = suggestions[0].text - ) - ) - ) - ), - suggestions = suggestions, - isDeleteButtonEnabled = false, - isSpaceButtonHidden = true - ) - - val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertEquals(expectedViewState, actualViewState) - } - - @Test - fun `Content with active not filled Variable and enabled delete button`() { - val suggestions = listOf( - Suggestion.ConstantString("1"), - Suggestion.ConstantString("2") - ) - val state = stubState( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = suggestions, - selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = suggestions, - selectedSuggestion = null - ) - ) - ) - ) - ) - val expectedViewState = StepQuizCodeBlanksViewState.Content( - codeBlocks = listOf( - StepQuizCodeBlanksViewState.CodeBlockItem.Variable( - id = 0, - children = listOf( - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 0, - isActive = true, - value = null - ), - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 1, - isActive = false, - value = null - ) - ) - ) - ), - suggestions = suggestions, - isDeleteButtonEnabled = true, - isSpaceButtonHidden = true - ) - - val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertEquals(expectedViewState, actualViewState) - } - - @Test - fun `Content with active filled Variable and enabled delete button`() { - val suggestions = listOf( - Suggestion.ConstantString("1"), - Suggestion.ConstantString("2") - ) - val state = stubState( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = suggestions, - selectedSuggestion = suggestions[0] - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = suggestions, - selectedSuggestion = suggestions[1] - ) - ) - ) - ) - ) - val expectedViewState = StepQuizCodeBlanksViewState.Content( - codeBlocks = listOf( - StepQuizCodeBlanksViewState.CodeBlockItem.Variable( - id = 0, - children = listOf( - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 0, - isActive = true, - value = suggestions[0].text - ), - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 1, - isActive = false, - value = suggestions[1].text - ) - ) - ) - ), - suggestions = emptyList(), - isDeleteButtonEnabled = true, - isSpaceButtonHidden = true - ) - - val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertEquals(expectedViewState, actualViewState) - } - - @Test - fun `Content with suggestions when active code block is Blank`() { - val suggestions = listOf(Suggestion.Print, Suggestion.Variable) - val state = stubState( - codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = suggestions)) - ) - val expectedViewState = StepQuizCodeBlanksViewState.Content( - codeBlocks = listOf(StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 0, isActive = true)), - suggestions = suggestions, - isDeleteButtonEnabled = false, - isSpaceButtonHidden = true - ) - - val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertEquals(expectedViewState, actualViewState) - } - - @Test - fun `Content with suggestions when active code block is Print and no selected suggestion`() { - val suggestions = listOf( - Suggestion.ConstantString("1"), - Suggestion.ConstantString("2") - ) - val state = stubState( - codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = suggestions, - selectedSuggestion = null - ) - ) - ) - ) - ) - val expectedViewState = StepQuizCodeBlanksViewState.Content( - codeBlocks = listOf( - StepQuizCodeBlanksViewState.CodeBlockItem.Print( - id = 0, - children = listOf( - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 0, - isActive = true, - value = null - ) - ) - ) - ), - suggestions = suggestions, - isDeleteButtonEnabled = true, - isSpaceButtonHidden = true - ) - - val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertEquals(expectedViewState, actualViewState) - } - - @Test - fun `Content with no suggestions when active code block is Print and has selected suggestion`() { - val suggestions = listOf( - Suggestion.ConstantString("1"), - Suggestion.ConstantString("2") - ) - val state = stubState( - codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = suggestions, - selectedSuggestion = suggestions[0] - ) - ) - ) - ) - ) - val expectedViewState = StepQuizCodeBlanksViewState.Content( - codeBlocks = listOf( - StepQuizCodeBlanksViewState.CodeBlockItem.Print( - id = 0, - children = listOf( - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 0, - isActive = true, - value = suggestions[0].text - ) - ) - ) - ), - suggestions = emptyList(), - isDeleteButtonEnabled = true, - isSpaceButtonHidden = true - ) - - val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertEquals(expectedViewState, actualViewState) - } - - @Test - fun `Content with suggestions when active code block is Variable and active child has no selected suggestion`() { - val suggestions = listOf( - Suggestion.ConstantString("1"), - Suggestion.ConstantString("2") - ) - val state = stubState( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = suggestions, - selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = suggestions, - selectedSuggestion = null - ) - ) - ) - ) - ) - val expectedViewState = StepQuizCodeBlanksViewState.Content( - codeBlocks = listOf( - StepQuizCodeBlanksViewState.CodeBlockItem.Variable( - id = 0, - children = listOf( - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 0, - isActive = true, - value = null - ), - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 1, - isActive = false, - value = null - ) - ) - ) - ), - suggestions = suggestions, - isDeleteButtonEnabled = true, - isSpaceButtonHidden = true - ) - - val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertEquals(expectedViewState, actualViewState) - } - - @Test - fun `Content with no suggestions when active code block is Variable and active child has selected suggestion`() { - val suggestions = listOf( - Suggestion.ConstantString("1"), - Suggestion.ConstantString("2") - ) - val state = stubState( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = suggestions, - selectedSuggestion = suggestions[0] - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = suggestions, - selectedSuggestion = suggestions[1] - ) - ) - ) - ) - ) - val expectedViewState = StepQuizCodeBlanksViewState.Content( - codeBlocks = listOf( - StepQuizCodeBlanksViewState.CodeBlockItem.Variable( - id = 0, - children = listOf( - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 0, - isActive = true, - value = suggestions[0].text - ), - StepQuizCodeBlanksViewState.CodeBlockChildItem( - id = 1, - isActive = false, - value = suggestions[1].text - ) - ) - ) - ), - suggestions = emptyList(), - isDeleteButtonEnabled = true, - isSpaceButtonHidden = true - ) - - val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertEquals(expectedViewState, actualViewState) - } - - @Test - fun `Action buttons hidden when onboarding is available`() { - val state = stubState( - codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList())), - onboardingState = OnboardingState.HighlightSuggestions - ) - val viewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertTrue(viewState is StepQuizCodeBlanksViewState.Content) - assertTrue(viewState.isActionButtonsHidden) - } - - @Test - fun `Action buttons not hidden when onboarding is unavailable`() { - val state = stubState( - codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList())), - onboardingState = OnboardingState.Unavailable - ) - val viewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertTrue(viewState is StepQuizCodeBlanksViewState.Content) - assertFalse(viewState.isActionButtonsHidden) - } - - @Test - fun `Suggestions highlight effect is active when onboardingState is HighlightSuggestions`() { - val state = stubState( - codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList())), - onboardingState = OnboardingState.HighlightSuggestions - ) - val viewState = StepQuizCodeBlanksViewStateMapper.map(state) - - assertTrue(viewState is StepQuizCodeBlanksViewState.Content) - assertTrue(viewState.isSuggestionsHighlightEffectActive) - } - - private fun stubState( - codeBlocks: List, - onboardingState: OnboardingState = OnboardingState.Unavailable - ): StepQuizCodeBlanksFeature.State.Content = - StepQuizCodeBlanksFeature.State.Content( - step = Step.stub(id = 0), - codeBlocks = codeBlocks, - onboardingState = onboardingState - ) -} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksStateExtensionsTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksCreateReplyTest.kt similarity index 56% rename from shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksStateExtensionsTest.kt rename to shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksCreateReplyTest.kt index 60fc9f2351..a4e9aa1481 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksStateExtensionsTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksCreateReplyTest.kt @@ -1,60 +1,31 @@ -package org.hyperskill.step_quiz_code_blanks +package org.hyperskill.step_quiz_code_blanks.presentation import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue import org.hyperskill.app.step.domain.model.Block import org.hyperskill.app.step.domain.model.Step import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature -import org.hyperskill.app.step_quiz_code_blanks.presentation.activeCodeBlockIndex import org.hyperskill.app.step_quiz_code_blanks.presentation.createReply -import org.hyperskill.app.step_quiz_code_blanks.presentation.isVariableSuggestionsAvailable import org.hyperskill.app.submissions.domain.model.Reply import org.hyperskill.step.domain.model.stub -class StepQuizCodeBlanksStateExtensionsTest { - @Test - fun `activeCodeBlockIndex should return null if no active code block`() { - val state = stubContentState( - codeBlocks = listOf( - CodeBlock.Blank(isActive = false, suggestions = emptyList()), - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = emptyList(), - selectedSuggestion = null - ) - ) - ) - ) - ) - assertNull(state.activeCodeBlockIndex()) +class StepQuizCodeBlanksCreateReplyTest { + companion object { + private const val REPLY_CODE_LANGUAGE = "python3" + private const val REPLY_CODE_PREFIX = "# solved with code blanks\n" } - @Test - fun `activeCodeBlockIndex should return index of the active code block`() { - val state = stubContentState( - codeBlocks = listOf( - CodeBlock.Blank(isActive = false, suggestions = emptyList()), - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = emptyList(), - selectedSuggestion = null - ) - ) - ) + private val step = Step.stub( + id = 1, + block = Block.stub( + options = Block.Options( + codeTemplates = mapOf(REPLY_CODE_LANGUAGE to "# put your python code here") ) ) - assertEquals(1, state.activeCodeBlockIndex()) - } + ) @Test fun `createReply should return Reply with code from code blocks and language from step options`() { @@ -70,24 +41,14 @@ class StepQuizCodeBlanksStateExtensionsTest { ), CodeBlock.Blank(isActive = true, suggestions = emptyList()) ) - val step = Step.stub(id = 1).copy( - block = Block.stub( - options = Block.Options( - codeTemplates = mapOf("python3" to "# put your python code here") - ) - ) - ) - val state = stubContentState( - step = step, - codeBlocks = codeBlocks - ) + val state = StepQuizCodeBlanksFeature.State.Content.stub(step = step, codeBlocks = codeBlocks) val expectedReply = Reply.code( code = buildString { - append("# solved with code blanks\n") + append(REPLY_CODE_PREFIX) append("print(\"test\")\n") }, - language = "python3" + language = REPLY_CODE_LANGUAGE ) assertEquals(expectedReply, state.createReply()) @@ -120,24 +81,14 @@ class StepQuizCodeBlanksStateExtensionsTest { ) ), ) - val step = Step.stub(id = 1).copy( - block = Block.stub( - options = Block.Options( - codeTemplates = mapOf("python3" to "# put your python code here") - ) - ) - ) - val state = stubContentState( - step = step, - codeBlocks = codeBlocks - ) + val state = StepQuizCodeBlanksFeature.State.Content.stub(step = step, codeBlocks = codeBlocks) val expectedReply = Reply.code( code = buildString { - append("# solved with code blanks\n") + append(REPLY_CODE_PREFIX) append("a = 1\nprint(a)") }, - language = "python3" + language = REPLY_CODE_LANGUAGE ) assertEquals(expectedReply, state.createReply()) @@ -248,27 +199,17 @@ class StepQuizCodeBlanksStateExtensionsTest { ) ) ) - val step = Step.stub(id = 1).copy( - block = Block.stub( - options = Block.Options( - codeTemplates = mapOf("python3" to "# put your python code here") - ) - ) - ) - val state = stubContentState( - step = step, - codeBlocks = codeBlocks - ) + val state = StepQuizCodeBlanksFeature.State.Content.stub(step = step, codeBlocks = codeBlocks) val expectedReply = Reply.code( code = buildString { - append("# solved with code blanks\n") + append(REPLY_CODE_PREFIX) append("x = 1000\n") append("r = 5\n") append("y = 10\n") append("print(x * (1 + r / 100) ** y)") }, - language = "python3" + language = REPLY_CODE_LANGUAGE ) assertEquals(expectedReply, state.createReply()) @@ -393,69 +334,253 @@ class StepQuizCodeBlanksStateExtensionsTest { ) ) ) - val step = Step.stub(id = 1).copy( - block = Block.stub( - options = Block.Options( - codeTemplates = mapOf("python3" to "# put your python code here") - ) - ) - ) - val state = stubContentState( - step = step, - codeBlocks = codeBlocks - ) + val state = StepQuizCodeBlanksFeature.State.Content.stub(step = step, codeBlocks = codeBlocks) val expectedReply = Reply.code( code = buildString { - append("# solved with code blanks\n") + append(REPLY_CODE_PREFIX) append("x = 1000\n") append("r = 5\n") append("y = 10\n") append("a = x * (1 + r / 100) ** y\n") append("print(a)") }, - language = "python3" + language = REPLY_CODE_LANGUAGE ) assertEquals(expectedReply, state.createReply()) } @Test - fun `isVariableSuggestionsAvailable should return true if variable suggestions are available`() { - val step = Step.stub( - id = 1, - block = Block.stub(options = Block.Options(codeBlanksVariables = listOf("a", "b"))) + fun `createReply should return correct Reply with single IfStatement`() { + val codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("a") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("33") + ) + ) + ), + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("b") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("200") + ) + ) + ), + CodeBlock.IfStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("b") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString(">") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("a") + ) + ) + ), + CodeBlock.Print( + indentLevel = 1, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("\"b is greater than a\"") + ) + ) + ) ) - val state = stubContentState( - step = step, - codeBlocks = emptyList() + val state = StepQuizCodeBlanksFeature.State.Content.stub(step = step, codeBlocks = codeBlocks) + + val expectedReply = Reply.code( + code = buildString { + append(REPLY_CODE_PREFIX) + append("a = 33\n") + append("b = 200\n") + append("if b > a:\n") + append("\tprint(\"b is greater than a\")") + }, + language = REPLY_CODE_LANGUAGE ) - assertTrue(state.isVariableSuggestionsAvailable) + assertEquals(expectedReply, state.createReply()) } @Test - fun `isVariableSuggestionsAvailable should return false if variable suggestions are not available`() { - listOf(null, emptyList()).forEach { codeBlanksVariables -> - val step = Step.stub( - id = 1, - block = Block.stub(options = Block.Options(codeBlanksVariables = codeBlanksVariables)) - ) - val state = stubContentState( - step = step, - codeBlocks = emptyList() + fun `createReply should return correct Reply with multiple IfStatement and indentation level of 2`() { + val codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("x") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("10") + ) + ) + ), + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("y") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("5") + ) + ) + ), + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("z") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("15") + ) + ) + ), + CodeBlock.IfStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("x") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString(">") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("y") + ) + ) + ), + CodeBlock.Print( + indentLevel = 1, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("\"x is greater than y\"") + ) + ) + ), + CodeBlock.IfStatement( + indentLevel = 1, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("z") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString(">") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("x") + ) + ) + ), + CodeBlock.Print( + indentLevel = 2, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("\"z is greater than x\"") + ) + ) + ), + CodeBlock.IfStatement( + indentLevel = 1, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("z") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("<") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("x") + ) + ) + ), + CodeBlock.Print( + indentLevel = 2, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("\"z is less than x\"") + ) + ) ) + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub(step = step, codeBlocks = codeBlocks) - assertFalse(state.isVariableSuggestionsAvailable) - } - } - - private fun stubContentState( - step: Step = Step.stub(id = 1), - codeBlocks: List - ): StepQuizCodeBlanksFeature.State.Content = - StepQuizCodeBlanksFeature.State.Content( - step = step, - codeBlocks = codeBlocks + val expectedReply = Reply.code( + code = buildString { + append(REPLY_CODE_PREFIX) + append("x = 10\n") + append("y = 5\n") + append("z = 15\n") + append("if x > y:\n") + append("\tprint(\"x is greater than y\")\n") + append("\tif z > x:\n") + append("\t\tprint(\"z is greater than x\")\n") + append("\tif z < x:\n") + append("\t\tprint(\"z is less than x\")") + }, + language = REPLY_CODE_LANGUAGE ) + + assertEquals(expectedReply, state.createReply()) + } } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksFeatureStateStub.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksFeatureStateStub.kt new file mode 100644 index 0000000000..20feff2f7d --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksFeatureStateStub.kt @@ -0,0 +1,18 @@ +package org.hyperskill.step_quiz_code_blanks.presentation + +import org.hyperskill.app.step.domain.model.Step +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature.OnboardingState +import org.hyperskill.step.domain.model.stub + +fun StepQuizCodeBlanksFeature.State.Content.Companion.stub( + step: Step = Step.stub(id = 1), + codeBlocks: List = emptyList(), + onboardingState: OnboardingState = OnboardingState.Unavailable +): StepQuizCodeBlanksFeature.State.Content = + StepQuizCodeBlanksFeature.State.Content( + step = step, + codeBlocks = codeBlocks, + onboardingState = onboardingState + ) \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerCodeBlockChildClickedTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerCodeBlockChildClickedTest.kt new file mode 100644 index 0000000000..9c864bf956 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerCodeBlockChildClickedTest.kt @@ -0,0 +1,281 @@ +package org.hyperskill.step_quiz_code_blanks.presentation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.hyperskill.app.step.domain.model.StepRoute +import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedCodeBlockChildHyperskillAnalyticEvent +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksReducer +import org.hyperskill.app.step_quiz_code_blanks.view.model.StepQuizCodeBlanksViewState + +class StepQuizCodeBlanksReducerCodeBlockChildClickedTest { + private val reducer = StepQuizCodeBlanksReducer(StepRoute.Learn.Step(1, null)) + + @Test + fun `CodeBlockChildClicked should not update state if state is not Content`() { + val initialState = StepQuizCodeBlanksFeature.State.Idle + val message = StepQuizCodeBlanksFeature.Message.CodeBlockChildClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Variable(id = 0, children = emptyList()), + codeBlockChildItem = StepQuizCodeBlanksViewState.CodeBlockChildItem(id = 0, isActive = false, value = null) + ) + val (state, actions) = reducer.reduce(initialState, message) + + assertEquals(initialState, state) + assertTrue(actions.isEmpty()) + } + + @Test + fun `CodeBlockChildClicked should not update state if target code block is not found`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockChildClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Variable(id = 1, children = emptyList()), + codeBlockChildItem = StepQuizCodeBlanksViewState.CodeBlockChildItem(id = 0, isActive = false, value = null) + ) + val (state, actions) = reducer.reduce(initialState, message) + + assertEquals(initialState, state) + assertContainsCodeBlockChildClickedAnalyticEvent(actions) + } + + @Test + fun `CodeBlockChildClicked should not update state if target code block is Blank`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = false, + suggestions = emptyList() + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockChildClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 0, isActive = false), + codeBlockChildItem = StepQuizCodeBlanksViewState.CodeBlockChildItem(id = 0, isActive = false, value = null) + ) + val (state, actions) = reducer.reduce(initialState, message) + + assertEquals(initialState, state) + assertContainsCodeBlockChildClickedAnalyticEvent(actions) + } + + @Test + fun `CodeBlockChildClicked should not update state if target code block is ElseStatement`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf(CodeBlock.ElseStatement(isActive = false)) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockChildClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.ElseStatement(id = 0, isActive = false), + codeBlockChildItem = StepQuizCodeBlanksViewState.CodeBlockChildItem(id = 0, isActive = false, value = null) + ) + val (state, actions) = reducer.reduce(initialState, message) + + assertEquals(initialState, state) + assertContainsCodeBlockChildClickedAnalyticEvent(actions) + } + + @Test + fun `CodeBlockChildClicked should update state to activate the clicked Variable child`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockChildClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Variable(id = 0, children = emptyList()), + codeBlockChildItem = StepQuizCodeBlanksViewState.CodeBlockChildItem(id = 0, isActive = false, value = null) + ) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsCodeBlockChildClickedAnalyticEvent(actions) + } + + @Test + fun `CodeBlockChildClicked should update state to activate the clicked Print child`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockChildClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Print(id = 0, children = emptyList()), + codeBlockChildItem = StepQuizCodeBlanksViewState.CodeBlockChildItem(id = 0, isActive = false, value = null) + ) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsCodeBlockChildClickedAnalyticEvent(actions) + } + + @Test + fun `CodeBlockChildClicked should update state to activate the clicked IfStatement child`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.IfStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockChildClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.IfStatement(id = 0, children = emptyList()), + codeBlockChildItem = StepQuizCodeBlanksViewState.CodeBlockChildItem(id = 0, isActive = false, value = null) + ) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.IfStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsCodeBlockChildClickedAnalyticEvent(actions) + } + + @Test + fun `CodeBlockChildClicked should update state to activate the clicked ElifStatement child`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.ElifStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockChildClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.ElifStatement(id = 0, children = emptyList()), + codeBlockChildItem = StepQuizCodeBlanksViewState.CodeBlockChildItem(id = 0, isActive = false, value = null) + ) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.ElifStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsCodeBlockChildClickedAnalyticEvent(actions) + } + + private fun assertContainsCodeBlockChildClickedAnalyticEvent(actions: Set) { + assertTrue { + actions.any { + it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && + it.analyticEvent is StepQuizCodeBlanksClickedCodeBlockChildHyperskillAnalyticEvent + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerCodeBlockClickedTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerCodeBlockClickedTest.kt new file mode 100644 index 0000000000..84deb40e1e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerCodeBlockClickedTest.kt @@ -0,0 +1,239 @@ +package org.hyperskill.step_quiz_code_blanks.presentation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.hyperskill.app.step.domain.model.StepRoute +import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksReducer +import org.hyperskill.app.step_quiz_code_blanks.view.model.StepQuizCodeBlanksViewState + +class StepQuizCodeBlanksReducerCodeBlockClickedTest { + private val reducer = StepQuizCodeBlanksReducer(StepRoute.Learn.Step(1, null)) + + @Test + fun `CodeBlockClicked should not update state if state is not Content`() { + val initialState = StepQuizCodeBlanksFeature.State.Idle + val message = StepQuizCodeBlanksFeature.Message.CodeBlockClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 0, isActive = true) + ) + val (state, actions) = reducer.reduce(initialState, message) + + assertEquals(initialState, state) + assertTrue(actions.isEmpty()) + } + + @Test + fun `CodeBlockClicked should not update state if no target code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList())) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 1, isActive = true) + ) + val (state, actions) = reducer.reduce(initialState, message) + + assertEquals(initialState, state) + assertContainsCodeBlockClickedAnalyticEvent(actions) + } + + @Test + fun `CodeBlockClicked should not update state if target code block is active`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList())) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 0, isActive = true) + ) + val (state, actions) = reducer.reduce(initialState, message) + + assertEquals(initialState, state) + assertContainsCodeBlockClickedAnalyticEvent(actions) + } + + @Test + fun `CodeBlockClicked should update active Print code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank(isActive = false, suggestions = emptyList()), + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, suggestions = emptyList(), selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 0, isActive = false) + ) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Blank(isActive = true, suggestions = emptyList()), + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, suggestions = emptyList(), selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsCodeBlockClickedAnalyticEvent(actions) + } + + @Test + fun `CodeBlockClicked should update active ElseStatement code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank(isActive = false, suggestions = emptyList()), + CodeBlock.ElseStatement(isActive = true) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 0, isActive = false) + ) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Blank(isActive = true, suggestions = emptyList()), + CodeBlock.ElseStatement(isActive = false) + ) + ) + + assertEquals(expectedState, state) + assertContainsCodeBlockClickedAnalyticEvent(actions) + } + + @Test + fun `CodeBlockClicked should update active Variable code block to not active`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, suggestions = emptyList(), selectedSuggestion = null + ) + ) + ), + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, suggestions = emptyList(), selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, suggestions = emptyList(), selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 0, isActive = false) + ) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, suggestions = emptyList(), selectedSuggestion = null + ) + ) + ), + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, suggestions = emptyList(), selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, suggestions = emptyList(), selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsCodeBlockClickedAnalyticEvent(actions) + } + + @Test + fun `CodeBlockClicked should update not active Variable code block to active`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, suggestions = emptyList(), selectedSuggestion = null + ) + ) + ), + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, suggestions = emptyList(), selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, suggestions = emptyList(), selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.CodeBlockClicked( + codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 1, isActive = false) + ) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, suggestions = emptyList(), selectedSuggestion = null + ) + ) + ), + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, suggestions = emptyList(), selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, suggestions = emptyList(), selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsCodeBlockClickedAnalyticEvent(actions) + } + + private fun assertContainsCodeBlockClickedAnalyticEvent(actions: Set) { + assertTrue { + actions.any { + it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && + it.analyticEvent is StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerDecreaseIndentLevelButtonClickedTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerDecreaseIndentLevelButtonClickedTest.kt new file mode 100644 index 0000000000..9e16f08b9b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerDecreaseIndentLevelButtonClickedTest.kt @@ -0,0 +1,148 @@ +package org.hyperskill.step_quiz_code_blanks.presentation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.hyperskill.app.step.domain.model.StepRoute +import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedDecreaseIndentLevelHyperskillAnalyticEvent +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksReducer + +class StepQuizCodeBlanksReducerDecreaseIndentLevelButtonClickedTest { + private val reducer = StepQuizCodeBlanksReducer(StepRoute.Learn.Step(1, null)) + + @Test + fun `DecreaseIndentLevelButtonClicked should not update state if state is not Content`() { + val initialState = StepQuizCodeBlanksFeature.State.Idle + + val (state, actions) = reducer.reduce( + initialState, + StepQuizCodeBlanksFeature.Message.DecreaseIndentLevelButtonClicked + ) + + assertEquals(initialState, state) + assertTrue(actions.isEmpty()) + } + + @Test + fun `DecreaseIndentLevelButtonClicked should not update state if no active code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf(CodeBlock.Blank(isActive = false, suggestions = emptyList())) + ) + + val (state, actions) = reducer.reduce( + initialState, + StepQuizCodeBlanksFeature.Message.DecreaseIndentLevelButtonClicked + ) + + assertEquals(initialState, state) + assertContainsDecreaseIndentLevelAnalyticEvent(actions) + } + + @Test + fun `DecreaseIndentLevelButtonClicked should not decrease indent level below 1`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, indentLevel = 0, suggestions = emptyList())) + ) + + val (state, actions) = reducer.reduce( + initialState, + StepQuizCodeBlanksFeature.Message.DecreaseIndentLevelButtonClicked + ) + + assertEquals(initialState, state) + assertContainsDecreaseIndentLevelAnalyticEvent(actions) + } + + @Test + fun `DecreaseIndentLevelButtonClicked should decrease indent level by 1`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, indentLevel = 1, suggestions = emptyList())) + ) + + val (state, actions) = reducer.reduce( + initialState, + StepQuizCodeBlanksFeature.Message.DecreaseIndentLevelButtonClicked + ) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = true, + indentLevel = 0, + suggestions = listOf(Suggestion.Print) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsDecreaseIndentLevelAnalyticEvent(actions) + } + + @Test + fun `DecreaseIndentLevelButtonClicked should decrease indent level by 1 and update suggestions for Blank`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = true, + indentLevel = 1, + suggestions = emptyList() + ) + ) + ) + + val (state, actions) = reducer.reduce( + initialState, + StepQuizCodeBlanksFeature.Message.DecreaseIndentLevelButtonClicked + ) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = true, + indentLevel = 0, + suggestions = listOf(Suggestion.Print) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsDecreaseIndentLevelAnalyticEvent(actions) + } + + @Test + fun `DecreaseIndentLevelButtonClicked should decrease indent level for active code block only`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank(isActive = false, indentLevel = 3, suggestions = emptyList()), + CodeBlock.Blank(isActive = true, indentLevel = 2, suggestions = emptyList()) + ) + ) + + val (state, actions) = reducer.reduce( + initialState, + StepQuizCodeBlanksFeature.Message.DecreaseIndentLevelButtonClicked + ) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Blank(isActive = false, indentLevel = 3, suggestions = emptyList()), + CodeBlock.Blank(isActive = true, indentLevel = 1, suggestions = listOf(Suggestion.Print)) + ) + ) + + assertEquals(expectedState, state) + assertContainsDecreaseIndentLevelAnalyticEvent(actions) + } + + private fun assertContainsDecreaseIndentLevelAnalyticEvent(actions: Set) { + assertTrue { + actions.any { + it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && + it.analyticEvent is StepQuizCodeBlanksClickedDecreaseIndentLevelHyperskillAnalyticEvent + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksReducerTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerDeleteButtonClickedTest.kt similarity index 54% rename from shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksReducerTest.kt rename to shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerDeleteButtonClickedTest.kt index d0160f37be..d8492a778e 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksReducerTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerDeleteButtonClickedTest.kt @@ -1,503 +1,31 @@ -package org.hyperskill.step_quiz_code_blanks +package org.hyperskill.step_quiz_code_blanks.presentation import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -import org.hyperskill.app.step.domain.model.Block -import org.hyperskill.app.step.domain.model.Step import org.hyperskill.app.step.domain.model.StepRoute -import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedCodeBlockChildHyperskillAnalyticEvent -import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent -import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedEnterHyperskillAnalyticEvent -import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedSpaceHyperskillAnalyticEvent -import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedSuggestionHyperskillAnalyticEvent import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature -import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature.OnboardingState import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksReducer -import org.hyperskill.app.step_quiz_code_blanks.view.model.StepQuizCodeBlanksViewState -import org.hyperskill.step.domain.model.stub -class StepQuizCodeBlanksReducerTest { +class StepQuizCodeBlanksReducerDeleteButtonClickedTest { private val reducer = StepQuizCodeBlanksReducer(StepRoute.Learn.Step(1, null)) @Test - fun `Initialize should return Content state with active Blank and Print and Variable suggestions`() { - val step = Step.stub( - id = 1, - block = Block.stub(options = Block.Options(codeBlanksVariables = listOf("a", "b"))) - ) - - val message = StepQuizCodeBlanksFeature.InternalMessage.Initialize(step) - val (state, actions) = reducer.reduce(StepQuizCodeBlanksFeature.State.Idle, message) - - val expectedState = StepQuizCodeBlanksFeature.State.Content( - step = step, - codeBlocks = listOf( - CodeBlock.Blank( - isActive = true, - suggestions = listOf(Suggestion.Print, Suggestion.Variable) - ) - ) - ) - - assertTrue(state is StepQuizCodeBlanksFeature.State.Content) - assertEquals(expectedState.codeBlocks, state.codeBlocks) - assertTrue(actions.isEmpty()) - } - - @Test - fun `Initialize should return Content state with active Blank and Print suggestion`() { - val step = Step.stub(id = 1) - - val message = StepQuizCodeBlanksFeature.InternalMessage.Initialize(step) - val (state, actions) = reducer.reduce(StepQuizCodeBlanksFeature.State.Idle, message) - - val expectedState = StepQuizCodeBlanksFeature.State.Content( - step = step, - codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print))) - ) - - assertTrue(state is StepQuizCodeBlanksFeature.State.Content) - assertEquals(expectedState.codeBlocks, state.codeBlocks) - assertTrue(actions.isEmpty()) - } - - @Test - fun `SuggestionClicked should not update state if no active code block`() { - val initialState = - stubContentState(codeBlocks = listOf(CodeBlock.Blank(isActive = false, suggestions = emptyList()))) - - val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.Print) - val (state, actions) = reducer.reduce(initialState, message) - - assertEquals(initialState, state) - assertContainsSuggestionClickedAnalyticEvent(actions) - } - - @Test - fun `SuggestionClicked should not update state if suggestion does not exist`() { - val initialState = - stubContentState(codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList()))) - - val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.ConstantString("test")) - val (state, actions) = reducer.reduce(initialState, message) - - assertEquals(initialState, state) - assertContainsSuggestionClickedAnalyticEvent(actions) - } - - @Test - fun `SuggestionClicked should not update state if state is not Content`() { - val initialState = StepQuizCodeBlanksFeature.State.Idle - val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.Print) - val (state, actions) = reducer.reduce(initialState, message) - - assertEquals(initialState, state) - assertTrue(actions.isEmpty()) - } - - @Test - fun `SuggestionClicked should update active Blank code block to Print if suggestion exists`() { - val initialState = stubContentState( - codeBlocks = listOf( - CodeBlock.Blank( - isActive = true, - suggestions = listOf(Suggestion.Print) - ) - ) - ) - - val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.Print) - val (state, actions) = reducer.reduce(initialState, message) - - val expectedState = initialState.copy( - codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = initialState.codeBlanksStringsSuggestions, - selectedSuggestion = null - ) - ) - ) - ) - ) - - assertEquals(expectedState, state) - assertContainsSuggestionClickedAnalyticEvent(actions) - } - - @Test - fun `SuggestionClicked should update active Blank code block to Variable if suggestion exists`() { - val initialState = stubContentState( - codeBlocks = listOf( - CodeBlock.Blank( - isActive = true, - suggestions = listOf(Suggestion.Print, Suggestion.Variable) - ) - ) - ) - - val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.Variable) - val (state, actions) = reducer.reduce(initialState, message) - - val expectedState = initialState.copy( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = initialState.codeBlanksVariablesSuggestions, - selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = initialState.codeBlanksStringsSuggestions, - selectedSuggestion = null - ) - ) - ) - ) - ) - - assertEquals(expectedState, state) - assertContainsSuggestionClickedAnalyticEvent(actions) - } - - @Test - fun `SuggestionClicked should update Print code block with selected suggestion`() { - val suggestion = Suggestion.ConstantString("suggestion") - val initialState = stubContentState( - codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = listOf(suggestion), - selectedSuggestion = null - ) - ) - ) - ) - ) - - val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(suggestion) - val (state, actions) = reducer.reduce(initialState, message) - - assertTrue(state is StepQuizCodeBlanksFeature.State.Content) - assertEquals(suggestion, (state.codeBlocks[0] as CodeBlock.Print).children[0].selectedSuggestion) - assertContainsSuggestionClickedAnalyticEvent(actions) - } - - @Test - fun `SuggestionClicked should update Variable code block with selected suggestion for name`() { - val suggestion = Suggestion.ConstantString("suggestion") - val initialState = stubContentState( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = listOf(suggestion), - selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = listOf(suggestion), - selectedSuggestion = null - ) - ) - ) - ) - ) - - val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(suggestion) - val (state, actions) = reducer.reduce(initialState, message) - - val expectedState = initialState.copy( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = listOf(suggestion), - selectedSuggestion = suggestion - ), - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = listOf(suggestion), - selectedSuggestion = null - ) - ) - ) - ) - ) - - assertTrue(state is StepQuizCodeBlanksFeature.State.Content) - assertEquals(expectedState.codeBlocks, state.codeBlocks) - assertContainsSuggestionClickedAnalyticEvent(actions) - } - - @Test - fun `SuggestionClicked should update Variable code block with selected suggestion for value`() { - val suggestion = Suggestion.ConstantString("suggestion") - val initialState = stubContentState( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = listOf(suggestion), - selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = listOf(suggestion), - selectedSuggestion = null - ) - ) - ) - ) - ) - - val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(suggestion) - val (state, actions) = reducer.reduce(initialState, message) - - val expectedState = initialState.copy( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = listOf(suggestion), - selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = listOf(suggestion), - selectedSuggestion = suggestion - ) - ) - ) - ) - ) - - assertTrue(state is StepQuizCodeBlanksFeature.State.Content) - assertEquals(expectedState.codeBlocks, state.codeBlocks) - assertContainsSuggestionClickedAnalyticEvent(actions) - } - - @Test - fun `CodeBlockClicked should not update state if state is not Content`() { - val initialState = StepQuizCodeBlanksFeature.State.Idle - val message = StepQuizCodeBlanksFeature.Message.CodeBlockClicked( - codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 0, isActive = true) - ) - val (state, actions) = reducer.reduce(initialState, message) - - assertEquals(initialState, state) - assertTrue(actions.isEmpty()) - } - - @Test - fun `CodeBlockClicked should update active Print code block`() { - val initialState = stubContentState( - codeBlocks = listOf( - CodeBlock.Blank(isActive = false, suggestions = emptyList()), - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, suggestions = emptyList(), selectedSuggestion = null - ) - ) - ) - ) - ) - - val message = StepQuizCodeBlanksFeature.Message.CodeBlockClicked( - codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 0, isActive = false) - ) - val (state, actions) = reducer.reduce(initialState, message) - - val expectedState = initialState.copy( - codeBlocks = listOf( - CodeBlock.Blank(isActive = true, suggestions = emptyList()), - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, suggestions = emptyList(), selectedSuggestion = null - ) - ) - ) - ) - ) - - assertEquals(expectedState, state) - assertTrue { - actions.any { - it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && - it.analyticEvent is StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent - } - } - } - - @Test - fun `CodeBlockClicked should update active Variable code block`() { - val initialState = stubContentState( - codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, suggestions = emptyList(), selectedSuggestion = null - ) - ) - ), - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, suggestions = emptyList(), selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = false, suggestions = emptyList(), selectedSuggestion = null - ) - ) - ) - ) - ) - - val message = StepQuizCodeBlanksFeature.Message.CodeBlockClicked( - codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 0, isActive = false) - ) - val (state, actions) = reducer.reduce(initialState, message) - - val expectedState = initialState.copy( - codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, suggestions = emptyList(), selectedSuggestion = null - ) - ) - ), - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, suggestions = emptyList(), selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = false, suggestions = emptyList(), selectedSuggestion = null - ) - ) - ) - ) - ) - - assertEquals(expectedState, state) - assertTrue { - actions.any { - it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && - it.analyticEvent is StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent - } - } - } - - @Test - fun `CodeBlockChildClicked should not update state if state is not Content`() { - val initialState = StepQuizCodeBlanksFeature.State.Idle - val message = StepQuizCodeBlanksFeature.Message.CodeBlockChildClicked( - codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Variable(id = 0, children = emptyList()), - codeBlockChildItem = StepQuizCodeBlanksViewState.CodeBlockChildItem(id = 0, isActive = false, value = null) - ) - val (state, actions) = reducer.reduce(initialState, message) - - assertEquals(initialState, state) - assertTrue(actions.isEmpty()) - } - - @Test - fun `CodeBlockChildClicked should not update state if target code block is not found`() { - val initialState = stubContentState( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = emptyList(), - selectedSuggestion = null - ) - ) - ) - ) - ) - - val message = StepQuizCodeBlanksFeature.Message.CodeBlockChildClicked( - codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Variable(id = 1, children = emptyList()), - codeBlockChildItem = StepQuizCodeBlanksViewState.CodeBlockChildItem(id = 0, isActive = false, value = null) - ) - val (state, actions) = reducer.reduce(initialState, message) - - assertEquals(initialState, state) - assertContainsCodeBlockChildClickedAnalyticEvent(actions) - } - - @Test - fun `CodeBlockChildClicked should update state to activate the clicked Variable child`() { - val initialState = stubContentState( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = emptyList(), - selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = emptyList(), - selectedSuggestion = null - ) - ) - ) - ) - ) - - val message = StepQuizCodeBlanksFeature.Message.CodeBlockChildClicked( - codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Variable(id = 0, children = emptyList()), - codeBlockChildItem = StepQuizCodeBlanksViewState.CodeBlockChildItem(id = 0, isActive = false, value = null) - ) - val (state, actions) = reducer.reduce(initialState, message) - - val expectedState = initialState.copy( - codeBlocks = listOf( - CodeBlock.Variable( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = emptyList(), - selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = emptyList(), - selectedSuggestion = null - ) - ) - ) - ) - ) + fun `DeleteButtonClicked should not update state if state is not Content`() { + val initialState = StepQuizCodeBlanksFeature.State.Idle + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) - assertEquals(expectedState, state) - assertContainsCodeBlockChildClickedAnalyticEvent(actions) + assertEquals(initialState, state) + assertTrue(actions.isEmpty()) } @Test - fun `CodeBlockChildClicked should update state to activate the clicked Print child`() { - val initialState = stubContentState( + fun `DeleteButtonClicked should not update state if no active code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Print( children = listOf( @@ -505,60 +33,35 @@ class StepQuizCodeBlanksReducerTest { isActive = false, suggestions = emptyList(), selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = emptyList(), - selectedSuggestion = null ) ) ) ) ) - val message = StepQuizCodeBlanksFeature.Message.CodeBlockChildClicked( - codeBlockItem = StepQuizCodeBlanksViewState.CodeBlockItem.Print(id = 0, children = emptyList()), - codeBlockChildItem = StepQuizCodeBlanksViewState.CodeBlockChildItem(id = 0, isActive = false, value = null) - ) - val (state, actions) = reducer.reduce(initialState, message) + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) - val expectedState = initialState.copy( + assertEquals(initialState, state) + assertContainsDeleteButtonClickedAnalyticEvent(actions) + } + + @Test + fun `DeleteButtonClicked should not update state if isDeleteForbidden for active code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Print( + isDeleteForbidden = true, children = listOf( CodeBlockChild.SelectSuggestion( isActive = true, suggestions = emptyList(), - selectedSuggestion = null - ), - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = emptyList(), - selectedSuggestion = null + selectedSuggestion = Suggestion.ConstantString("suggestion") ) ) ) ) ) - assertEquals(expectedState, state) - assertContainsCodeBlockChildClickedAnalyticEvent(actions) - } - - @Test - fun `DeleteButtonClicked should not update state if state is not Content`() { - val initialState = StepQuizCodeBlanksFeature.State.Idle - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) - - assertEquals(initialState, state) - assertTrue(actions.isEmpty()) - } - - @Test - fun `DeleteButtonClicked should log analytic event and not update state if no active code block`() { - val initialState = - stubContentState(codeBlocks = listOf(CodeBlock.Blank(isActive = false, suggestions = emptyList()))) - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) assertEquals(initialState, state) @@ -567,56 +70,25 @@ class StepQuizCodeBlanksReducerTest { @Test fun `DeleteButtonClicked should not update state if active code block is Blank and single`() { - val initialState = - stubContentState(codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList()))) - - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) - - assertEquals(initialState, state) - assertContainsDeleteButtonClickedAnalyticEvent(actions) - } - - @Test - fun `DeleteButtonClicked should clear suggestion if active Print code block has selected suggestion`() { - val suggestion = Suggestion.ConstantString("suggestion") - val initialState = stubContentState( + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = listOf(suggestion), - selectedSuggestion = suggestion - ) - ) + CodeBlock.Blank( + isActive = true, + suggestions = emptyList() ) ) ) val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) - val expectedState = initialState.copy( - codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = listOf(suggestion), - selectedSuggestion = null - ) - ) - ) - ) - ) - - assertEquals(expectedState, state) + assertEquals(initialState, state) assertContainsDeleteButtonClickedAnalyticEvent(actions) } @Test fun `DeleteButtonClicked should set next code block as active if no code block before deleted`() { val initialStates = listOf( - stubContentState( + StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Print( children = listOf( @@ -630,13 +102,13 @@ class StepQuizCodeBlanksReducerTest { CodeBlock.Blank(isActive = false, suggestions = emptyList()) ) ), - stubContentState( + StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Blank(isActive = true, suggestions = emptyList()), CodeBlock.Blank(isActive = false, suggestions = emptyList()) ) ), - stubContentState( + StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Blank(isActive = true, suggestions = emptyList()), CodeBlock.Print( @@ -650,7 +122,7 @@ class StepQuizCodeBlanksReducerTest { ) ) ), - stubContentState( + StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Print( children = listOf( @@ -672,7 +144,7 @@ class StepQuizCodeBlanksReducerTest { ) ) ), - stubContentState( + StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Variable( children = listOf( @@ -691,7 +163,7 @@ class StepQuizCodeBlanksReducerTest { CodeBlock.Blank(isActive = false, suggestions = emptyList()) ) ), - stubContentState( + StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Blank(isActive = true, suggestions = emptyList()), CodeBlock.Variable( @@ -710,7 +182,7 @@ class StepQuizCodeBlanksReducerTest { ) ) ), - stubContentState( + StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Variable( children = listOf( @@ -821,7 +293,7 @@ class StepQuizCodeBlanksReducerTest { @Test fun `DeleteButtonClicked should set previous code block as active if has code block before deleted`() { val initialStates = listOf( - stubContentState( + StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Blank(isActive = false, suggestions = emptyList()), CodeBlock.Print( @@ -835,7 +307,7 @@ class StepQuizCodeBlanksReducerTest { ) ) ), - stubContentState( + StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Print( children = listOf( @@ -849,7 +321,7 @@ class StepQuizCodeBlanksReducerTest { CodeBlock.Blank(isActive = true, suggestions = emptyList()) ) ), - stubContentState( + StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Print( children = listOf( @@ -871,7 +343,7 @@ class StepQuizCodeBlanksReducerTest { ) ) ), - stubContentState( + StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( CodeBlock.Blank(isActive = false, suggestions = emptyList()), CodeBlock.Blank(isActive = true, suggestions = emptyList()) @@ -909,45 +381,220 @@ class StepQuizCodeBlanksReducerTest { initialStates[0].copy(codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList()))), ) - initialStates.zip(expectedStates).forEach { (initialState, expectedState) -> - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) - assertEquals(expectedState, state) - assertContainsDeleteButtonClickedAnalyticEvent(actions) - } - } + initialStates.zip(expectedStates).forEach { (initialState, expectedState) -> + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) + assertEquals(expectedState, state) + assertContainsDeleteButtonClickedAnalyticEvent(actions) + } + } + + @Test + fun `DeleteButtonClicked should clear suggestion if active Print code block has selected suggestion`() { + val suggestion = Suggestion.ConstantString("suggestion") + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = suggestion + ) + ) + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsDeleteButtonClickedAnalyticEvent(actions) + } + + @Test + fun `DeleteButtonClicked should remove child for Print code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("suggestion") + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("suggestion") + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsDeleteButtonClickedAnalyticEvent(actions) + } + + @Test + fun `DeleteButtonClicked should replace single Print code block with Blank`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) + + val expectedState = initialState.copy( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print))) + ) + + assertEquals(expectedState, state) + assertContainsDeleteButtonClickedAnalyticEvent(actions) + } + + @Test + fun `DeleteButtonClicked should clear suggestion if active Variable code block has selected suggestion`() { + val suggestion = Suggestion.ConstantString("suggestion") + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = suggestion + ) + ) + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsDeleteButtonClickedAnalyticEvent(actions) + } + + @Test + fun `DeleteButtonClicked should remove child for Variable code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("suggestion") + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) - @Test - fun `DeleteButtonClicked should not update state if no active code block`() { - val initialState = stubContentState( + val expectedState = initialState.copy( codeBlocks = listOf( - CodeBlock.Print( + CodeBlock.Variable( children = listOf( CodeBlockChild.SelectSuggestion( isActive = false, suggestions = emptyList(), selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("suggestion") ) ) ) ) ) - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) - - assertEquals(initialState, state) + assertEquals(expectedState, state) assertContainsDeleteButtonClickedAnalyticEvent(actions) } @Test - fun `DeleteButtonClicked should replace single Print code block with Blank`() { - val initialState = stubContentState( + fun `DeleteButtonClicked should replace single Variable code block with Blank`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( - CodeBlock.Print( + CodeBlock.Variable( children = listOf( CodeBlockChild.SelectSuggestion( isActive = true, suggestions = emptyList(), selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null ) ) ) @@ -965,9 +612,10 @@ class StepQuizCodeBlanksReducerTest { } @Test - fun `DeleteButtonClicked should replace single Variable code block with Blank`() { - val initialState = stubContentState( + fun `DeleteButtonClicked should remove Variable code block and set previous active`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( + CodeBlock.Blank(isActive = false, suggestions = emptyList()), CodeBlock.Variable( children = listOf( CodeBlockChild.SelectSuggestion( @@ -988,7 +636,7 @@ class StepQuizCodeBlanksReducerTest { val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) val expectedState = initialState.copy( - codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print))) + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList())) ) assertEquals(expectedState, state) @@ -996,71 +644,63 @@ class StepQuizCodeBlanksReducerTest { } @Test - fun `EnterButtonClicked should not update state if state is not Content`() { - val initialState = StepQuizCodeBlanksFeature.State.Idle - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.EnterButtonClicked) - - assertEquals(initialState, state) - assertTrue(actions.isEmpty()) - } - - @Test - fun `EnterButtonClicked should log analytic event and not update state if no active code block`() { - val initialState = - stubContentState(codeBlocks = listOf(CodeBlock.Blank(isActive = false, suggestions = emptyList()))) - - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.EnterButtonClicked) - - assertEquals(initialState, state) - assertContainsEnterButtonClickedAnalyticEvent(actions) - } - - @Test - fun `EnterButtonClicked should log analytic event and add new active Blank block if active code block exists`() { - val initialState = - stubContentState(codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList()))) + fun `DeleteButtonClicked should remove Variable code block and set next active`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ), + CodeBlock.Blank(isActive = false, suggestions = emptyList()) + ) + ) - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.EnterButtonClicked) + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) val expectedState = initialState.copy( - codeBlocks = listOf( - CodeBlock.Blank(isActive = false, suggestions = emptyList()), - CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print)) - ) + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList())) ) assertEquals(expectedState, state) - assertContainsEnterButtonClickedAnalyticEvent(actions) + assertContainsDeleteButtonClickedAnalyticEvent(actions) } @Test - fun `EnterButtonClicked should add new active Blank block after active code block`() { - val initialState = stubContentState( + fun `DeleteButtonClicked should clear suggestion if active IfStatement code block has selected suggestion`() { + val suggestion = Suggestion.ConstantString("suggestion") + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( - CodeBlock.Blank(isActive = true, suggestions = emptyList()), - CodeBlock.Print( + CodeBlock.IfStatement( children = listOf( CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = emptyList(), - selectedSuggestion = null + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = suggestion ) ) ) ) ) - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.EnterButtonClicked) + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) val expectedState = initialState.copy( codeBlocks = listOf( - CodeBlock.Blank(isActive = false, suggestions = emptyList()), - CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print)), - CodeBlock.Print( + CodeBlock.IfStatement( children = listOf( CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = emptyList(), + isActive = true, + suggestions = listOf(suggestion), selectedSuggestion = null ) ) @@ -1069,49 +709,39 @@ class StepQuizCodeBlanksReducerTest { ) assertEquals(expectedState, state) - assertContainsEnterButtonClickedAnalyticEvent(actions) - } - - @Test - fun `SpaceButtonClicked should not update state if state is not Content`() { - val initialState = StepQuizCodeBlanksFeature.State.Idle - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.SpaceButtonClicked) - - assertEquals(initialState, state) - assertTrue(actions.isEmpty()) + assertContainsDeleteButtonClickedAnalyticEvent(actions) } @Test - fun `SpaceButtonClicked should not update state if active Print block has no active child`() { - val initialState = stubContentState( + fun `DeleteButtonClicked should remove child for IfStatement code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( - CodeBlock.Print( + CodeBlock.IfStatement( children = listOf( CodeBlockChild.SelectSuggestion( isActive = false, suggestions = emptyList(), selectedSuggestion = Suggestion.ConstantString("suggestion") + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null ) ) ) ) ) - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.SpaceButtonClicked) - - assertEquals(initialState, state) - assertContainsSpaceButtonClickedAnalyticEvent(actions) - } + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) - @Test - fun `SpaceButtonClicked should add a new child to active Print code block`() { - val initialState = stubContentState( + val expectedState = initialState.copy( codeBlocks = listOf( - CodeBlock.Print( + CodeBlock.IfStatement( children = listOf( CodeBlockChild.SelectSuggestion( isActive = true, - suggestions = listOf(Suggestion.ConstantString("suggestion")), + suggestions = emptyList(), selectedSuggestion = Suggestion.ConstantString("suggestion") ) ) @@ -1119,17 +749,16 @@ class StepQuizCodeBlanksReducerTest { ) ) - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.SpaceButtonClicked) + assertEquals(expectedState, state) + assertContainsDeleteButtonClickedAnalyticEvent(actions) + } - val expectedState = initialState.copy( + @Test + fun `DeleteButtonClicked should replace single IfStatement code block with Blank`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( - CodeBlock.Print( + CodeBlock.IfStatement( children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = listOf(Suggestion.ConstantString("suggestion")), - selectedSuggestion = Suggestion.ConstantString("suggestion") - ), CodeBlockChild.SelectSuggestion( isActive = true, suggestions = emptyList(), @@ -1140,49 +769,55 @@ class StepQuizCodeBlanksReducerTest { ) ) + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) + + val expectedState = initialState.copy( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print))) + ) + assertEquals(expectedState, state) - assertContainsSpaceButtonClickedAnalyticEvent(actions) + assertContainsDeleteButtonClickedAnalyticEvent(actions) } + /* ktlint-disable */ @Test - fun `SpaceButtonClicked should add a new child to active Variable code block`() { - val initialState = stubContentState( + fun `DeleteButtonClicked should not replace single IfStatement code block with Blank when next code block has different indent level`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( - CodeBlock.Variable( + CodeBlock.IfStatement( + indentLevel = 0, children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = listOf(Suggestion.ConstantString("x")), - selectedSuggestion = Suggestion.ConstantString("x") - ), CodeBlockChild.SelectSuggestion( isActive = true, - suggestions = listOf(Suggestion.ConstantString("suggestion")), - selectedSuggestion = Suggestion.ConstantString("suggestion") + suggestions = emptyList(), + selectedSuggestion = null ) ) - ) + ), + CodeBlock.Blank(indentLevel = 1, isActive = false, suggestions = emptyList()) ) ) - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.SpaceButtonClicked) + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) - val expectedState = initialState.copy( + assertEquals(initialState, state) + assertContainsDeleteButtonClickedAnalyticEvent(actions) + } + + @Test + fun `DeleteButtonClicked should remove IfStatement code block and set previous active`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( - CodeBlock.Variable( + CodeBlock.Blank(isActive = false, suggestions = emptyList()), + CodeBlock.IfStatement( children = listOf( CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = listOf(Suggestion.ConstantString("x")), - selectedSuggestion = Suggestion.ConstantString("x") + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null ), CodeBlockChild.SelectSuggestion( isActive = false, - suggestions = listOf(Suggestion.ConstantString("suggestion")), - selectedSuggestion = Suggestion.ConstantString("suggestion") - ), - CodeBlockChild.SelectSuggestion( - isActive = true, suggestions = emptyList(), selectedSuggestion = null ) @@ -1191,159 +826,127 @@ class StepQuizCodeBlanksReducerTest { ) ) + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) + + val expectedState = initialState.copy( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList())) + ) + assertEquals(expectedState, state) - assertContainsSpaceButtonClickedAnalyticEvent(actions) + assertContainsDeleteButtonClickedAnalyticEvent(actions) } @Test - fun `SpaceButtonClicked should add a new child with operations suggestions after closing parentheses`() { - val initialState = stubContentState( - step = Step.stub( - id = 1, - block = Block.stub( - options = Block.Options(codeBlanksOperations = listOf("*", "+")) - ) - ), + fun `DeleteButtonClicked should remove IfStatement code block and set next active`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( - CodeBlock.Print( + CodeBlock.IfStatement( children = listOf( CodeBlockChild.SelectSuggestion( isActive = true, - suggestions = listOf(Suggestion.ConstantString(")")), - selectedSuggestion = Suggestion.ConstantString(")") + suggestions = emptyList(), + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null ) ) - ) + ), + CodeBlock.Blank(isActive = false, suggestions = emptyList()) ) ) - val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.SpaceButtonClicked) + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) val expectedState = initialState.copy( - codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = false, - suggestions = listOf(Suggestion.ConstantString(")")), - selectedSuggestion = Suggestion.ConstantString(")") - ), - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = initialState.codeBlanksOperationsSuggestions, - selectedSuggestion = null - ) - ) - ) - ) + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList())) ) assertEquals(expectedState, state) - assertContainsSpaceButtonClickedAnalyticEvent(actions) + assertContainsDeleteButtonClickedAnalyticEvent(actions) } + /* ktlint-disable */ @Test - fun `Onboarding should be unavailable`() { - val initialState = StepQuizCodeBlanksFeature.State.Idle - val (state, _) = reducer.reduce( - initialState, - StepQuizCodeBlanksFeature.InternalMessage.Initialize(Step.stub(id = 1)) + fun `DeleteButtonClicked should not replace single ElseStatement code block with Blank when next code block has different indent level`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.ElseStatement( + indentLevel = 0, + isActive = true + ), + CodeBlock.Blank(indentLevel = 1, isActive = false, suggestions = emptyList()) + ) ) - assertTrue(state is StepQuizCodeBlanksFeature.State.Content) - assertTrue(state.onboardingState is OnboardingState.Unavailable) + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) + + assertEquals(initialState, state) + assertContainsDeleteButtonClickedAnalyticEvent(actions) } @Test - fun `Onboarding should be available`() { - val initialState = StepQuizCodeBlanksFeature.State.Idle - val (state, _) = reducer.reduce( - initialState, - StepQuizCodeBlanksFeature.InternalMessage.Initialize(Step.stub(id = 47329)) + fun `DeleteButtonClicked should replace single ElseStatement code block with Blank`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf(CodeBlock.ElseStatement(isActive = true)) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) + + val expectedState = initialState.copy( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print))) ) - assertTrue(state is StepQuizCodeBlanksFeature.State.Content) - assertTrue(state.onboardingState is OnboardingState.HighlightSuggestions) + assertEquals(expectedState, state) + assertContainsDeleteButtonClickedAnalyticEvent(actions) } @Test - fun `Onboarding SuggestionClicked should update onboardingState to HighlightCallToActionButton`() { - val suggestion = Suggestion.ConstantString("suggestion") - val initialState = stubContentState( + fun `DeleteButtonClicked should remove ElseStatement code block and set previous active`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( codeBlocks = listOf( - CodeBlock.Print( - children = listOf( - CodeBlockChild.SelectSuggestion( - isActive = true, - suggestions = listOf(suggestion), - selectedSuggestion = null - ) - ) - ) - ), - onboardingState = OnboardingState.HighlightSuggestions + CodeBlock.Blank(isActive = false, suggestions = emptyList()), + CodeBlock.ElseStatement(isActive = true) + ) ) - val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(suggestion) - val (state, _) = reducer.reduce(initialState, message) + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) - assertTrue(state is StepQuizCodeBlanksFeature.State.Content) - assertEquals(OnboardingState.HighlightCallToActionButton, state.onboardingState) - } + val expectedState = initialState.copy( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList())) + ) - private fun assertContainsSuggestionClickedAnalyticEvent(actions: Set) { - assertTrue { - actions.any { - it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && - it.analyticEvent is StepQuizCodeBlanksClickedSuggestionHyperskillAnalyticEvent - } - } + assertEquals(expectedState, state) + assertContainsDeleteButtonClickedAnalyticEvent(actions) } - private fun assertContainsCodeBlockChildClickedAnalyticEvent(actions: Set) { - assertTrue { - actions.any { - it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && - it.analyticEvent is StepQuizCodeBlanksClickedCodeBlockChildHyperskillAnalyticEvent - } - } - } + @Test + fun `DeleteButtonClicked should remove ElseStatement code block and set next active`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.ElseStatement(isActive = true), + CodeBlock.Blank(isActive = false, suggestions = emptyList()) + ) + ) - private fun assertContainsDeleteButtonClickedAnalyticEvent(actions: Set) { - assertTrue { - actions.any { - it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && - it.analyticEvent is StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent - } - } - } + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked) - private fun assertContainsEnterButtonClickedAnalyticEvent(actions: Set) { - assertTrue { - actions.any { - it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && - it.analyticEvent is StepQuizCodeBlanksClickedEnterHyperskillAnalyticEvent - } - } + val expectedState = initialState.copy( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = emptyList())) + ) + + assertEquals(expectedState, state) + assertContainsDeleteButtonClickedAnalyticEvent(actions) } - private fun assertContainsSpaceButtonClickedAnalyticEvent(actions: Set) { + private fun assertContainsDeleteButtonClickedAnalyticEvent(actions: Set) { assertTrue { actions.any { it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && - it.analyticEvent is StepQuizCodeBlanksClickedSpaceHyperskillAnalyticEvent + it.analyticEvent is StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent } } } - - private fun stubContentState( - step: Step = Step.stub(id = 1), - codeBlocks: List, - onboardingState: OnboardingState = OnboardingState.Unavailable - ): StepQuizCodeBlanksFeature.State.Content = - StepQuizCodeBlanksFeature.State.Content( - step = step, - codeBlocks = codeBlocks, - onboardingState = onboardingState - ) } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerElifAndElseStatementsSuggestionsAvailabilityTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerElifAndElseStatementsSuggestionsAvailabilityTest.kt new file mode 100644 index 0000000000..726b291af1 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerElifAndElseStatementsSuggestionsAvailabilityTest.kt @@ -0,0 +1,89 @@ +package org.hyperskill.step_quiz_code_blanks.presentation + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksResolver + +class StepQuizCodeBlanksReducerElifAndElseStatementsSuggestionsAvailabilityTest { + @Test + fun `Should return false if index is less than 2`() { + val result = StepQuizCodeBlanksResolver.areElifAndElseStatementsSuggestionsAvailable( + index = 1, + indentLevel = 0, + codeBlocks = emptyList() + ) + assertFalse(result) + } + + @Test + fun `Should return false if codeBlocks is empty`() { + val result = StepQuizCodeBlanksResolver.areElifAndElseStatementsSuggestionsAvailable( + index = 2, + indentLevel = 0, + codeBlocks = emptyList() + ) + assertFalse(result) + } + + @Test + fun `Should return false if no previous code block at same indent level`() { + val codeBlocks = listOf( + CodeBlock.IfStatement(indentLevel = 0, children = emptyList()), + CodeBlock.Print(indentLevel = 1, children = emptyList()) + ) + val result = StepQuizCodeBlanksResolver.areElifAndElseStatementsSuggestionsAvailable( + index = 2, + indentLevel = 1, + codeBlocks = codeBlocks + ) + assertFalse(result) + } + + @Test + fun `Should return true if previous code block is IfStatement at same indent level`() { + val codeBlocks = listOf( + CodeBlock.IfStatement(indentLevel = 0, children = emptyList()), + CodeBlock.Print(indentLevel = 1, children = emptyList()) + ) + val result = StepQuizCodeBlanksResolver.areElifAndElseStatementsSuggestionsAvailable( + index = 2, + indentLevel = 0, + codeBlocks = codeBlocks + ) + assertTrue(result) + } + + @Test + fun `Should return true if previous code block is IfStatement at same indent level nested`() { + val codeBlocks = listOf( + CodeBlock.IfStatement(indentLevel = 0, children = emptyList()), + CodeBlock.Print(indentLevel = 1, children = emptyList()), + CodeBlock.IfStatement(indentLevel = 1, children = emptyList()), + CodeBlock.Print(indentLevel = 2, children = emptyList()) + ) + val result = StepQuizCodeBlanksResolver.areElifAndElseStatementsSuggestionsAvailable( + index = 4, + indentLevel = 1, + codeBlocks = codeBlocks + ) + assertTrue(result) + } + + @Test + fun `Should return true if previous code block is ElifStatement at same indent level`() { + val codeBlocks = listOf( + CodeBlock.IfStatement(indentLevel = 0, children = emptyList()), + CodeBlock.Print(indentLevel = 1, children = emptyList()), + CodeBlock.ElifStatement(indentLevel = 0, children = emptyList()), + CodeBlock.Print(indentLevel = 1, children = emptyList()) + ) + val result = StepQuizCodeBlanksResolver.areElifAndElseStatementsSuggestionsAvailable( + index = 4, + indentLevel = 0, + codeBlocks = codeBlocks + ) + assertTrue(result) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerEnterButtonClickedTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerEnterButtonClickedTest.kt new file mode 100644 index 0000000000..b25d8cd19c --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerEnterButtonClickedTest.kt @@ -0,0 +1,149 @@ +package org.hyperskill.step_quiz_code_blanks.presentation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.hyperskill.app.step.domain.model.StepRoute +import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedEnterHyperskillAnalyticEvent +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksReducer + +class StepQuizCodeBlanksReducerEnterButtonClickedTest { + private val reducer = StepQuizCodeBlanksReducer(StepRoute.Learn.Step(1, null)) + + @Test + fun `EnterButtonClicked should not update state if state is not Content`() { + val initialState = StepQuizCodeBlanksFeature.State.Idle + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.EnterButtonClicked) + + assertEquals(initialState, state) + assertTrue(actions.isEmpty()) + } + + @Test + fun `EnterButtonClicked should not update state if no active code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = false, + suggestions = emptyList() + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.EnterButtonClicked) + + assertEquals(initialState, state) + assertContainsEnterButtonClickedAnalyticEvent(actions) + } + + @Test + fun `EnterButtonClicked should append new active Blank block if active code block exists`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = true, + suggestions = emptyList() + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.EnterButtonClicked) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Blank(isActive = false, suggestions = emptyList()), + CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print)) + ) + ) + + assertEquals(expectedState, state) + assertContainsEnterButtonClickedAnalyticEvent(actions) + } + + @Test + fun `EnterButtonClicked should add new active Blank block after active code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank(isActive = true, suggestions = emptyList()), + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.EnterButtonClicked) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Blank(isActive = false, suggestions = emptyList()), + CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print)), + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsEnterButtonClickedAnalyticEvent(actions) + } + + @Test + fun `EnterButtonClicked should add Blank with next indentLevel if active code block is condition`() { + val codeBlocks = listOf( + CodeBlock.IfStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ), + CodeBlock.ElifStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ), + CodeBlock.ElseStatement(isActive = true) + ) + + codeBlocks.forEach { codeBlock -> + val initialState = StepQuizCodeBlanksFeature.State.Content.stub(codeBlocks = listOf(codeBlock)) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.EnterButtonClicked) + + assertTrue(state is StepQuizCodeBlanksFeature.State.Content) + assertEquals(1, state.codeBlocks[1].indentLevel) + assertContainsEnterButtonClickedAnalyticEvent(actions) + } + } + + private fun assertContainsEnterButtonClickedAnalyticEvent(actions: Set) { + assertTrue { + actions.any { + it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && + it.analyticEvent is StepQuizCodeBlanksClickedEnterHyperskillAnalyticEvent + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerInitializeTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerInitializeTest.kt new file mode 100644 index 0000000000..ef8ad0c509 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerInitializeTest.kt @@ -0,0 +1,59 @@ +package org.hyperskill.step_quiz_code_blanks.presentation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.hyperskill.app.step.domain.model.Block +import org.hyperskill.app.step.domain.model.Step +import org.hyperskill.app.step.domain.model.StepRoute +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksReducer +import org.hyperskill.step.domain.model.stub + +class StepQuizCodeBlanksReducerInitializeTest { + private val reducer = StepQuizCodeBlanksReducer(StepRoute.Learn.Step(1, null)) + + @Test + fun `Initialize should return Content state with active Blank and Print and Variable and If suggestions`() { + val step = Step.stub( + id = 1, + block = Block.stub(options = Block.Options(codeBlanksVariables = listOf("a", "b"))) + ) + + val message = StepQuizCodeBlanksFeature.InternalMessage.Initialize(step) + val (state, actions) = reducer.reduce(StepQuizCodeBlanksFeature.State.Idle, message) + + val expectedState = StepQuizCodeBlanksFeature.State.Content( + step = step, + codeBlocks = listOf( + CodeBlock.Blank( + isActive = true, + suggestions = listOf(Suggestion.Print, Suggestion.Variable, Suggestion.IfStatement) + ) + ) + ) + + assertTrue(state is StepQuizCodeBlanksFeature.State.Content) + assertEquals(expectedState.codeBlocks, state.codeBlocks) + assertTrue(actions.isEmpty()) + } + + @Test + fun `Initialize should return Content state with active Blank and Print suggestion`() { + val step = Step.stub(id = 1) + + val message = StepQuizCodeBlanksFeature.InternalMessage.Initialize(step) + val (state, actions) = reducer.reduce(StepQuizCodeBlanksFeature.State.Idle, message) + + val expectedState = StepQuizCodeBlanksFeature.State.Content( + step = step, + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print))) + ) + + assertTrue(state is StepQuizCodeBlanksFeature.State.Content) + assertEquals(expectedState.codeBlocks, state.codeBlocks) + assertTrue(actions.isEmpty()) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerOnboardingTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerOnboardingTest.kt new file mode 100644 index 0000000000..e6f4e5dafa --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerOnboardingTest.kt @@ -0,0 +1,67 @@ +package org.hyperskill.step_quiz_code_blanks.presentation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.hyperskill.app.step.domain.model.Step +import org.hyperskill.app.step.domain.model.StepRoute +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature.OnboardingState +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksReducer +import org.hyperskill.step.domain.model.stub + +class StepQuizCodeBlanksReducerOnboardingTest { + private val reducer = StepQuizCodeBlanksReducer(StepRoute.Learn.Step(1, null)) + + @Test + fun `Onboarding should be unavailable`() { + val initialState = StepQuizCodeBlanksFeature.State.Idle + val (state, _) = reducer.reduce( + initialState, + StepQuizCodeBlanksFeature.InternalMessage.Initialize(Step.stub(id = 1)) + ) + + assertTrue(state is StepQuizCodeBlanksFeature.State.Content) + assertTrue(state.onboardingState is OnboardingState.Unavailable) + } + + @Test + fun `Onboarding should be available`() { + val initialState = StepQuizCodeBlanksFeature.State.Idle + val (state, _) = reducer.reduce( + initialState, + StepQuizCodeBlanksFeature.InternalMessage.Initialize(Step.stub(id = 47329)) + ) + + assertTrue(state is StepQuizCodeBlanksFeature.State.Content) + assertTrue(state.onboardingState is OnboardingState.HighlightSuggestions) + } + + @Test + fun `Onboarding SuggestionClicked should update onboardingState to HighlightCallToActionButton`() { + val suggestion = Suggestion.ConstantString("suggestion") + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = null + ) + ) + ) + ), + onboardingState = OnboardingState.HighlightSuggestions + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(suggestion) + val (state, _) = reducer.reduce(initialState, message) + + assertTrue(state is StepQuizCodeBlanksFeature.State.Content) + assertEquals(OnboardingState.HighlightCallToActionButton, state.onboardingState) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerSpaceButtonClickedTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerSpaceButtonClickedTest.kt new file mode 100644 index 0000000000..3c8166d551 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerSpaceButtonClickedTest.kt @@ -0,0 +1,227 @@ +package org.hyperskill.step_quiz_code_blanks.presentation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.hyperskill.app.step.domain.model.Block +import org.hyperskill.app.step.domain.model.Step +import org.hyperskill.app.step.domain.model.StepRoute +import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedSpaceHyperskillAnalyticEvent +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksReducer +import org.hyperskill.step.domain.model.stub + +class StepQuizCodeBlanksReducerSpaceButtonClickedTest { + private val reducer = StepQuizCodeBlanksReducer(StepRoute.Learn.Step(1, null)) + + @Test + fun `SpaceButtonClicked should not update state if state is not Content`() { + val initialState = StepQuizCodeBlanksFeature.State.Idle + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.SpaceButtonClicked) + + assertEquals(initialState, state) + assertTrue(actions.isEmpty()) + } + + @Test + fun `SpaceButtonClicked should not update state if no active code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("suggestion") + ) + ) + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.SpaceButtonClicked) + + assertEquals(initialState, state) + assertContainsSpaceButtonClickedAnalyticEvent(actions) + } + + @Test + fun `SpaceButtonClicked should not update state if active code block is Blank`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = true, + suggestions = emptyList() + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.SpaceButtonClicked) + + assertEquals(initialState, state) + assertContainsSpaceButtonClickedAnalyticEvent(actions) + } + + @Test + fun `SpaceButtonClicked should not update state if active code block is ElseStatement`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf(CodeBlock.ElseStatement(isActive = true)) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.SpaceButtonClicked) + + assertEquals(initialState, state) + assertContainsSpaceButtonClickedAnalyticEvent(actions) + } + + @Test + fun `SpaceButtonClicked should add a new child to active Print code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(Suggestion.ConstantString("suggestion")), + selectedSuggestion = Suggestion.ConstantString("suggestion") + ) + ) + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.SpaceButtonClicked) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = listOf(Suggestion.ConstantString("suggestion")), + selectedSuggestion = Suggestion.ConstantString("suggestion") + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsSpaceButtonClickedAnalyticEvent(actions) + } + + @Test + fun `SpaceButtonClicked should add a new child to active Variable code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = listOf(Suggestion.ConstantString("x")), + selectedSuggestion = Suggestion.ConstantString("x") + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(Suggestion.ConstantString("suggestion")), + selectedSuggestion = Suggestion.ConstantString("suggestion") + ) + ) + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.SpaceButtonClicked) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = listOf(Suggestion.ConstantString("x")), + selectedSuggestion = Suggestion.ConstantString("x") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = listOf(Suggestion.ConstantString("suggestion")), + selectedSuggestion = Suggestion.ConstantString("suggestion") + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsSpaceButtonClickedAnalyticEvent(actions) + } + + @Test + fun `SpaceButtonClicked should add a new child with operations suggestions after closing parentheses`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + step = Step.stub( + id = 1, + block = Block.stub( + options = Block.Options(codeBlanksOperations = listOf("*", "+")) + ) + ), + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(Suggestion.ConstantString(")")), + selectedSuggestion = Suggestion.ConstantString(")") + ) + ) + ) + ) + ) + + val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.SpaceButtonClicked) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = listOf(Suggestion.ConstantString(")")), + selectedSuggestion = Suggestion.ConstantString(")") + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = initialState.codeBlanksOperationsSuggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsSpaceButtonClickedAnalyticEvent(actions) + } + + private fun assertContainsSpaceButtonClickedAnalyticEvent(actions: Set) { + assertTrue { + actions.any { + it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && + it.analyticEvent is StepQuizCodeBlanksClickedSpaceHyperskillAnalyticEvent + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerSuggestionClickedTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerSuggestionClickedTest.kt new file mode 100644 index 0000000000..5e3970aff8 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducerSuggestionClickedTest.kt @@ -0,0 +1,423 @@ +package org.hyperskill.step_quiz_code_blanks.presentation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.hyperskill.app.step.domain.model.StepRoute +import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedSuggestionHyperskillAnalyticEvent +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksReducer + +class StepQuizCodeBlanksReducerSuggestionClickedTest { + private val reducer = StepQuizCodeBlanksReducer(StepRoute.Learn.Step(1, null)) + + @Test + fun `SuggestionClicked should not update state if no active code block`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = false, + suggestions = emptyList() + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.Print) + val (state, actions) = reducer.reduce(initialState, message) + + assertEquals(initialState, state) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + @Test + fun `SuggestionClicked should not update state if suggestion does not exist`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = true, + suggestions = emptyList() + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.ConstantString("test")) + val (state, actions) = reducer.reduce(initialState, message) + + assertEquals(initialState, state) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + @Test + fun `SuggestionClicked should not update state if state is not Content`() { + val initialState = StepQuizCodeBlanksFeature.State.Idle + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.Print) + val (state, actions) = reducer.reduce(initialState, message) + + assertEquals(initialState, state) + assertTrue(actions.isEmpty()) + } + + @Test + fun `SuggestionClicked should update Blank to Print`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = true, + suggestions = listOf(Suggestion.Print) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.Print) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = initialState.codeBlanksStringsSuggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + @Test + fun `SuggestionClicked should update Blank to Variable`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = true, + suggestions = listOf(Suggestion.Print, Suggestion.Variable) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.Variable) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = initialState.codeBlanksVariablesSuggestions, + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = initialState.codeBlanksStringsSuggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + @Test + fun `SuggestionClicked should update Blank to IfStatement`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = true, + suggestions = listOf(Suggestion.Print, Suggestion.IfStatement) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.IfStatement) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.IfStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = initialState.codeBlanksVariablesAndStringsSuggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + @Test + fun `SuggestionClicked should update Blank to ElifStatement`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = true, + suggestions = listOf(Suggestion.Print, Suggestion.ElifStatement) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.ElifStatement) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.ElifStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = initialState.codeBlanksVariablesAndStringsSuggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + @Test + fun `SuggestionClicked should update Blank to ElseStatement and add Blank with next indentLevel`() { + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = true, + suggestions = listOf(Suggestion.Print, Suggestion.ElseStatement) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.ElseStatement) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.ElseStatement( + isActive = false, + indentLevel = 0 + ), + CodeBlock.Blank( + isActive = true, + indentLevel = 1, + suggestions = listOf(Suggestion.Print) + ) + ) + ) + + assertEquals(expectedState, state) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + @Test + fun `SuggestionClicked should update Print code block with selected suggestion`() { + val suggestion = Suggestion.ConstantString("suggestion") + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(suggestion) + val (state, actions) = reducer.reduce(initialState, message) + + assertTrue(state is StepQuizCodeBlanksFeature.State.Content) + assertEquals(suggestion, (state.codeBlocks[0] as CodeBlock.Print).children[0].selectedSuggestion) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + @Test + fun `SuggestionClicked should update IfStatement code block with selected suggestion`() { + val suggestion = Suggestion.ConstantString("suggestion") + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.IfStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(suggestion) + val (state, actions) = reducer.reduce(initialState, message) + + assertTrue(state is StepQuizCodeBlanksFeature.State.Content) + assertEquals(suggestion, (state.codeBlocks[0] as CodeBlock.IfStatement).children[0].selectedSuggestion) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + @Test + fun `SuggestionClicked should update ElifStatement code block with selected suggestion`() { + val suggestion = Suggestion.ConstantString("suggestion") + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.ElifStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(suggestion) + val (state, actions) = reducer.reduce(initialState, message) + + assertTrue(state is StepQuizCodeBlanksFeature.State.Content) + assertEquals(suggestion, (state.codeBlocks[0] as CodeBlock.ElifStatement).children[0].selectedSuggestion) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + @Test + fun `SuggestionClicked should update Variable code block with selected suggestion for name`() { + val suggestion = Suggestion.ConstantString("suggestion") + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = listOf(suggestion), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(suggestion) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = listOf(suggestion), + selectedSuggestion = suggestion + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = null + ) + ) + ) + ) + ) + + assertTrue(state is StepQuizCodeBlanksFeature.State.Content) + assertEquals(expectedState.codeBlocks, state.codeBlocks) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + @Test + fun `SuggestionClicked should update Variable code block with selected suggestion for value`() { + val suggestion = Suggestion.ConstantString("suggestion") + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = listOf(suggestion), + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(suggestion) + val (state, actions) = reducer.reduce(initialState, message) + + val expectedState = initialState.copy( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(suggestion), + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = listOf(suggestion), + selectedSuggestion = suggestion + ) + ) + ) + ) + ) + + assertTrue(state is StepQuizCodeBlanksFeature.State.Content) + assertEquals(expectedState.codeBlocks, state.codeBlocks) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + @Test + fun `SuggestionClicked should not update ElseStatement code block with selected suggestion`() { + val suggestion = Suggestion.ConstantString("suggestion") + val initialState = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf(CodeBlock.ElseStatement(isActive = true)) + ) + + val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(suggestion) + val (state, actions) = reducer.reduce(initialState, message) + + assertEquals(initialState, state) + assertContainsSuggestionClickedAnalyticEvent(actions) + } + + private fun assertContainsSuggestionClickedAnalyticEvent(actions: Set) { + assertTrue { + actions.any { + it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent && + it.analyticEvent is StepQuizCodeBlanksClickedSuggestionHyperskillAnalyticEvent + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksStateExtensionsTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksStateExtensionsTest.kt new file mode 100644 index 0000000000..02dc52f29d --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/presentation/StepQuizCodeBlanksStateExtensionsTest.kt @@ -0,0 +1,85 @@ +package org.hyperskill.step_quiz_code_blanks.presentation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.hyperskill.app.step.domain.model.Block +import org.hyperskill.app.step.domain.model.Step +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.presentation.activeCodeBlockIndex +import org.hyperskill.app.step_quiz_code_blanks.presentation.isVariableSuggestionsAvailable +import org.hyperskill.step.domain.model.stub + +class StepQuizCodeBlanksStateExtensionsTest { + @Test + fun `activeCodeBlockIndex should return null if no active code block`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank(isActive = false, suggestions = emptyList()), + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + assertNull(state.activeCodeBlockIndex()) + } + + @Test + fun `activeCodeBlockIndex should return index of the active code block`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank(isActive = false, suggestions = emptyList()), + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + assertEquals(1, state.activeCodeBlockIndex()) + } + + @Test + fun `isVariableSuggestionsAvailable should return true if variable suggestions are available`() { + val step = Step.stub( + id = 1, + block = Block.stub(options = Block.Options(codeBlanksVariables = listOf("a", "b"))) + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + step = step, + codeBlocks = emptyList() + ) + + assertTrue(state.isVariableSuggestionsAvailable) + } + + @Test + fun `isVariableSuggestionsAvailable should return false if variable suggestions are not available`() { + listOf(null, emptyList()).forEach { codeBlanksVariables -> + val step = Step.stub( + id = 1, + block = Block.stub(options = Block.Options(codeBlanksVariables = codeBlanksVariables)) + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + step = step, + codeBlocks = emptyList() + ) + + assertFalse(state.isVariableSuggestionsAvailable) + } + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/template/CodeBlanksTemplateMapperTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/template/CodeBlanksTemplateMapperTest.kt new file mode 100644 index 0000000000..aede0c7a74 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/template/CodeBlanksTemplateMapperTest.kt @@ -0,0 +1,104 @@ +package org.hyperskill.step_quiz_code_blanks.template + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.hyperskill.app.step.domain.model.Block +import org.hyperskill.app.step.domain.model.Step +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.domain.model.template.CodeBlanksTemplateMapper +import org.hyperskill.app.step_quiz_code_blanks.domain.model.template.CodeBlockTemplateEntry +import org.hyperskill.app.step_quiz_code_blanks.domain.model.template.CodeBlockTemplateEntryType +import org.hyperskill.step.domain.model.stub + +class CodeBlanksTemplateMapperTest { + @Test + fun `map should return math expression code blocks when step ID matches math expression template ID`() { + val step = Step.stub(id = 47580) // Math expressions template step ID + + val result = CodeBlanksTemplateMapper.map(step) + + assertEquals(4, result.size) + assertTrue(result[0] is CodeBlock.Variable) + assertTrue(result[1] is CodeBlock.Variable) + assertTrue(result[2] is CodeBlock.Variable) + assertTrue(result[3] is CodeBlock.Blank) + } + + @Test + fun `map should return empty list when code blanks template contains unknown type`() { + val step = Step.stub( + id = 1, + block = Block.stub( + options = Block.Options( + codeBlanksTemplate = listOf( + CodeBlockTemplateEntry(type = CodeBlockTemplateEntryType.UNKNOWN) + ) + ) + ) + ) + + val result = CodeBlanksTemplateMapper.map(step) + + assertTrue(result.isEmpty()) + } + + @Test + fun `map should return parsed code blocks when code blanks template is available`() { + val codeBlanksTemplate = listOf( + CodeBlockTemplateEntry( + type = CodeBlockTemplateEntryType.VARIABLE, + indentLevel = 0, + isActive = false, + isDeleteForbidden = true, + children = listOf("x", "1000") + ), + CodeBlockTemplateEntry( + type = CodeBlockTemplateEntryType.PRINT, + indentLevel = 0, + isActive = true, + isDeleteForbidden = false, + children = emptyList() + ) + ) + val step = Step.stub( + id = 1, + block = Block.stub(options = Block.Options(codeBlanksTemplate = codeBlanksTemplate)) + ) + + val expectedCodeBlocks = listOf( + CodeBlock.Variable( + indentLevel = 0, + isDeleteForbidden = true, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("x") + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("1000") + ) + ) + ), + CodeBlock.Print( + indentLevel = 0, + isDeleteForbidden = false, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + + val actualCodeBlocks = CodeBlanksTemplateMapper.map(step) + assertEquals(expectedCodeBlocks, actualCodeBlocks) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperIsDecreaseIndentLevelButtonHiddenTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperIsDecreaseIndentLevelButtonHiddenTest.kt new file mode 100644 index 0000000000..e69afd6c12 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperIsDecreaseIndentLevelButtonHiddenTest.kt @@ -0,0 +1,104 @@ +package org.hyperskill.step_quiz_code_blanks.view + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.view.mapper.StepQuizCodeBlanksViewStateMapper +import org.hyperskill.app.step_quiz_code_blanks.view.model.StepQuizCodeBlanksViewState +import org.hyperskill.step_quiz_code_blanks.presentation.stub + +class StepQuizCodeBlanksViewStateMapperIsDecreaseIndentLevelButtonHiddenTest { + @Test + fun `isDecreaseIndentLevelButtonHidden should be true when no active code block`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank( + isActive = false, + suggestions = emptyList() + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDecreaseIndentLevelButtonHidden) + } + + @Test + fun `isDecreaseIndentLevelButtonHidden should be true when active code block's indent level is less than 1`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, indentLevel = 0, suggestions = emptyList())) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDecreaseIndentLevelButtonHidden) + } + + @Test + fun `isDecreaseIndentLevelButtonHidden should be false when active code block's indent level is 1 or more`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + indentLevel = 1, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertFalse(viewState.isDecreaseIndentLevelButtonHidden) + } + + @Test + fun `isDecreaseIndentLevelButtonHidden should be true when previous code block is IfStatement`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.IfStatement(indentLevel = 1, children = emptyList()), + CodeBlock.Blank(isActive = true, indentLevel = 1, suggestions = emptyList()) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDecreaseIndentLevelButtonHidden) + } + + @Test + fun `isDecreaseIndentLevelButtonHidden should be false when previous code block is not IfStatement`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print(indentLevel = 1, children = emptyList()), + CodeBlock.Print( + indentLevel = 1, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertFalse(viewState.isDecreaseIndentLevelButtonHidden) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperIsDeleteButtonEnabledTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperIsDeleteButtonEnabledTest.kt new file mode 100644 index 0000000000..d1129ff17e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperIsDeleteButtonEnabledTest.kt @@ -0,0 +1,418 @@ +package org.hyperskill.step_quiz_code_blanks.view + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.view.mapper.StepQuizCodeBlanksViewStateMapper +import org.hyperskill.app.step_quiz_code_blanks.view.model.StepQuizCodeBlanksViewState +import org.hyperskill.step_quiz_code_blanks.presentation.stub + +class StepQuizCodeBlanksViewStateMapperIsDeleteButtonEnabledTest { + @Test + fun `isDeleteButtonEnabled should be false when isDeleteForbidden`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Blank(isActive = false, isDeleteForbidden = false, suggestions = emptyList()), + CodeBlock.Blank(isActive = true, isDeleteForbidden = true, suggestions = emptyList()) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertFalse(viewState.isDeleteButtonEnabled) + } + + @Test + fun `isDeleteButtonEnabled should be false when active code block is Blank and single`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print))) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertFalse(viewState.isDeleteButtonEnabled) + } + + @Test + fun `isDeleteButtonEnabled should be true when active code block is Print and single`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = listOf(Suggestion.Print), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDeleteButtonEnabled) + } + + @Test + fun `isDeleteButtonEnabled should be true when Variable active name is unselected`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDeleteButtonEnabled) + } + + @Test + fun `isDeleteButtonEnabled should be true when Variable active name is selected`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = suggestions[0] + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDeleteButtonEnabled) + } + + @Test + fun `isDeleteButtonEnabled should be true when Variable active value child index is greater than one`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = suggestions, + selectedSuggestion = suggestions[0] + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = suggestions, + selectedSuggestion = suggestions[1] + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDeleteButtonEnabled) + } + + @Test + fun `isDeleteButtonEnabled should be false when Variable name is selected and active value is unselected`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = suggestions, + selectedSuggestion = suggestions[0] + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertFalse(viewState.isDeleteButtonEnabled) + } + + @Test + fun `isDeleteButtonEnabled should be true when Variable name is selected and active value is selected`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = suggestions, + selectedSuggestion = suggestions[0] + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = suggestions[1] + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDeleteButtonEnabled) + } + + @Test + fun `isDeleteButtonEnabled should be true when Variable name is unselected and active value is unselected`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = suggestions, + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDeleteButtonEnabled) + } + + @Test + fun `isDeleteButtonEnabled should be true when Variable name is unselected and active value is selected`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = suggestions, + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = suggestions[0] + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDeleteButtonEnabled) + } + + @Test + fun `isDeleteButtonEnabled should be true when IfStatement active child index greater than zero`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.IfStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = suggestions, + selectedSuggestion = suggestions[0] + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDeleteButtonEnabled) + } + + @Test + fun `isDeleteButtonEnabled should be true when IfStatement child is selected`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.IfStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = suggestions[0] + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDeleteButtonEnabled) + } + + /* ktlint-disable */ + @Test + fun `isDeleteButtonEnabled should be true when IfStatement child is unselected and next code block on same indent level`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.IfStatement( + indentLevel = 1, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + ), + CodeBlock.Print( + indentLevel = 1, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isDeleteButtonEnabled) + } + + /* ktlint-disable */ + @Test + fun `isDeleteButtonEnabled should be false when IfStatement child is unselected and next code block on different indent level`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.IfStatement( + indentLevel = 1, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + ), + CodeBlock.Print( + indentLevel = 2, + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertFalse(viewState.isDeleteButtonEnabled) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperIsSpaceButtonHiddenTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperIsSpaceButtonHiddenTest.kt new file mode 100644 index 0000000000..027aedd37e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperIsSpaceButtonHiddenTest.kt @@ -0,0 +1,239 @@ +package org.hyperskill.step_quiz_code_blanks.view + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.hyperskill.app.step.domain.model.Block +import org.hyperskill.app.step.domain.model.Step +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.view.mapper.StepQuizCodeBlanksViewStateMapper +import org.hyperskill.app.step_quiz_code_blanks.view.model.StepQuizCodeBlanksViewState +import org.hyperskill.step.domain.model.stub +import org.hyperskill.step_quiz_code_blanks.presentation.stub + +class StepQuizCodeBlanksViewStateMapperIsSpaceButtonHiddenTest { + private val step = Step.stub( + id = 0, + block = Block.stub( + options = Block.Options( + codeBlanksOperations = listOf("+") + ) + ) + ) + + @Test + fun `isSpaceButtonHidden should be true when codeBlanksOperationsSuggestions is empty`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + step = Step.stub(id = 0), + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("suggestion") + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isSpaceButtonHidden) + } + + @Test + fun `isSpaceButtonHidden should be true when no active code block`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + step = step, + codeBlocks = listOf( + CodeBlock.Blank( + isActive = false, + suggestions = emptyList() + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isSpaceButtonHidden) + } + + @Test + fun `isSpaceButtonHidden should be true when active Print code block has no active child`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + step = step, + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isSpaceButtonHidden) + } + + @Test + fun `isSpaceButtonHidden should be true when active Print code block child has no selected suggestion`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + step = step, + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isSpaceButtonHidden) + } + + @Test + fun `isSpaceButtonHidden should be false when active Print code block child has selected suggestion`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + step = step, + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("suggestion") + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertFalse(viewState.isSpaceButtonHidden) + } + + /* ktlint-disable */ + @Test + fun `isSpaceButtonHidden should be true when active Variable code block's first child has no selected suggestion`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + step = step, + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isSpaceButtonHidden) + } + + @Test + fun `isSpaceButtonHidden should be true when active Variable code block's second child has no selected suggestion`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + step = step, + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("x") + ), + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isSpaceButtonHidden) + } + + @Test + fun `isSpaceButtonHidden should be false when active IfStatement code block child has selected suggestion`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + step = step, + codeBlocks = listOf( + CodeBlock.IfStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = Suggestion.ConstantString("if") + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertFalse(viewState.isSpaceButtonHidden) + } + + @Test + fun `isSpaceButtonHidden should be true when active IfStatement code block child has no selected suggestion`() { + val state = StepQuizCodeBlanksFeature.State.Content.stub( + step = step, + codeBlocks = listOf( + CodeBlock.IfStatement( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = emptyList(), + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.isSpaceButtonHidden) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperSequencesTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperSequencesTest.kt new file mode 100644 index 0000000000..e7c16c1df9 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperSequencesTest.kt @@ -0,0 +1,170 @@ +package org.hyperskill.step_quiz_code_blanks.view + +import kotlin.test.Test +import kotlin.test.assertEquals +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.view.mapper.StepQuizCodeBlanksViewStateMapper +import org.hyperskill.app.step_quiz_code_blanks.view.model.StepQuizCodeBlanksViewState +import org.hyperskill.step_quiz_code_blanks.presentation.stub + +class StepQuizCodeBlanksViewStateMapperSequencesTest { + @Test + fun `map should return Idle view state for Idle state`() { + val state = StepQuizCodeBlanksFeature.State.Idle + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + assertEquals(StepQuizCodeBlanksViewState.Idle, viewState) + } + + @Test + fun `Content with active not filled Print`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + val expectedViewState = StepQuizCodeBlanksViewState.Content( + codeBlocks = listOf( + StepQuizCodeBlanksViewState.CodeBlockItem.Print( + id = 0, + children = listOf( + StepQuizCodeBlanksViewState.CodeBlockChildItem( + id = 0, + isActive = true, + value = null + ) + ) + ) + ), + suggestions = suggestions, + isDeleteButtonEnabled = true, + isSpaceButtonHidden = true, + isDecreaseIndentLevelButtonHidden = true + ) + + val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertEquals(expectedViewState, actualViewState) + } + + @Test + fun `Content with sequence of filled Print and active Blank`() { + val printSuggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = printSuggestions, + selectedSuggestion = printSuggestions[0] + ) + ) + ), + CodeBlock.Blank(isActive = true, suggestions = listOf(Suggestion.Print)) + ) + ) + val expectedViewState = StepQuizCodeBlanksViewState.Content( + codeBlocks = listOf( + StepQuizCodeBlanksViewState.CodeBlockItem.Print( + id = 0, + children = listOf( + StepQuizCodeBlanksViewState.CodeBlockChildItem( + id = 0, + isActive = false, + value = printSuggestions[0].text + ) + ) + ), + StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 1, isActive = true) + ), + suggestions = listOf(Suggestion.Print), + isDeleteButtonEnabled = true, + isSpaceButtonHidden = true, + isDecreaseIndentLevelButtonHidden = true + ) + + val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertEquals(expectedViewState, actualViewState) + } + + @Test + fun `Content with sequence of filled Print and active not filled Print`() { + val printSuggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = printSuggestions, + selectedSuggestion = printSuggestions[0] + ) + ) + ), + CodeBlock.Print( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = printSuggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + val expectedViewState = StepQuizCodeBlanksViewState.Content( + codeBlocks = listOf( + StepQuizCodeBlanksViewState.CodeBlockItem.Print( + id = 0, + children = listOf( + StepQuizCodeBlanksViewState.CodeBlockChildItem( + id = 0, + isActive = false, + value = printSuggestions[0].text + ) + ) + ), + StepQuizCodeBlanksViewState.CodeBlockItem.Print( + id = 1, + children = listOf( + StepQuizCodeBlanksViewState.CodeBlockChildItem( + id = 0, + isActive = true, + value = null + ) + ) + ) + ), + suggestions = printSuggestions, + isDeleteButtonEnabled = true, + isSpaceButtonHidden = true, + isDecreaseIndentLevelButtonHidden = true + ) + + val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertEquals(expectedViewState, actualViewState) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperSuggestionsTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperSuggestionsTest.kt new file mode 100644 index 0000000000..fbfaf6d78a --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateMapperSuggestionsTest.kt @@ -0,0 +1,114 @@ +package org.hyperskill.step_quiz_code_blanks.view + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlock +import org.hyperskill.app.step_quiz_code_blanks.domain.model.CodeBlockChild +import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature +import org.hyperskill.app.step_quiz_code_blanks.view.mapper.StepQuizCodeBlanksViewStateMapper +import org.hyperskill.app.step_quiz_code_blanks.view.model.StepQuizCodeBlanksViewState +import org.hyperskill.step_quiz_code_blanks.presentation.stub + +class StepQuizCodeBlanksViewStateMapperSuggestionsTest { + @Test + fun `Non empty suggestions when active code block is Blank`() { + val suggestions = listOf(Suggestion.Print, Suggestion.Variable) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf(CodeBlock.Blank(isActive = true, suggestions = suggestions)) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertEquals(suggestions, viewState.suggestions) + } + + @Test + fun `Empty suggestions when code block active child has selected suggestion`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = suggestions[0] + ) + ) + val codeBlocks = listOf( + CodeBlock.Print(children = children), + CodeBlock.Variable(children = children), + CodeBlock.IfStatement(children = children) + ) + + codeBlocks.forEach { codeBlock -> + val state = StepQuizCodeBlanksFeature.State.Content.stub(codeBlocks = listOf(codeBlock)) + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertTrue(viewState.suggestions.isEmpty()) + } + } + + @Test + fun `Non empty suggestions when code block active child is unselected`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + val codeBlocks = listOf( + CodeBlock.Print(children = children), + CodeBlock.Variable(children = children), + CodeBlock.IfStatement(children = children) + ) + + codeBlocks.forEach { codeBlock -> + val state = StepQuizCodeBlanksFeature.State.Content.stub(codeBlocks = listOf(codeBlock)) + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertEquals(suggestions, viewState.suggestions) + } + } + + @Test + fun `Non empty suggestions when active code block is Variable and active child has no selected suggestion`() { + val suggestions = listOf( + Suggestion.ConstantString("1"), + Suggestion.ConstantString("2") + ) + val state = StepQuizCodeBlanksFeature.State.Content.stub( + codeBlocks = listOf( + CodeBlock.Variable( + children = listOf( + CodeBlockChild.SelectSuggestion( + isActive = true, + suggestions = suggestions, + selectedSuggestion = null + ), + CodeBlockChild.SelectSuggestion( + isActive = false, + suggestions = suggestions, + selectedSuggestion = null + ) + ) + ) + ) + ) + + val viewState = StepQuizCodeBlanksViewStateMapper.map(state) + + assertTrue(viewState is StepQuizCodeBlanksViewState.Content) + assertEquals(suggestions, viewState.suggestions) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateTest.kt new file mode 100644 index 0000000000..ac692810fd --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/view/StepQuizCodeBlanksViewStateTest.kt @@ -0,0 +1,39 @@ +package org.hyperskill.step_quiz_code_blanks.view + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature.OnboardingState +import org.hyperskill.app.step_quiz_code_blanks.view.model.StepQuizCodeBlanksViewState + +class StepQuizCodeBlanksViewStateTest { + @Test + fun `isActionButtonsHidden should be true when onboarding is available`() { + val viewState = stubContentViewState(onboardingState = OnboardingState.HighlightSuggestions) + assertTrue(viewState.isActionButtonsHidden) + } + + @Test + fun `isActionButtonsHidden should be false when onboarding is unavailable`() { + val viewState = stubContentViewState(onboardingState = OnboardingState.Unavailable) + assertFalse(viewState.isActionButtonsHidden) + } + + @Test + fun `isSuggestionsHighlightEffectActive should be true when onboardingState is HighlightSuggestions`() { + val viewState = stubContentViewState(onboardingState = OnboardingState.HighlightSuggestions) + assertTrue(viewState.isSuggestionsHighlightEffectActive) + } + + private fun stubContentViewState( + onboardingState: OnboardingState + ): StepQuizCodeBlanksViewState.Content = + StepQuizCodeBlanksViewState.Content( + codeBlocks = emptyList(), + suggestions = emptyList(), + isDeleteButtonEnabled = false, + isSpaceButtonHidden = false, + isDecreaseIndentLevelButtonHidden = false, + onboardingState = onboardingState + ) +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/manager/IosPurchaseManager.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/manager/IosPurchaseManager.kt index cdd7386bdc..0523bd27ea 100644 --- a/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/manager/IosPurchaseManager.kt +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/manager/IosPurchaseManager.kt @@ -1,5 +1,6 @@ package org.hyperskill.app.purchases.domain.manager +import cocoapods.RevenueCat.RCStoreProduct import org.hyperskill.app.core.domain.model.SwiftyResult import org.hyperskill.app.purchases.domain.model.PlatformPurchaseParams import org.hyperskill.app.purchases.domain.model.PurchaseResult @@ -12,13 +13,9 @@ interface IosPurchaseManager { suspend fun login(userId: Long): SwiftyResult suspend fun purchase( - productId: String, + storeProduct: RCStoreProduct, platformPurchaseParams: PlatformPurchaseParams ): SwiftyResult suspend fun getManagementUrl(): SwiftyResult - - suspend fun getFormattedProductPrice(productId: String): String? - - suspend fun checkTrialOrIntroDiscountEligibility(productId: String): Boolean } \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/manager/IosPurchaseManagerImpl.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/manager/IosPurchaseManagerImpl.kt index 9f3af34dea..13f77f41a0 100644 --- a/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/manager/IosPurchaseManagerImpl.kt +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/manager/IosPurchaseManagerImpl.kt @@ -1,9 +1,19 @@ package org.hyperskill.app.purchases.domain.manager +import cocoapods.RevenueCat.RCOfferings +import cocoapods.RevenueCat.RCPackage +import cocoapods.RevenueCat.RCPurchases +import cocoapods.RevenueCat.localizedPricePerMonth +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine import org.hyperskill.app.core.domain.model.toKotlinResult +import org.hyperskill.app.purchases.domain.model.HyperskillStoreProduct import org.hyperskill.app.purchases.domain.model.PlatformPurchaseParams import org.hyperskill.app.purchases.domain.model.PurchaseManager import org.hyperskill.app.purchases.domain.model.PurchaseResult +import org.hyperskill.app.purchases.domain.model.SubscriptionPeriod +import org.hyperskill.app.purchases.domain.model.SubscriptionProduct internal class IosPurchaseManagerImpl( private val purchases: IosPurchaseManager @@ -24,11 +34,11 @@ internal class IosPurchaseManagerImpl( Result.success(true) override suspend fun purchase( - productId: String, + storeProduct: HyperskillStoreProduct, platformPurchaseParams: PlatformPurchaseParams ): Result = purchases - .purchase(productId, platformPurchaseParams) + .purchase(storeProduct.rcStoreProduct, platformPurchaseParams) .toKotlinResult() override suspend fun getManagementUrl(): Result = @@ -37,9 +47,45 @@ internal class IosPurchaseManagerImpl( .toKotlinResult() .map { if (it?.isEmpty() == true) null else it } - override suspend fun getFormattedProductPrice(productId: String): Result = - Result.success(purchases.getFormattedProductPrice(productId)) + override suspend fun getSubscriptionProducts(): Result> = + kotlin.runCatching { + suspendCoroutine { continuation -> + RCPurchases.sharedPurchases().getOfferingsWithCompletion { rcOfferings, nsError -> + when { + nsError != null -> { + continuation.resumeWithException(FetchOfferingsException(nsError.description)) + } + rcOfferings == null -> { + continuation.resumeWithException(FetchOfferingsException("Received rcOfferings is null")) + } + else -> { + continuation.resume(mapOfferingsToSubscriptionProducts(rcOfferings)) + } + } + } + } + } - override suspend fun checkTrialEligibility(productId: String): Boolean = - purchases.checkTrialOrIntroDiscountEligibility(productId) -} \ No newline at end of file + private fun mapOfferingsToSubscriptionProducts(rcOfferings: RCOfferings): List { + val currentOffering = rcOfferings.current() ?: return emptyList() + return currentOffering + .availablePackages() + .mapNotNull { + val rcPackage = it as? RCPackage ?: return@mapNotNull null + val rcStoreProduct = rcPackage.storeProduct() + SubscriptionProduct( + id = rcStoreProduct.productIdentifier(), + period = when (rcStoreProduct.subscriptionPeriod()?.unit()) { + cocoapods.RevenueCat.RCSubscriptionPeriodUnitMonth -> SubscriptionPeriod.MONTH + cocoapods.RevenueCat.RCSubscriptionPeriodUnitYear -> SubscriptionPeriod.YEAR + else -> return@mapNotNull null + }, + formattedPrice = rcStoreProduct.localizedPriceString(), + formattedPricePerMonth = rcStoreProduct.localizedPricePerMonth() ?: return@mapNotNull null, + storeProduct = HyperskillStoreProduct(rcStoreProduct) + ) + } + } +} + +class FetchOfferingsException(override val message: String?) : Exception() \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/model/HyperskillStoreProduct.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/model/HyperskillStoreProduct.kt new file mode 100644 index 0000000000..230cc42a80 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/model/HyperskillStoreProduct.kt @@ -0,0 +1,5 @@ +package org.hyperskill.app.purchases.domain.model + +import cocoapods.RevenueCat.RCStoreProduct + +actual class HyperskillStoreProduct(val rcStoreProduct: RCStoreProduct) \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformProductIdentifiers.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformProductIdentifiers.kt index ae6b43ed18..7e003b46fd 100644 --- a/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformProductIdentifiers.kt +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformProductIdentifiers.kt @@ -1,5 +1,5 @@ package org.hyperskill.app.purchases.domain.model internal actual object PlatformProductIdentifiers { - actual const val MOBILE_ONLY_SUBSCRIPTION: String = "org.hyperskill.App.MobileOnly.Monthly" + actual const val MOBILE_ONLY_MONTHLY_SUBSCRIPTION: String = "org.hyperskill.App.MobileOnly.Monthly" } \ No newline at end of file