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, anytime Learn to code anywhere, anytime](resources/screenshots/01.webp?raw=true)
![Master real-world skills Master real-world skills](resources/screenshots/02.webp?raw=true)
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