diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/fragment/DefaultStepQuizFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/fragment/DefaultStepQuizFragment.kt
index 52d3067f57..8a08d476df 100644
--- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/fragment/DefaultStepQuizFragment.kt
+++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/fragment/DefaultStepQuizFragment.kt
@@ -320,6 +320,9 @@ abstract class DefaultStepQuizFragment :
StepQuizFeature.Action.ViewAction.ScrollToCallToActionButton -> {
handleScrollToCallToActionButton()
}
+ is StepQuizFeature.Action.ViewAction.StepQuizCodeBlanksViewAction -> {
+ // no op
+ }
}
}
diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml
index c5ff276bac..2dc5cc7292 100644
--- a/config/detekt/baseline.xml
+++ b/config/detekt/baseline.xml
@@ -51,6 +51,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: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 )
LongMethod:DefaultStepQuizFragment.kt$DefaultStepQuizFragment$override fun onAction(action: StepQuizFeature.Action.ViewAction)
@@ -62,6 +63,7 @@
LongMethod:ProblemOfDayCardFormDelegate.kt$ProblemOfDayCardFormDelegate$fun render( dateFormatter: SharedDateFormatter, binding: LayoutProblemOfTheDayCardBinding, state: HomeFeature.ProblemOfDayState, areProblemsLimited: Boolean )
LongMethod:ProfileBadges.kt$@Composable fun ProfileBadges( viewState: BadgesViewState, windowWidthSizeClass: WindowWidthSizeClass, onBadgeClick: (BadgeKind) -> Unit, onExpandButtonClick: (ProfileFeature.Message.BadgesVisibilityButton) -> Unit, modifier: Modifier = Modifier )
LongMethod:ProfileSettingsDialogFragment.kt$ProfileSettingsDialogFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?)
+ LongMethod:StepQuizReducer.kt$StepQuizReducer$private fun handleFetchAttemptSuccess( state: State, message: InternalMessage.FetchAttemptSuccess ): StepQuizReducerResult
LongMethod:StreakFreezeDialogFragment.kt$StreakFreezeDialogFragment$override fun onViewCreated(view: View, savedInstanceState: Bundle?)
LongMethod:TrackProgressContent.kt$@Composable fun TrackProgressContent( viewState: ProgressScreenViewState.TrackProgressViewState.Content, onNewMessage: (ProgressScreenFeature.Message) -> Unit, modifier: Modifier = Modifier )
LongParameterList:AppInteractor.kt$AppInteractor$( private val appRepository: AppRepository, private val authInteractor: AuthInteractor, private val currentProfileStateRepository: CurrentProfileStateRepository, private val userStorageInteractor: UserStorageInteractor, private val analyticInteractor: AnalyticInteractor, private val progressesRepository: ProgressesRepository, private val trackRepository: TrackRepository, private val providersRepository: ProvidersRepository, private val projectsRepository: ProjectsRepository, private val shareStreakRepository: ShareStreakRepository, private val pushNotificationsInteractor: PushNotificationsInteractor )
@@ -242,6 +244,9 @@
ReturnCount:SharedDateFormatter.kt$SharedDateFormatter$fun formatTimeDistance(millis: Long): String
ReturnCount:StateExtentions.kt$internal fun ChallengeWidgetFeature.State.Content.setCurrentChallengeIntervalProgressAsCompleted(): Challenge?
ReturnCount:StepQuizActionDispatcher.kt$StepQuizActionDispatcher$private suspend fun handleUpdateProblemsLimitAction( action: InternalAction.UpdateProblemsLimit, onNewMessage: (Message) -> Unit )
+ ReturnCount:StepQuizCodeBlanksReducer.kt$StepQuizCodeBlanksReducer$private fun handleCodeBlockClicked( state: State, message: Message.CodeBlockClicked ): StepQuizCodeBlanksReducerResult?
+ ReturnCount:StepQuizCodeBlanksReducer.kt$StepQuizCodeBlanksReducer$private fun handleDeleteButtonClicked( state: State ): StepQuizCodeBlanksReducerResult?
+ ReturnCount:StepQuizCodeBlanksReducer.kt$StepQuizCodeBlanksReducer$private fun handleSuggestionClicked( state: State, message: Message.SuggestionClicked ): StepQuizCodeBlanksReducerResult?
ReturnCount:StepQuizHintsInteractor.kt$StepQuizHintsInteractor$suspend fun getLastSeenHint(stepId: Long): Comment?
ReturnCount:StepQuizHintsInteractor.kt$StepQuizHintsInteractor$suspend fun getNotSeenHintsIds(stepId: Long): List<Long>
ReturnCount:StepQuizReducer.kt$StepQuizReducer$private fun handleGenerateGptCodeWithErrorsResult( state: State, message: InternalMessage.GenerateGptCodeWithErrorsResult ): StepQuizReducerResult
diff --git a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj
index 231c1ed38c..de1dbd60e1 100644
--- a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj
+++ b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj
@@ -246,6 +246,8 @@
2C66720F2A529A7C0040EA2F /* ProgressScreenTrackProgressSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C66720E2A529A7C0040EA2F /* ProgressScreenTrackProgressSkeletonView.swift */; };
2C6770992AEBDF3A00E832AC /* StepQuizFillBlanksSelectOptionsInputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6770982AEBDF3A00E832AC /* StepQuizFillBlanksSelectOptionsInputProtocol.swift */; };
2C67709B2AEBDF5200E832AC /* StepQuizFillBlanksSelectOptionsOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C67709A2AEBDF5200E832AC /* StepQuizFillBlanksSelectOptionsOutputProtocol.swift */; };
+ 2C677D002C4A39580019AF03 /* FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C677CFF2C4A39570019AF03 /* FlowLayout.swift */; };
+ 2C677D022C4A3F860019AF03 /* StepQuizCodeBlanksSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C677D012C4A3F860019AF03 /* StepQuizCodeBlanksSuggestionsView.swift */; };
2C688C032A4E8F900061AFFD /* ProgressScreenTrackProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C688C022A4E8F900061AFFD /* ProgressScreenTrackProgressView.swift */; };
2C688C052A4E97750061AFFD /* ProgressScreenProjectProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C688C042A4E97750061AFFD /* ProgressScreenProjectProgressView.swift */; };
2C68FD7C2ABC1FF700D9EBE2 /* NotificationsOnboardingContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C68FD7B2ABC1FF700D9EBE2 /* NotificationsOnboardingContentView.swift */; };
@@ -298,6 +300,11 @@
2C83FBC22B1781FA007AD7E2 /* LeaderboardListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C83FBC12B1781FA007AD7E2 /* LeaderboardListRowView.swift */; };
2C83FBC62B178F0B007AD7E2 /* LeaderboardListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C83FBC52B178F0B007AD7E2 /* LeaderboardListItem.swift */; };
2C8409532805BF3C009C6BE9 /* StepTheoryContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8409522805BF3C009C6BE9 /* StepTheoryContentView.swift */; };
+ 2C84E7082C47BA11002EE787 /* StepQuizCodeBlanksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C84E7072C47BA11002EE787 /* StepQuizCodeBlanksViewModel.swift */; };
+ 2C84E70A2C47BA38002EE787 /* StepQuizCodeBlanksOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C84E7092C47BA38002EE787 /* StepQuizCodeBlanksOutputProtocol.swift */; };
+ 2C84E70C2C47BAB6002EE787 /* StepQuizCodeBlanksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C84E70B2C47BAB6002EE787 /* StepQuizCodeBlanksView.swift */; };
+ 2C84E70E2C47BB5B002EE787 /* StepQuizCodeBlanksViewStateKsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C84E70D2C47BB5B002EE787 /* StepQuizCodeBlanksViewStateKsExtensions.swift */; };
+ 2C84E7102C47BC10002EE787 /* StepQuizCodeBlanksAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C84E70F2C47BC10002EE787 /* StepQuizCodeBlanksAssembly.swift */; };
2C863D932804279E0021EFED /* Require.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C863D922804279E0021EFED /* Require.swift */; };
2C8CD9AE2994EFC5008DC09D /* DebugFeatureViewStateKsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8CD9AD2994EFC5008DC09D /* DebugFeatureViewStateKsExtensions.swift */; };
2C8DD4092AFB7DFD00FD5359 /* ShareStreakModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C8DD4082AFB7DFD00FD5359 /* ShareStreakModalViewController.swift */; };
@@ -452,6 +459,7 @@
2CB2BDB72BEB8488009E2D83 /* CodePlaygroundGetChangesSubstringPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB2BDB62BEB8488009E2D83 /* CodePlaygroundGetChangesSubstringPerformanceTests.swift */; };
2CB2BDB92BEB934D009E2D83 /* CodePlaygroundShouldMakeTabLineAfterPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB2BDB82BEB934D009E2D83 /* CodePlaygroundShouldMakeTabLineAfterPerformanceTests.swift */; };
2CB2BF272AE91F38000A144F /* FillBlanksModeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB2BF262AE91F38000A144F /* FillBlanksModeWrapper.swift */; };
+ 2CB3BC562C46171000F5354F /* StepQuizCodeBlanksPrintInstructionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB3BC552C46171000F5354F /* StepQuizCodeBlanksPrintInstructionView.swift */; };
2CB45762288EC29D007C2D77 /* StepQuizActionButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB45761288EC29D007C2D77 /* StepQuizActionButtons.swift */; };
2CB45764288ED6D4007C2D77 /* StepQuizActionButtonCodeQuizDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB45763288ED6D4007C2D77 /* StepQuizActionButtonCodeQuizDelegate.swift */; };
2CB64A3F2ABC47590053A998 /* NotificationsOnboardingOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB64A3E2ABC47590053A998 /* NotificationsOnboardingOutputProtocol.swift */; };
@@ -504,6 +512,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 */; };
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 */; };
@@ -1026,6 +1036,8 @@
2C66720E2A529A7C0040EA2F /* ProgressScreenTrackProgressSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressScreenTrackProgressSkeletonView.swift; sourceTree = ""; };
2C6770982AEBDF3A00E832AC /* StepQuizFillBlanksSelectOptionsInputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsInputProtocol.swift; sourceTree = ""; };
2C67709A2AEBDF5200E832AC /* StepQuizFillBlanksSelectOptionsOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsOutputProtocol.swift; sourceTree = ""; };
+ 2C677CFF2C4A39570019AF03 /* FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowLayout.swift; sourceTree = ""; };
+ 2C677D012C4A3F860019AF03 /* StepQuizCodeBlanksSuggestionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksSuggestionsView.swift; sourceTree = ""; };
2C688C022A4E8F900061AFFD /* ProgressScreenTrackProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressScreenTrackProgressView.swift; sourceTree = ""; };
2C688C042A4E97750061AFFD /* ProgressScreenProjectProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressScreenProjectProgressView.swift; sourceTree = ""; };
2C68FD7B2ABC1FF700D9EBE2 /* NotificationsOnboardingContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsOnboardingContentView.swift; sourceTree = ""; };
@@ -1078,6 +1090,11 @@
2C83FBC12B1781FA007AD7E2 /* LeaderboardListRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaderboardListRowView.swift; sourceTree = ""; };
2C83FBC52B178F0B007AD7E2 /* LeaderboardListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaderboardListItem.swift; sourceTree = ""; };
2C8409522805BF3C009C6BE9 /* StepTheoryContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepTheoryContentView.swift; sourceTree = ""; };
+ 2C84E7072C47BA11002EE787 /* StepQuizCodeBlanksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksViewModel.swift; sourceTree = ""; };
+ 2C84E7092C47BA38002EE787 /* StepQuizCodeBlanksOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksOutputProtocol.swift; sourceTree = ""; };
+ 2C84E70B2C47BAB6002EE787 /* StepQuizCodeBlanksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksView.swift; sourceTree = ""; };
+ 2C84E70D2C47BB5B002EE787 /* StepQuizCodeBlanksViewStateKsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksViewStateKsExtensions.swift; sourceTree = ""; };
+ 2C84E70F2C47BC10002EE787 /* StepQuizCodeBlanksAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksAssembly.swift; sourceTree = ""; };
2C863D922804279E0021EFED /* Require.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Require.swift; sourceTree = ""; };
2C8CD9AD2994EFC5008DC09D /* DebugFeatureViewStateKsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugFeatureViewStateKsExtensions.swift; sourceTree = ""; };
2C8DD4082AFB7DFD00FD5359 /* ShareStreakModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareStreakModalViewController.swift; sourceTree = ""; };
@@ -1233,6 +1250,7 @@
2CB2BDB62BEB8488009E2D83 /* CodePlaygroundGetChangesSubstringPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodePlaygroundGetChangesSubstringPerformanceTests.swift; sourceTree = ""; };
2CB2BDB82BEB934D009E2D83 /* CodePlaygroundShouldMakeTabLineAfterPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodePlaygroundShouldMakeTabLineAfterPerformanceTests.swift; sourceTree = ""; };
2CB2BF262AE91F38000A144F /* FillBlanksModeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksModeWrapper.swift; sourceTree = ""; };
+ 2CB3BC552C46171000F5354F /* StepQuizCodeBlanksPrintInstructionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeBlanksPrintInstructionView.swift; sourceTree = ""; };
2CB45761288EC29D007C2D77 /* StepQuizActionButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizActionButtons.swift; sourceTree = ""; };
2CB45763288ED6D4007C2D77 /* StepQuizActionButtonCodeQuizDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizActionButtonCodeQuizDelegate.swift; sourceTree = ""; };
2CB64A3E2ABC47590053A998 /* NotificationsOnboardingOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsOnboardingOutputProtocol.swift; sourceTree = ""; };
@@ -1285,6 +1303,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 = ""; };
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 = ""; };
@@ -1804,6 +1824,7 @@
2C05AC452A0E9EBC0039C7EF /* ProjectSelectionListFeatureViewStateKsExtensions.swift */,
2C306A0D29B4590C0068FF4F /* StageImplementFeatureViewStateKsExtensions.swift */,
2CFD7C67292542FB00902748 /* StepFeatureStateKsExtensions.swift */,
+ 2C84E70D2C47BB5B002EE787 /* StepQuizCodeBlanksViewStateKsExtensions.swift */,
2CFD7C692925447600902748 /* StepQuizFeatureStateKsExtensions.swift */,
2CB9537D2AF2474100CA64BA /* StepQuizHintsFeatureStateKsExtensions.swift */,
E9AD65A9292DC0BE00E574F0 /* TopicsRepetitionsFeatureStateKsExtensions.swift */,
@@ -2637,6 +2658,14 @@
path = InputOutput;
sourceTree = "";
};
+ 2C677CF92C4A2CA50019AF03 /* Layouts */ = {
+ isa = PBXGroup;
+ children = (
+ 2C677CFF2C4A39570019AF03 /* FlowLayout.swift */,
+ );
+ path = Layouts;
+ sourceTree = "";
+ };
2C68FD7D2ABC351D00D9EBE2 /* Views */ = {
isa = PBXGroup;
children = (
@@ -3517,6 +3546,7 @@
2C3E656E2A127E6100BC8DC0 /* Gradients */,
2CBC97CE2A555BD00078E445 /* Hypercoins */,
2CF87DA029B717E20092FF83 /* Introspect */,
+ 2C677CF92C4A2CA50019AF03 /* Layouts */,
2C82BA302844AFED004C9013 /* PlaceholderView */,
2C60F1B428880AA600B66C78 /* Skeletons */,
2CA8E08E281039EB00154088 /* Styles */,
@@ -3670,6 +3700,29 @@
path = ViewData;
sourceTree = "";
};
+ 2CD67C9C2C451AEE00240C17 /* StepQuizCodeBlanks */ = {
+ isa = PBXGroup;
+ children = (
+ 2C84E70F2C47BC10002EE787 /* StepQuizCodeBlanksAssembly.swift */,
+ 2C84E7092C47BA38002EE787 /* StepQuizCodeBlanksOutputProtocol.swift */,
+ 2C84E7072C47BA11002EE787 /* StepQuizCodeBlanksViewModel.swift */,
+ 2CD67C9D2C451B0200240C17 /* Views */,
+ );
+ path = StepQuizCodeBlanks;
+ sourceTree = "";
+ };
+ 2CD67C9D2C451B0200240C17 /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ 2CD67CA02C452B2400240C17 /* StepQuizCodeBlanksBlankView.swift */,
+ 2CD67CA22C452DED00240C17 /* StepQuizCodeBlanksOptionView.swift */,
+ 2CB3BC552C46171000F5354F /* StepQuizCodeBlanksPrintInstructionView.swift */,
+ 2C677D012C4A3F860019AF03 /* StepQuizCodeBlanksSuggestionsView.swift */,
+ 2C84E70B2C47BAB6002EE787 /* StepQuizCodeBlanksView.swift */,
+ );
+ path = Views;
+ sourceTree = "";
+ };
2CD7C2D12BFDDC3400DFD5BE /* Views */ = {
isa = PBXGroup;
children = (
@@ -3800,6 +3853,7 @@
children = (
E9802D03281BB5A500CF3AC1 /* StepQuizChoice */,
2C96742C288823EB0091B6C9 /* StepQuizCode */,
+ 2CD67C9C2C451AEE00240C17 /* StepQuizCodeBlanks */,
2CBFB94828897D970044D1BA /* StepQuizCodeFullScreen */,
2C7CB6692ADFB91C006F78DA /* StepQuizFillBlanks */,
2C198DFF2AEA831C00DCD35A /* StepQuizFillBlanksSelectOptions */,
@@ -5020,6 +5074,7 @@
2C7A1B1F2922EB070018D72C /* Hyperskill-Mobile_shared.swift in Sources */,
2C45E7C12A0FE9E100DFF32D /* ProjectSelectionListGridCellProjectGraduateView.swift in Sources */,
2C0146AA28FDF2350083DA9C /* StepQuizCodeFullScreenInputProtocol.swift in Sources */,
+ 2C84E70C2C47BAB6002EE787 /* StepQuizCodeBlanksView.swift in Sources */,
2C95BAD92A4AED4300371C17 /* BackgroundProgressView.swift in Sources */,
E9523BF229DA966C0013A661 /* StudyPlanAssembly.swift in Sources */,
2C52A34A2BD0D73800A76AB3 /* StepToolbarProgressView.swift in Sources */,
@@ -5091,6 +5146,7 @@
E9A022AE291D0E3F004317DB /* TopicsRepetitionsCardView.swift in Sources */,
2C5CBBE32948F4B600113007 /* StepQuizSQLViewModel.swift in Sources */,
E9F923F628A2633D00C065A7 /* WelcomeView.swift in Sources */,
+ 2CD67CA32C452DED00240C17 /* StepQuizCodeBlanksOptionView.swift in Sources */,
E9BDB4052A7BE1E30069EF98 /* BadgeImageView.swift in Sources */,
2C7FE8A52B98261600F09615 /* PurchaseManager.swift in Sources */,
2C106D9928C1CE6E004FA584 /* SendEmailFeedbackController.swift in Sources */,
@@ -5129,6 +5185,7 @@
2C20B28A286C350C000F458A /* CodeEditor.swift in Sources */,
2C919DE827EEEDD60022A2F2 /* LinkedList.swift in Sources */,
2C0DB912286455D8001EA35E /* CodePlaygroundManager.swift in Sources */,
+ 2C84E70E2C47BB5B002EE787 /* StepQuizCodeBlanksViewStateKsExtensions.swift in Sources */,
2C225E48281A5B6800E8ABD2 /* SourcelessRouter.swift in Sources */,
E9C5C93028AE105200CADDEC /* NotificationsService.swift in Sources */,
2C05AC4D2A0EA0460039C7EF /* ProjectSelectionListView.swift in Sources */,
@@ -5139,6 +5196,7 @@
2C08A939287866A200F1DCA2 /* LazyAvatarView.swift in Sources */,
E9A1DA6A2ACFF31D006A9D4B /* FirstProblemOnboardingOutputProtocol.swift in Sources */,
2CAFD38F27FC517D00F88B0B /* ColorPalette.swift in Sources */,
+ 2C84E7102C47BC10002EE787 /* StepQuizCodeBlanksAssembly.swift in Sources */,
2C05AC642A0EDFD80039C7EF /* ProjectSelectionListGridCellBadgesView.swift in Sources */,
2C93AF2329B34F66004639E0 /* StepQuizPyCharmView.swift in Sources */,
2C20FBAA284F1951006D879E /* ContentProcessor.swift in Sources */,
@@ -5195,9 +5253,11 @@
2CE31F4D27F1E0C8008EEE66 /* AppAssembly.swift in Sources */,
2CCF3B5A280050890075D12C /* DeviceInfo.swift in Sources */,
E9D90905289814AA00D0EE91 /* NotificationsRegistrationService.swift in Sources */,
+ 2C677D002C4A39580019AF03 /* FlowLayout.swift in Sources */,
2CB9537E2AF2474100CA64BA /* StepQuizHintsFeatureStateKsExtensions.swift in Sources */,
2C963BCA2812D3550036DD53 /* ProfileSettingsView.swift in Sources */,
2C772E7D28ABB4E500A58758 /* AppleIDSocialAuthSDKProvider.swift in Sources */,
+ 2CD67CA12C452B2400240C17 /* StepQuizCodeBlanksBlankView.swift in Sources */,
2C963BCC2812D9330036DD53 /* ProfileSettingsAssembly.swift in Sources */,
E9470C6B29810AB7008ACF9A /* StepQuizOutputProtocol.swift in Sources */,
2C079687285CFFF500EE0487 /* StepQuizSortingAssembly.swift in Sources */,
@@ -5206,6 +5266,7 @@
2CD4148729A8D92000ACA855 /* CodeInputPasteControl.swift in Sources */,
2C8E4FA12848BF1F0011ADFA /* StepQuizTableSelectColumnsViewController.swift in Sources */,
2C078CE72AE26E2000D97E24 /* UIKitSeparatorView.swift in Sources */,
+ 2C677D022C4A3F860019AF03 /* StepQuizCodeBlanksSuggestionsView.swift in Sources */,
2C1F5877280D2B4800372A37 /* ApplicationInfo.swift in Sources */,
2C5CA23A2A201A3900DBF2F9 /* ProjectSelectionDetailsLearningOutcomesView.swift in Sources */,
2C20FBBE284F658E006D879E /* ProcessedContentTextView.swift in Sources */,
@@ -5252,6 +5313,7 @@
2C96743728882A0C0091B6C9 /* StepQuizCodeDetailsView.swift in Sources */,
2C20FBC7284F6928006D879E /* ProgrammaticallyInitializableViewProtocol.swift in Sources */,
2C05AC4A2A0EA0290039C7EF /* ProjectSelectionListViewModel.swift in Sources */,
+ 2C84E70A2C47BA38002EE787 /* StepQuizCodeBlanksOutputProtocol.swift in Sources */,
E9B09FEC29F155A500D4B1FA /* ProgressBarButtonItem.swift in Sources */,
2CE3F5BC2BD7B079000B51A4 /* ProblemsLimitInfoModalView.swift in Sources */,
E951757927E61ACD00995008 /* AuthSocialView.swift in Sources */,
@@ -5380,6 +5442,7 @@
2C32374D2837F7190062CAF6 /* Images.swift in Sources */,
2C23C00A2879EB1E0083709F /* StreakViewBuilder.swift in Sources */,
2C1F588B280D8C8600372A37 /* GoogleSocialAuthSDKProvider.swift in Sources */,
+ 2C84E7082C47BA11002EE787 /* StepQuizCodeBlanksViewModel.swift in Sources */,
E99B21832887E996006A6154 /* StepQuizChoiceSkeletonView.swift in Sources */,
2C9EB95D2861BABC007DDE44 /* ProfileView.swift in Sources */,
2CF41A8E28505D2C000736D6 /* LatexView.swift in Sources */,
@@ -5502,6 +5565,7 @@
2CB0ADEC2B04AD550089D557 /* ChallengeWidgetView.swift in Sources */,
2C5837A12B28413C0096B89B /* SearchPlaceholderEmptyView.swift in Sources */,
AE0B2D1D267B8904498FA371 /* ProjectSelectionDetailsViewModel.swift in Sources */,
+ 2CB3BC562C46171000F5354F /* StepQuizCodeBlanksPrintInstructionView.swift in Sources */,
2C8DD4092AFB7DFD00FD5359 /* ShareStreakModalViewController.swift in Sources */,
0809817CFCC9D4C45457B3C8 /* ProgressScreenAssembly.swift in Sources */,
59B66CD4D1508049555D35AE /* ProgressScreenView.swift in Sources */,
diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/BlockOptionsExtensions.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/BlockOptionsExtensions.swift
index 907a78a3a3..d2b2cde49c 100644
--- a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/BlockOptionsExtensions.swift
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/BlockOptionsExtensions.swift
@@ -10,7 +10,8 @@ extension Block.Options {
limits: [String: Limit]? = nil,
codeTemplates: [String: String]? = nil,
samples: [[String]]? = nil,
- files: [Block.OptionsFile]? = nil
+ files: [Block.OptionsFile]? = nil,
+ codeBlanksStrings: [String]? = nil
) {
let isMultipleChoice: KotlinBoolean? = {
if let isMultipleChoice {
@@ -33,7 +34,8 @@ extension Block.Options {
limits: limits,
codeTemplates: codeTemplates,
samples: samples,
- files: files
+ files: files,
+ codeBlanksStrings: codeBlanksStrings
)
}
// swiftlint:enable discouraged_optional_boolean
diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/StepQuizCodeBlanksViewStateKsExtensions.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/StepQuizCodeBlanksViewStateKsExtensions.swift
new file mode 100644
index 0000000000..e5322d6d5d
--- /dev/null
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/StepQuizCodeBlanksViewStateKsExtensions.swift
@@ -0,0 +1,17 @@
+import Foundation
+import shared
+
+extension StepQuizCodeBlanksViewStateKs: Equatable {
+ public static func == (lhs: StepQuizCodeBlanksViewStateKs, rhs: StepQuizCodeBlanksViewStateKs) -> Bool {
+ switch (lhs, rhs) {
+ case (.idle, .idle):
+ true
+ case (.content(let lhsContent), .content(let rhsContent)):
+ lhsContent.isEqual(rhsContent)
+ case (.content, .idle):
+ false
+ case (.idle, .content):
+ false
+ }
+ }
+}
diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift
index 08bcb8ce7e..681250246d 100644
--- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift
@@ -83,8 +83,15 @@ final class StepQuizViewModel: FeatureViewModel<
}
func doMainQuizAction() {
- guard let reply = childQuizModuleInput?.createReply() else {
- return
+ let reply =
+ if let codeBlanksContentState = state.stepQuizCodeBlanksState as? StepQuizCodeBlanksFeatureStateContent {
+ codeBlanksContentState.createReply()
+ } else {
+ childQuizModuleInput?.createReply()
+ }
+
+ guard let reply else {
+ return assertionFailure("StepQuizViewModel: reply is nil")
}
onNewMessage(StepQuizFeatureMessageCreateSubmissionClicked(step: step, reply: reply))
@@ -194,6 +201,34 @@ extension StepQuizViewModel: StepQuizInputProtocol {
}
}
+// MARK: - StepQuizViewModel: StepQuizCodeBlanksOutputProtocol -
+
+extension StepQuizViewModel: StepQuizCodeBlanksOutputProtocol {
+ func handleStepQuizCodeBlanksDidTapOnSuggestion(_ suggestion: Suggestion) {
+ onNewMessage(
+ StepQuizFeatureMessageStepQuizCodeBlanksMessage(
+ message: StepQuizCodeBlanksFeatureMessageSuggestionClicked(suggestion: suggestion)
+ )
+ )
+ }
+
+ func handleStepQuizCodeBlanksDidTapOnCodeBlock(_ codeBlock: StepQuizCodeBlanksViewStateCodeBlockItem) {
+ onNewMessage(
+ StepQuizFeatureMessageStepQuizCodeBlanksMessage(
+ message: StepQuizCodeBlanksFeatureMessageCodeBlockClicked(codeBlockItem: codeBlock)
+ )
+ )
+ }
+
+ func handleStepQuizCodeBlanksDidTapDelete() {
+ onNewMessage(
+ StepQuizFeatureMessageStepQuizCodeBlanksMessage(
+ message: StepQuizCodeBlanksFeatureMessageDeleteButtonClicked()
+ )
+ )
+ }
+}
+
// MARK: - StepQuizViewModel: StepQuizProblemOnboardingModalViewControllerDelegate -
extension StepQuizViewModel: StepQuizProblemOnboardingModalViewControllerDelegate {
diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizView.swift
index 6b631121d9..a73c426900 100644
--- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizView.swift
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizView.swift
@@ -156,7 +156,16 @@ struct StepQuizView: View {
step: Step,
attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded
) -> some View {
- if let dataset = attemptLoadedState.attempt.dataset {
+ let stepQuizCodeBlanksState = viewModel.state.stepQuizCodeBlanksState
+ if stepQuizCodeBlanksState is StepQuizCodeBlanksFeatureStateContent {
+ StepQuizCodeBlanksAssembly(
+ state: stepQuizCodeBlanksState,
+ moduleOutput: viewModel
+ )
+ .makeModule()
+ .equatable()
+ .disabled(!StepQuizResolver.shared.isQuizEnabled(state: attemptLoadedState))
+ } else if let dataset = attemptLoadedState.attempt.dataset {
let reply = StepQuizStateExtensionsKt.reply(attemptLoadedState.submissionState)
StepQuizChildQuizViewFactory.make(
@@ -299,6 +308,10 @@ private extension StepQuizView {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) {
self.scrollToCallToActionButtonTrigger.toggle()
}
+ case .stepQuizCodeBlanksViewAction(let stepQuizCodeBlanksViewAction):
+ assertionFailure(
+ "StepQuizView :: did receive unexpected StepQuizCodeBlanksViewAction: \(stepQuizCodeBlanksViewAction)"
+ )
}
}
diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksAssembly.swift
new file mode 100644
index 0000000000..afda5dcdac
--- /dev/null
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksAssembly.swift
@@ -0,0 +1,25 @@
+import shared
+import SwiftUI
+
+final class StepQuizCodeBlanksAssembly: Assembly {
+ weak var moduleOutput: StepQuizCodeBlanksOutputProtocol?
+
+ private let state: StepQuizCodeBlanksFeatureState
+
+ init(state: StepQuizCodeBlanksFeatureState, moduleOutput: StepQuizCodeBlanksOutputProtocol?) {
+ self.state = state
+ self.moduleOutput = moduleOutput
+ }
+
+ func makeModule() -> StepQuizCodeBlanksView {
+ let viewModel = StepQuizCodeBlanksViewModel()
+ viewModel.moduleOutput = moduleOutput
+
+ let viewState = StepQuizCodeBlanksViewStateMapper.shared.map(state: state)
+
+ return StepQuizCodeBlanksView(
+ viewStateKs: .init(viewState),
+ viewModel: viewModel
+ )
+ }
+}
diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksOutputProtocol.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksOutputProtocol.swift
new file mode 100644
index 0000000000..8806fb86f5
--- /dev/null
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksOutputProtocol.swift
@@ -0,0 +1,8 @@
+import Foundation
+import shared
+
+protocol StepQuizCodeBlanksOutputProtocol: AnyObject {
+ func handleStepQuizCodeBlanksDidTapOnSuggestion(_ suggestion: Suggestion)
+ func handleStepQuizCodeBlanksDidTapOnCodeBlock(_ codeBlock: StepQuizCodeBlanksViewStateCodeBlockItem)
+ func handleStepQuizCodeBlanksDidTapDelete()
+}
diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksViewModel.swift
new file mode 100644
index 0000000000..03b84a8e05
--- /dev/null
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksViewModel.swift
@@ -0,0 +1,27 @@
+import Foundation
+import shared
+
+final class StepQuizCodeBlanksViewModel {
+ private let selectionFeedbackGenerator = FeedbackGenerator(feedbackType: .selection)
+ private let impactFeedbackGenerator = FeedbackGenerator(feedbackType: .impact(.soft))
+
+ weak var moduleOutput: StepQuizCodeBlanksOutputProtocol?
+
+ @MainActor
+ func doSuggestionMainAction(_ suggestion: Suggestion) {
+ selectionFeedbackGenerator.triggerFeedback()
+ moduleOutput?.handleStepQuizCodeBlanksDidTapOnSuggestion(suggestion)
+ }
+
+ @MainActor
+ func doCodeBlockMainAction(_ codeBlock: StepQuizCodeBlanksViewStateCodeBlockItem) {
+ selectionFeedbackGenerator.triggerFeedback()
+ moduleOutput?.handleStepQuizCodeBlanksDidTapOnCodeBlock(codeBlock)
+ }
+
+ @MainActor
+ func doDeleteMainAction() {
+ impactFeedbackGenerator.triggerFeedback()
+ moduleOutput?.handleStepQuizCodeBlanksDidTapDelete()
+ }
+}
diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksBlankView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksBlankView.swift
new file mode 100644
index 0000000000..ce5b84c107
--- /dev/null
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksBlankView.swift
@@ -0,0 +1,48 @@
+import SwiftUI
+
+struct StepQuizCodeBlanksBlankView: View {
+ var width: CGFloat = 208
+ var height: CGFloat = 48
+
+ let isActive: Bool
+
+ var body: some View {
+ Color(ColorPalette.onSurfaceAlpha9)
+ .frame(width: width, height: height)
+ .addBorder(
+ color: isActive ? StepQuizCodeBlanksAppearance.activeBorderColor : .clear,
+ width: isActive ? 1 : 0
+ )
+ .animation(.default, value: isActive)
+ }
+}
+
+extension StepQuizCodeBlanksBlankView {
+ init(style: Style, isActive: Bool) {
+ let size = style.size
+ self.init(width: size.width, height: size.height, isActive: isActive)
+ }
+
+ enum Style {
+ case small
+ case large
+
+ fileprivate var size: CGSize {
+ switch self {
+ case .small:
+ CGSize(width: 100, height: 32)
+ case .large:
+ CGSize(width: 208, height: 48)
+ }
+ }
+ }
+}
+
+#if DEBUG
+#Preview {
+ VStack {
+ StepQuizCodeBlanksBlankView(style: .small, isActive: true)
+ StepQuizCodeBlanksBlankView(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/StepQuizCodeBlanksOptionView.swift
new file mode 100644
index 0000000000..ae024cad31
--- /dev/null
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksOptionView.swift
@@ -0,0 +1,37 @@
+import SwiftUI
+
+extension StepQuizCodeBlanksOptionView {
+ enum Appearance {
+ static let insets = LayoutInsets(horizontal: 12, vertical: LayoutInsets.smallInset)
+ static let minWidth: CGFloat = 48
+ static let minHeight: CGFloat = 40
+ }
+}
+
+struct StepQuizCodeBlanksOptionView: View {
+ let text: String
+
+ let isActive: Bool
+
+ var body: some View {
+ Text(text)
+ .font(StepQuizCodeBlanksAppearance.blankFont)
+ .foregroundColor(StepQuizCodeBlanksAppearance.blankTextColor)
+ .padding(Appearance.insets.edgeInsets)
+ .frame(minWidth: Appearance.minWidth, minHeight: Appearance.minHeight)
+ .background(Color(ColorPalette.background))
+ .addBorder(
+ color: isActive ? StepQuizCodeBlanksAppearance.activeBorderColor : .border
+ )
+ }
+}
+
+#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)
+ }
+}
+#endif
diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksPrintInstructionView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksPrintInstructionView.swift
new file mode 100644
index 0000000000..b045e142da
--- /dev/null
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksPrintInstructionView.swift
@@ -0,0 +1,48 @@
+import SwiftUI
+
+struct StepQuizCodeBlanksPrintInstructionView: View {
+ let isActive: Bool
+
+ let output: String?
+
+ var body: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(alignment: .center, spacing: LayoutInsets.smallInset) {
+ Text("print(")
+ .font(StepQuizCodeBlanksAppearance.blankFont)
+ .foregroundColor(StepQuizCodeBlanksAppearance.blankTextColor)
+
+ if let output, !output.isEmpty {
+ StepQuizCodeBlanksOptionView(text: output, isActive: isActive)
+ } else {
+ StepQuizCodeBlanksBlankView(style: .small, isActive: isActive)
+ }
+
+ Text(")")
+ .font(StepQuizCodeBlanksAppearance.blankFont)
+ .foregroundColor(StepQuizCodeBlanksAppearance.blankTextColor)
+ }
+ .padding(.horizontal, LayoutInsets.defaultInset)
+ .padding(.vertical, LayoutInsets.smallInset)
+ .background(Color(ColorPalette.violet400Alpha7))
+ .cornerRadius(8)
+ .animation(.default, value: isActive)
+ .animation(.default, value: output)
+ .padding(.horizontal)
+ }
+ .scrollBounceBehaviorBasedOnSize(axes: .horizontal)
+ }
+}
+
+#if DEBUG
+#Preview {
+ VStack {
+ StepQuizCodeBlanksPrintInstructionView(isActive: false, output: "")
+ StepQuizCodeBlanksPrintInstructionView(isActive: true, output: "")
+ StepQuizCodeBlanksPrintInstructionView(isActive: true, output: "There is a cat on the keyboard, it is true")
+ StepQuizCodeBlanksPrintInstructionView(isActive: false, output: "There is a cat on the keyboard, it is true")
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+}
+#endif
diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksSuggestionsView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksSuggestionsView.swift
new file mode 100644
index 0000000000..feed5acbc2
--- /dev/null
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksSuggestionsView.swift
@@ -0,0 +1,36 @@
+import shared
+import SwiftUI
+
+struct StepQuizCodeBlanksSuggestionsView: View {
+ let suggestions: [Suggestion]
+
+ let onSuggestionTap: (Suggestion) -> Void
+
+ var body: some View {
+ let views = ForEach(suggestions, id: \.self) { suggestion in
+ Button(
+ action: {
+ onSuggestionTap(suggestion)
+ },
+ label: {
+ StepQuizCodeBlanksOptionView(text: suggestion.text, isActive: true)
+ }
+ )
+ .buttonStyle(BounceButtonStyle())
+ }
+
+ if #available(iOS 16.0, *) {
+ FlowLayout(spacing: LayoutInsets.defaultInset) {
+ views
+ }
+ .padding(LayoutInsets.defaultInset)
+ .frame(minHeight: 72)
+ } else {
+ VStack(alignment: .leading, spacing: LayoutInsets.defaultInset) {
+ views
+ }
+ .padding(LayoutInsets.defaultInset)
+ .frame(minHeight: 72)
+ }
+ }
+}
diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksView.swift
new file mode 100644
index 0000000000..97dc3455fb
--- /dev/null
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksView.swift
@@ -0,0 +1,183 @@
+import shared
+import SwiftUI
+
+enum StepQuizCodeBlanksAppearance {
+ static let activeBorderColor = Color(ColorPalette.primary)
+
+ static let blankTextColor = Color.primaryText
+ static let blankFont = Font(CodeEditorThemeService().theme.font)
+}
+
+struct StepQuizCodeBlanksView: View {
+ let viewStateKs: StepQuizCodeBlanksViewStateKs
+
+ let viewModel: StepQuizCodeBlanksViewModel
+
+ @Environment(\.isEnabled) private var isEnabled
+
+ var body: some View {
+ if case .content(let contentState) = viewStateKs {
+ VStack(alignment: .leading, spacing: 0) {
+ Divider()
+ titleView
+ Divider()
+
+ codeBlocksView(
+ codeBlocks: contentState.codeBlocks,
+ isDeleteButtonVisible: contentState.isDeleteButtonVisible
+ )
+ Divider()
+
+ StepQuizCodeBlanksSuggestionsView(
+ suggestions: contentState.suggestions,
+ onSuggestionTap: viewModel.doSuggestionMainAction(_:)
+ )
+ Divider()
+ }
+ .padding(.horizontal, -LayoutInsets.defaultInset)
+ .conditionalOpacity(isEnabled: isEnabled)
+ }
+ }
+
+ private var titleView: some View {
+ Text(Strings.StepQuizCode.title)
+ .font(.headline)
+ .foregroundColor(.primaryText)
+ .padding(.horizontal)
+ .padding(.vertical, LayoutInsets.smallInset)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(BackgroundView())
+ }
+
+ @MainActor
+ private func codeBlocksView(
+ codeBlocks: [StepQuizCodeBlanksViewStateCodeBlockItem],
+ isDeleteButtonVisible: 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(
+ isActive: printItem.isActive,
+ output: printItem.output
+ )
+ .onTapGesture {
+ viewModel.doCodeBlockMainAction(codeBlock)
+ }
+ }
+ }
+
+ HStack {
+ Spacer()
+ Button(
+ action: viewModel.doDeleteMainAction,
+ label: {
+ Image(systemName: "delete.left")
+ .imageScale(.large)
+ .padding(.vertical, LayoutInsets.smallInset / 2)
+ .padding(.horizontal, LayoutInsets.smallInset)
+ .background(Color(ColorPalette.primary))
+ .foregroundColor(Color(ColorPalette.onPrimary))
+ .cornerRadius(8)
+ }
+ )
+ .buttonStyle(BounceButtonStyle())
+ .disabled(!isDeleteButtonVisible)
+ }
+ .padding(.horizontal)
+ .conditionalOpacity(isEnabled: isDeleteButtonVisible, opacityDisabled: 0)
+ }
+ .padding(.vertical, LayoutInsets.defaultInset)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(BackgroundView())
+ }
+}
+
+extension StepQuizCodeBlanksView: Equatable {
+ static func == (lhs: StepQuizCodeBlanksView, rhs: StepQuizCodeBlanksView) -> Bool {
+ lhs.viewStateKs == rhs.viewStateKs
+ }
+}
+
+#if DEBUG
+#Preview("Blank") {
+ VStack {
+ StepQuizCodeBlanksView(
+ viewStateKs: .content(
+ StepQuizCodeBlanksViewStateContent(
+ codeBlocks: [StepQuizCodeBlanksViewStateCodeBlockItemBlank(id: 0, isActive: true)],
+ suggestions: [Suggestion.Print()],
+ isDeleteButtonVisible: true
+ )
+ ),
+ viewModel: StepQuizCodeBlanksViewModel()
+ )
+
+ Spacer()
+ }
+ .padding()
+}
+
+#Preview("Not filled Print") {
+ VStack {
+ StepQuizCodeBlanksView(
+ viewStateKs: .content(
+ StepQuizCodeBlanksViewStateContent(
+ codeBlocks: [StepQuizCodeBlanksViewStateCodeBlockItemPrint(id: 0, isActive: true, output: nil)],
+ suggestions: [
+ Suggestion.ConstantString(text: "There is a cat on the keyboard, it is true"),
+ Suggestion.ConstantString(text: "Typing messages out of the blue")
+ ],
+ isDeleteButtonVisible: false
+ )
+ ),
+ viewModel: StepQuizCodeBlanksViewModel()
+ )
+
+ Spacer()
+ }
+ .padding()
+}
+
+#Preview("Filled Print and not filled one") {
+ VStack {
+ StepQuizCodeBlanksView(
+ viewStateKs: .content(
+ StepQuizCodeBlanksViewStateContent(
+ codeBlocks: [
+ StepQuizCodeBlanksViewStateCodeBlockItemPrint(
+ id: 0,
+ isActive: false,
+ output: "There is a cat on the keyboard, it is true"
+ ),
+ StepQuizCodeBlanksViewStateCodeBlockItemPrint(
+ id: 1,
+ isActive: true,
+ output: nil
+ )
+ ],
+ suggestions: [
+ Suggestion.ConstantString(text: "There is a cat on the keyboard, it is true"),
+ Suggestion.ConstantString(text: "Typing messages out of the blue")
+ ],
+ isDeleteButtonVisible: false
+ )
+ ),
+ viewModel: StepQuizCodeBlanksViewModel()
+ )
+
+ Spacer()
+ }
+ .padding()
+}
+#endif
diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/Layouts/FlowLayout.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/Layouts/FlowLayout.swift
new file mode 100644
index 0000000000..90e130fd60
--- /dev/null
+++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/Layouts/FlowLayout.swift
@@ -0,0 +1,74 @@
+import SwiftUI
+
+@available(iOS 16.0, *)
+struct FlowLayout: Layout {
+ var spacing: CGFloat = LayoutInsets.smallInset
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let containerWidth = proposal.width ?? .infinity
+ let sizes = subviews.map { $0.sizeThatFits(.init(width: proposal.width, height: proposal.height)) }
+ return layout(sizes: sizes, containerWidth: containerWidth).size
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ let sizes = subviews.map { $0.sizeThatFits(.init(width: proposal.width, height: proposal.height)) }
+ let offsets = layout(sizes: sizes, containerWidth: bounds.width).offsets
+ for (offset, subview) in zip(offsets, subviews) {
+ subview.place(
+ at: .init(x: offset.x + bounds.minX, y: offset.y + bounds.minY),
+ proposal: .init(width: proposal.width, height: proposal.height)
+ )
+ }
+ }
+
+ private func layout(
+ sizes: [CGSize],
+ containerWidth: CGFloat
+ ) -> (offsets: [CGPoint], size: CGSize) {
+ var result: [CGPoint] = []
+
+ var currentPosition: CGPoint = .zero
+
+ var lineHeight: CGFloat = 0
+
+ var maxX: CGFloat = 0
+ for size in sizes {
+ if currentPosition.x + size.width > containerWidth {
+ currentPosition.x = 0
+ currentPosition.y += lineHeight + spacing
+ lineHeight = 0
+ }
+ result.append(currentPosition)
+ currentPosition.x += size.width
+
+ maxX = max(maxX, currentPosition.x)
+ currentPosition.x += spacing
+ lineHeight = max(lineHeight, size.height)
+ }
+
+ return (result, .init(width: maxX, height: currentPosition.y + lineHeight))
+ }
+}
+
+@available(iOS 16.0, *)
+#Preview {
+ FlowLayout {
+ ForEach(
+ [
+ "print",
+ "test",
+ "There is a cat on the keyboard, it is true or false",
+ "Typing messages out of the blue",
+ "print"
+ ],
+ id: \.self
+ ) { suggestion in
+ Text(suggestion)
+ .padding()
+ .background(Color.yellow)
+ .cornerRadius(8)
+ }
+ }
+ .background(Color.green)
+ .padding()
+}
diff --git a/shared/src/androidUnitTest/kotlin/org/hyperskill/step_quiz/AndroidStepQuizTest.kt b/shared/src/androidUnitTest/kotlin/org/hyperskill/step_quiz/AndroidStepQuizTest.kt
index 5312c2eea6..f58f71d273 100644
--- a/shared/src/androidUnitTest/kotlin/org/hyperskill/step_quiz/AndroidStepQuizTest.kt
+++ b/shared/src/androidUnitTest/kotlin/org/hyperskill/step_quiz/AndroidStepQuizTest.kt
@@ -8,6 +8,8 @@ import org.hyperskill.app.step_quiz.domain.model.attempts.Attempt
import org.hyperskill.app.step_quiz.presentation.StepQuizChildFeatureReducer
import org.hyperskill.app.step_quiz.presentation.StepQuizFeature
import org.hyperskill.app.step_quiz.presentation.StepQuizReducer
+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_hints.presentation.StepQuizHintsFeature
import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsReducer
import org.hyperskill.app.step_quiz_toolbar.presentation.StepQuizToolbarFeature
@@ -67,14 +69,16 @@ class AndroidStepQuizTest {
}
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
stepRoute = stepRoute,
stepQuizChildFeatureReducer = StepQuizChildFeatureReducer(
stepQuizHintsReducer = StepQuizHintsReducer(stepRoute),
- stepQuizToolbarReducer = StepQuizToolbarReducer(stepRoute)
+ stepQuizToolbarReducer = StepQuizToolbarReducer(stepRoute),
+ stepQuizCodeBlanksReducer = StepQuizCodeBlanksReducer(stepRoute)
),
)
@@ -82,7 +86,8 @@ class AndroidStepQuizTest {
StepQuizFeature.State(
stepQuizState = StepQuizFeature.StepQuizState.Loading,
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
),
StepQuizFeature.InternalMessage.FetchAttemptSuccess(
step,
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt
index fdb43fa174..0571e35513 100644
--- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt
@@ -22,10 +22,7 @@ enum class HyperskillAnalyticPart(val partName: String) {
DAILY_STEP_COMPLETED_MODAL("daily_step_completed_modal"),
TOPIC_COMPLETED_MODAL("topic_completed_modal"),
PROBLEMS_LIMIT_REACHED_MODAL("problems_limit_reached_modal"),
- PROBLEMS_LIMIT_WIDGET("problems_limit_widget"),
PARSONS_PROBLEM_ONBOARDING_MODAL("parsons_problem_onboarding_modal"),
- FILL_BLANKS_INPUT_MODE_ONBOARDING_MODAL("fill_blanks_input_mode_onboarding_modal"),
- FILL_BLANKS_SELECT_MODE_ONBOARDING_MODAL("fill_blanks_select_mode_onboarding_modal"),
GPT_CODE_GENERATION_WITH_ERRORS_ONBOARDING_MODAL("gpt_code_generation_with_errors_onboarding_modal"),
MODAL("modal"),
STREAK_WIDGET("streak_widget"),
@@ -48,5 +45,6 @@ enum class HyperskillAnalyticPart(val partName: String) {
DAILY_STUDY_REMINDERS_HOUR_INTERVAL_PICKER_MODAL("daily_study_reminders_hour_interval_picker_modal"),
REQUEST_REVIEW_MODAL("request_review_modal"),
USERS_INTERVIEW_WIDGET("users_interview_widget"),
- UNSUPPORTED_QUIZ_PLACEHOLDER("unsupported_quiz_placeholder")
+ UNSUPPORTED_QUIZ_PLACEHOLDER("unsupported_quiz_placeholder"),
+ CODE_BLANKS("code_blanks")
}
\ 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 e8f9a8003b..1b761c88ee 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
@@ -135,5 +135,7 @@ enum class HyperskillAnalyticTarget(val targetName: String) {
QUESTIONNAIRE_ITEM("questionnaire_item"),
PROGRAMMING_LANGUAGE("programming_language"),
SHOW_MORE("show_more"),
- SHOW_REPLIES("show_replies")
+ SHOW_REPLIES("show_replies"),
+ CODE_BLOCK("code_block"),
+ CODE_BLOCK_SUGGESTION("code_block_suggestion")
}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt
index bcd788c311..ea136dc428 100644
--- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt
@@ -69,6 +69,7 @@ import org.hyperskill.app.step_completion.injection.StepCompletionComponent
import org.hyperskill.app.step_completion.injection.StepCompletionFlowDataComponent
import org.hyperskill.app.step_feedback.injection.StepFeedbackComponent
import org.hyperskill.app.step_quiz.injection.StepQuizComponent
+import org.hyperskill.app.step_quiz_code_blanks.injection.StepQuizCodeBlanksComponent
import org.hyperskill.app.step_quiz_hints.injection.StepQuizHintsComponent
import org.hyperskill.app.step_quiz_toolbar.injection.StepQuizToolbarComponent
import org.hyperskill.app.step_toolbar.injection.StepToolbarComponent
@@ -135,6 +136,7 @@ interface AppGraph {
fun buildStepQuizComponent(stepRoute: StepRoute): StepQuizComponent
fun buildStepQuizHintsComponent(stepRoute: StepRoute): StepQuizHintsComponent
fun buildStepQuizToolbarComponent(stepRoute: StepRoute): StepQuizToolbarComponent
+ fun buildStepQuizCodeBlanksComponent(stepRoute: StepRoute): StepQuizCodeBlanksComponent
fun buildStepCompletionComponent(stepRoute: StepRoute): StepCompletionComponent
fun buildStepToolbarComponent(stepRoute: StepRoute): StepToolbarComponent
fun buildStageImplementComponent(projectId: Long, stageId: Long): StageImplementComponent
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt
index 5184e95b06..2b6a0282a3 100644
--- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt
@@ -125,6 +125,8 @@ import org.hyperskill.app.step_feedback.injection.StepFeedbackComponent
import org.hyperskill.app.step_feedback.injection.StepFeedbackComponentImpl
import org.hyperskill.app.step_quiz.injection.StepQuizComponent
import org.hyperskill.app.step_quiz.injection.StepQuizComponentImpl
+import org.hyperskill.app.step_quiz_code_blanks.injection.StepQuizCodeBlanksComponent
+import org.hyperskill.app.step_quiz_code_blanks.injection.StepQuizCodeBlanksComponentImpl
import org.hyperskill.app.step_quiz_hints.injection.StepQuizHintsComponent
import org.hyperskill.app.step_quiz_hints.injection.StepQuizHintsComponentImpl
import org.hyperskill.app.step_quiz_toolbar.injection.StepQuizToolbarComponent
@@ -293,6 +295,9 @@ abstract class BaseAppGraph : AppGraph {
override fun buildStepQuizToolbarComponent(stepRoute: StepRoute): StepQuizToolbarComponent =
StepQuizToolbarComponentImpl(this, stepRoute)
+ override fun buildStepQuizCodeBlanksComponent(stepRoute: StepRoute): StepQuizCodeBlanksComponent =
+ StepQuizCodeBlanksComponentImpl(this, stepRoute)
+
/**
* Step completion component
*/
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 c90128ae12..91e5d8757f 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
@@ -28,7 +28,9 @@ data class Block(
@SerialName("samples")
val samples: List>? = null,
@SerialName("files")
- val files: List? = null
+ val files: List? = null,
+ @SerialName("code_blanks_strings")
+ val codeBlanksStrings: List? = null
) {
@Serializable
data class File(
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizComponentImpl.kt
index 949693a085..2597202b0a 100644
--- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizComponentImpl.kt
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizComponentImpl.kt
@@ -11,6 +11,7 @@ import org.hyperskill.app.step_quiz.presentation.StepQuizFeature
import org.hyperskill.app.step_quiz.remote.AttemptRemoteDataSourceImpl
import org.hyperskill.app.step_quiz.view.mapper.StepQuizStatsTextMapper
import org.hyperskill.app.step_quiz.view.mapper.StepQuizTitleMapper
+import org.hyperskill.app.step_quiz_code_blanks.injection.StepQuizCodeBlanksComponent
import org.hyperskill.app.step_quiz_hints.injection.StepQuizHintsComponent
import org.hyperskill.app.step_quiz_toolbar.injection.StepQuizToolbarComponent
import ru.nobird.app.presentation.redux.feature.Feature
@@ -49,6 +50,9 @@ internal class StepQuizComponentImpl(
private val stepQuizToolbarComponent: StepQuizToolbarComponent =
appGraph.buildStepQuizToolbarComponent(stepRoute)
+ private val stepQuizCodeBlanksComponent: StepQuizCodeBlanksComponent =
+ appGraph.buildStepQuizCodeBlanksComponent(stepRoute)
+
override val stepQuizFeature: Feature
get() = StepQuizFeatureBuilder.build(
stepRoute = stepRoute,
@@ -67,6 +71,8 @@ internal class StepQuizComponentImpl(
stepQuizHintsActionDispatcher = stepQuizHintsComponent.stepQuizHintsActionDispatcher,
stepQuizHintsReducer = stepQuizHintsComponent.stepQuizHintsReducer,
stepQuizToolbarReducer = stepQuizToolbarComponent.stepQuizToolbarReducer,
- stepQuizToolbarActionDispatcher = stepQuizToolbarComponent.stepQuizToolbarActionDispatcher
+ stepQuizToolbarActionDispatcher = stepQuizToolbarComponent.stepQuizToolbarActionDispatcher,
+ stepQuizCodeBlanksReducer = stepQuizCodeBlanksComponent.stepQuizCodeBlanksReducer,
+ stepQuizCodeBlanksActionDispatcher = stepQuizCodeBlanksComponent.stepQuizCodeBlanksActionDispatcher
)
}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizFeatureBuilder.kt
index 0fa3f39480..035ab1ca4c 100644
--- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizFeatureBuilder.kt
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizFeatureBuilder.kt
@@ -18,6 +18,9 @@ import org.hyperskill.app.step_quiz.presentation.StepQuizActionDispatcher
import org.hyperskill.app.step_quiz.presentation.StepQuizChildFeatureReducer
import org.hyperskill.app.step_quiz.presentation.StepQuizFeature
import org.hyperskill.app.step_quiz.presentation.StepQuizReducer
+import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksActionDispatcher
+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_hints.presentation.StepQuizHintsActionDispatcher
import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsFeature
import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsReducer
@@ -51,6 +54,8 @@ internal object StepQuizFeatureBuilder {
stepQuizHintsActionDispatcher: StepQuizHintsActionDispatcher,
stepQuizToolbarReducer: StepQuizToolbarReducer,
stepQuizToolbarActionDispatcher: StepQuizToolbarActionDispatcher,
+ stepQuizCodeBlanksReducer: StepQuizCodeBlanksReducer,
+ stepQuizCodeBlanksActionDispatcher: StepQuizCodeBlanksActionDispatcher,
logger: Logger,
buildVariant: BuildVariant
): Feature {
@@ -58,7 +63,8 @@ internal object StepQuizFeatureBuilder {
stepRoute = stepRoute,
stepQuizChildFeatureReducer = StepQuizChildFeatureReducer(
stepQuizHintsReducer = stepQuizHintsReducer,
- stepQuizToolbarReducer = stepQuizToolbarReducer
+ stepQuizToolbarReducer = stepQuizToolbarReducer,
+ stepQuizCodeBlanksReducer = stepQuizCodeBlanksReducer
),
).wrapWithLogger(buildVariant, logger, LOG_TAG)
@@ -81,7 +87,8 @@ internal object StepQuizFeatureBuilder {
StepQuizFeature.State(
stepQuizState = StepQuizFeature.StepQuizState.Idle,
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
),
stepQuizReducer
)
@@ -98,6 +105,12 @@ internal object StepQuizFeatureBuilder {
transformMessage = StepQuizFeature.Message::StepQuizToolbarMessage
)
)
+ .wrapWithActionDispatcher(
+ stepQuizCodeBlanksActionDispatcher.transform(
+ transformAction = { it.safeCast()?.action },
+ transformMessage = StepQuizFeature.Message::StepQuizCodeBlanksMessage
+ )
+ )
.wrapWithAnalyticLogger(analyticInteractor) {
(it as? StepQuizFeature.InternalAction.LogAnalyticEvent)?.analyticEvent
}
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizChildFeatureReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizChildFeatureReducer.kt
index 7404535bab..41b2bbf5a4 100644
--- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizChildFeatureReducer.kt
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizChildFeatureReducer.kt
@@ -1,5 +1,7 @@
package org.hyperskill.app.step_quiz.presentation
+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_hints.presentation.StepQuizHintsFeature
import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsReducer
import org.hyperskill.app.step_quiz_toolbar.presentation.StepQuizToolbarFeature
@@ -7,7 +9,8 @@ import org.hyperskill.app.step_quiz_toolbar.presentation.StepQuizToolbarReducer
internal class StepQuizChildFeatureReducer(
private val stepQuizToolbarReducer: StepQuizToolbarReducer,
- private val stepQuizHintsReducer: StepQuizHintsReducer
+ private val stepQuizHintsReducer: StepQuizHintsReducer,
+ private val stepQuizCodeBlanksReducer: StepQuizCodeBlanksReducer
) {
companion object
@@ -27,6 +30,11 @@ internal class StepQuizChildFeatureReducer(
reduceStepQuizToolbarMessage(state.stepQuizToolbarState, message.message)
state.copy(stepQuizToolbarState = stepQuizToolbarState) to stepQuizToolbarActions
}
+ is StepQuizFeature.Message.StepQuizCodeBlanksMessage -> {
+ val (stepQuizCodeBlanksState, stepQuizCodeBlanksActions) =
+ reduceStepQuizCodeBlanksMessage(state.stepQuizCodeBlanksState, message.message)
+ state.copy(stepQuizCodeBlanksState = stepQuizCodeBlanksState) to stepQuizCodeBlanksActions
+ }
}
fun reduceStepQuizHintsMessage(
@@ -66,4 +74,23 @@ internal class StepQuizChildFeatureReducer(
return stepQuizToolbarState to actions
}
+
+ fun reduceStepQuizCodeBlanksMessage(
+ state: StepQuizCodeBlanksFeature.State,
+ message: StepQuizCodeBlanksFeature.Message
+ ): Pair> {
+ val (stepQuizCodeBlanksState, stepQuizCodeBlanksActions) = stepQuizCodeBlanksReducer.reduce(state, message)
+
+ val actions = stepQuizCodeBlanksActions
+ .map {
+ if (it is StepQuizCodeBlanksFeature.Action.ViewAction) {
+ StepQuizFeature.Action.ViewAction.StepQuizCodeBlanksViewAction(it)
+ } else {
+ StepQuizFeature.Action.StepQuizCodeBlanksAction(it)
+ }
+ }
+ .toSet()
+
+ return stepQuizCodeBlanksState to actions
+ }
}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizFeature.kt
index ad3eb2e293..377a335d82 100644
--- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizFeature.kt
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizFeature.kt
@@ -10,6 +10,7 @@ import org.hyperskill.app.step.domain.model.StepRoute
import org.hyperskill.app.step_quiz.domain.model.attempts.Attempt
import org.hyperskill.app.step_quiz.domain.model.attempts.Dataset
import org.hyperskill.app.step_quiz.domain.validation.ReplyValidationResult
+import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature
import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsFeature
import org.hyperskill.app.step_quiz_toolbar.presentation.StepQuizToolbarFeature
import org.hyperskill.app.submissions.domain.model.Reply
@@ -21,7 +22,8 @@ object StepQuizFeature {
data class State(
val stepQuizState: StepQuizState,
val stepQuizHintsState: StepQuizHintsFeature.State,
- val stepQuizToolbarState: StepQuizToolbarFeature.State
+ val stepQuizToolbarState: StepQuizToolbarFeature.State,
+ val stepQuizCodeBlanksState: StepQuizCodeBlanksFeature.State
)
sealed interface StepQuizState {
@@ -130,6 +132,9 @@ object StepQuizFeature {
*/
data class StepQuizHintsMessage(val message: StepQuizHintsFeature.Message) : Message, ChildFeatureMessage
data class StepQuizToolbarMessage(val message: StepQuizToolbarFeature.Message) : Message, ChildFeatureMessage
+ data class StepQuizCodeBlanksMessage(
+ val message: StepQuizCodeBlanksFeature.Message
+ ) : Message, ChildFeatureMessage
}
internal sealed interface InternalMessage : Message {
@@ -195,6 +200,7 @@ object StepQuizFeature {
*/
data class StepQuizHintsAction(val action: StepQuizHintsFeature.Action) : Action
data class StepQuizToolbarAction(val action: StepQuizToolbarFeature.Action) : Action
+ data class StepQuizCodeBlanksAction(val action: StepQuizCodeBlanksFeature.Action) : Action
sealed interface ViewAction : Action {
object ShowNetworkError : ViewAction // error
@@ -221,6 +227,10 @@ object StepQuizFeature {
val viewAction: StepQuizToolbarFeature.Action.ViewAction
) : ViewAction
+ data class StepQuizCodeBlanksViewAction(
+ val viewAction: StepQuizCodeBlanksFeature.Action.ViewAction
+ ) : ViewAction
+
sealed interface CreateMagicLinkState : ViewAction {
object Loading : CreateMagicLinkState
object Error : CreateMagicLinkState
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizReducer.kt
index 8d132d0c18..a7fee71a31 100644
--- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizReducer.kt
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizReducer.kt
@@ -30,6 +30,7 @@ import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.InternalMessage
import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.Message
import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.State
import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.StepQuizState
+import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature
import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsFeature
import org.hyperskill.app.step_quiz_toolbar.presentation.StepQuizToolbarFeature
import org.hyperskill.app.submissions.domain.model.Reply
@@ -81,20 +82,7 @@ internal class StepQuizReducer(
} else {
null
}
- is Message.CreateAttemptSuccess ->
- if (state.stepQuizState is StepQuizState.AttemptLoading) {
- state.copy(
- stepQuizState = StepQuizState.AttemptLoaded(
- step = message.step,
- attempt = message.attempt,
- submissionState = message.submissionState,
- isProblemsLimitReached = message.isProblemsLimitReached,
- isTheoryAvailable = StepQuizResolver.isTheoryAvailable(stepRoute, message.step)
- )
- ) to emptySet()
- } else {
- null
- }
+ is Message.CreateAttemptSuccess -> handleCreateAttemptSuccess(state, message)
is Message.CreateAttemptError ->
if (state.stepQuizState is StepQuizState.AttemptLoading) {
state.copy(
@@ -320,19 +308,37 @@ internal class StepQuizReducer(
if (isMobileGptCodeGenerationWithErrorsAvailable && !isProblemsLimitReached) {
state to setOf(InternalAction.GenerateGptCodeWithErrors(stepQuizState))
} else {
+ val (stepQuizCodeBlanksState, stepQuizCodeBlanksActions) =
+ if (StepQuizCodeBlanksFeature.isCodeBlanksFeatureAvailable(message.step)) {
+ stepQuizChildFeatureReducer.reduceStepQuizCodeBlanksMessage(
+ state.stepQuizCodeBlanksState,
+ StepQuizCodeBlanksFeature.InternalMessage.Initialize(message.step)
+ )
+ } else {
+ StepQuizCodeBlanksFeature.State.Idle to emptySet()
+ }
+
val shouldShowProblemsLimitModal = shouldShowProblemsLimitModal(
subscription = message.subscription,
isProblemsLimitReached = message.isProblemsLimitReached
)
- state.copy(stepQuizState = stepQuizState) to
+
+ state.copy(
+ stepQuizState = stepQuizState,
+ stepQuizCodeBlanksState = stepQuizCodeBlanksState
+ ) to buildSet {
if (isProblemsLimitReached && shouldShowProblemsLimitModal) {
- showProblemsLimitReachedModal(message.subscription, message.chargeLimitsStrategy)
+ addAll(showProblemsLimitReachedModal(message.subscription, message.chargeLimitsStrategy))
} else {
- getProblemOnboardingModalActions(
- step = message.step,
- problemsOnboardingFlags = message.problemsOnboardingFlags
+ addAll(
+ getProblemOnboardingModalActions(
+ step = message.step,
+ problemsOnboardingFlags = message.problemsOnboardingFlags
+ )
)
}
+ addAll(stepQuizCodeBlanksActions)
+ }
}
}
} else {
@@ -372,6 +378,35 @@ internal class StepQuizReducer(
)
}
+ private fun handleCreateAttemptSuccess(
+ state: State,
+ message: Message.CreateAttemptSuccess
+ ): StepQuizReducerResult? =
+ if (state.stepQuizState is StepQuizState.AttemptLoading) {
+ val (stepQuizCodeBlanksState, stepQuizCodeBlanksActions) =
+ if (StepQuizCodeBlanksFeature.isCodeBlanksFeatureAvailable(message.step)) {
+ stepQuizChildFeatureReducer.reduceStepQuizCodeBlanksMessage(
+ state.stepQuizCodeBlanksState,
+ StepQuizCodeBlanksFeature.InternalMessage.Initialize(message.step)
+ )
+ } else {
+ StepQuizCodeBlanksFeature.State.Idle to emptySet()
+ }
+
+ state.copy(
+ stepQuizState = StepQuizState.AttemptLoaded(
+ step = message.step,
+ attempt = message.attempt,
+ submissionState = message.submissionState,
+ isProblemsLimitReached = message.isProblemsLimitReached,
+ isTheoryAvailable = StepQuizResolver.isTheoryAvailable(stepRoute, message.step)
+ ),
+ stepQuizCodeBlanksState = stepQuizCodeBlanksState
+ ) to stepQuizCodeBlanksActions
+ } else {
+ null
+ }
+
private fun initialize(state: State, message: Message.InitWithStep): StepQuizReducerResult {
val needReloadStepQuiz =
state.stepQuizState is StepQuizState.Idle ||
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksAnalyticParams.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksAnalyticParams.kt
new file mode 100644
index 0000000000..663584ab91
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksAnalyticParams.kt
@@ -0,0 +1,6 @@
+package org.hyperskill.app.step_quiz_code_blanks.domain.analytic
+
+internal object StepQuizCodeBlanksAnalyticParams {
+ const val PARAM_CODE_BLOCK = "code_block"
+ const val PARAM_SUGGESTION = "suggestion"
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent.kt
new file mode 100644
index 0000000000..36d0b1e592
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent.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 code block in the code blanks analytic event.
+ *
+ * JSON payload:
+ * ```
+ * {
+ * "route": "/learn/step/1",
+ * "action": "click",
+ * "part": "code_blanks",
+ * "target": "code_block",
+ * "context":
+ * {
+ * "code_block": "Blank(isActive=true, suggestions=[Print])"
+ * }
+ * }
+ * ```
+ *
+ * @see HyperskillAnalyticEvent
+ */
+class StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent(
+ route: HyperskillAnalyticRoute,
+ codeBlock: CodeBlock?
+) : HyperskillAnalyticEvent(
+ route = route,
+ action = HyperskillAnalyticAction.CLICK,
+ part = HyperskillAnalyticPart.CODE_BLANKS,
+ target = HyperskillAnalyticTarget.CODE_BLOCK,
+ 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/analytic/StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent.kt
new file mode 100644
index 0000000000..99cc54148d
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent.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 "Delete" button in the code block analytic event.
+ *
+ * JSON payload:
+ * ```
+ * {
+ * "route": "/learn/step/1",
+ * "action": "click",
+ * "part": "code_blanks",
+ * "target": "delete",
+ * "context":
+ * {
+ * "code_block": "Print(isActive=true, suggestions=[ConstantString(text=suggestion)], selectedSuggestion=null)"
+ * }
+ * }
+ * ```
+ *
+ * @see HyperskillAnalyticEvent
+ */
+class StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent(
+ route: HyperskillAnalyticRoute,
+ codeBlock: CodeBlock?
+) : HyperskillAnalyticEvent(
+ route = route,
+ action = HyperskillAnalyticAction.CLICK,
+ part = HyperskillAnalyticPart.CODE_BLANKS,
+ target = HyperskillAnalyticTarget.DELETE,
+ 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/analytic/StepQuizCodeBlanksClickedSuggestionHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedSuggestionHyperskillAnalyticEvent.kt
new file mode 100644
index 0000000000..89dea658c0
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedSuggestionHyperskillAnalyticEvent.kt
@@ -0,0 +1,45 @@
+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 org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion
+import ru.nobird.app.core.model.mapOfNotNull
+
+/**
+ * Represents click on the code block suggestion in the code blanks analytic event.
+ *
+ * JSON payload:
+ * ```
+ * {
+ * "route": "/learn/step/1",
+ * "action": "click",
+ * "part": "code_blanks",
+ * "target": "code_block_suggestion",
+ * "context":
+ * {
+ * "code_block": "Blank(isActive=true, suggestions=[Print])",
+ * "suggestion": "ConstantString(text='suggestion')"
+ * }
+ * }
+ * ```
+ *
+ * @see HyperskillAnalyticEvent
+ */
+class StepQuizCodeBlanksClickedSuggestionHyperskillAnalyticEvent(
+ route: HyperskillAnalyticRoute,
+ codeBlock: CodeBlock?,
+ suggestion: Suggestion
+) : HyperskillAnalyticEvent(
+ route = route,
+ action = HyperskillAnalyticAction.CLICK,
+ part = HyperskillAnalyticPart.CODE_BLANKS,
+ target = HyperskillAnalyticTarget.CODE_BLOCK_SUGGESTION,
+ context = mapOfNotNull(
+ StepQuizCodeBlanksAnalyticParams.PARAM_CODE_BLOCK to codeBlock?.analyticRepresentation,
+ StepQuizCodeBlanksAnalyticParams.PARAM_SUGGESTION to suggestion.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
new file mode 100644
index 0000000000..aa6cfefc54
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/CodeBlock.kt
@@ -0,0 +1,39 @@
+package org.hyperskill.app.step_quiz_code_blanks.domain.model
+
+sealed class CodeBlock {
+ abstract val isActive: Boolean
+
+ abstract val suggestions: List
+
+ internal abstract val analyticRepresentation: String
+
+ data class Blank(
+ override val isActive: Boolean
+ ) : CodeBlock() {
+ override val suggestions: List
+ get() = listOf(Suggestion.Print)
+
+ override fun toString(): String = ""
+
+ override val analyticRepresentation: String
+ get() = "Blank(isActive=$isActive, suggestions=$suggestions)"
+ }
+
+ data class Print(
+ override val isActive: Boolean,
+ override val suggestions: List,
+ val selectedSuggestion: Suggestion.ConstantString?
+ ) : CodeBlock() {
+ override fun toString(): String =
+ buildString {
+ append("print(")
+ if (selectedSuggestion != null) {
+ append(selectedSuggestion.text)
+ }
+ append(")")
+ }
+
+ override val analyticRepresentation: String
+ get() = "Print(isActive=$isActive, suggestions=$suggestions, selectedSuggestion=$selectedSuggestion)"
+ }
+}
\ 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
new file mode 100644
index 0000000000..83cfbd449b
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/Suggestion.kt
@@ -0,0 +1,21 @@
+package org.hyperskill.app.step_quiz_code_blanks.domain.model
+
+sealed class Suggestion {
+ abstract val text: String
+
+ internal abstract val analyticRepresentation: String
+
+ data object Print : Suggestion() {
+ override val text: String = "print"
+
+ override val analyticRepresentation: String =
+ "Print(text='$text')"
+ }
+
+ data class ConstantString(
+ override val text: String
+ ) : Suggestion() {
+ override val analyticRepresentation: String
+ get() = "ConstantString(text='$text')"
+ }
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/injection/StepQuizCodeBlanksComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/injection/StepQuizCodeBlanksComponent.kt
new file mode 100644
index 0000000000..9de20a6975
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/injection/StepQuizCodeBlanksComponent.kt
@@ -0,0 +1,9 @@
+package org.hyperskill.app.step_quiz_code_blanks.injection
+
+import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksActionDispatcher
+import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksReducer
+
+interface StepQuizCodeBlanksComponent {
+ val stepQuizCodeBlanksReducer: StepQuizCodeBlanksReducer
+ val stepQuizCodeBlanksActionDispatcher: StepQuizCodeBlanksActionDispatcher
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/injection/StepQuizCodeBlanksComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/injection/StepQuizCodeBlanksComponentImpl.kt
new file mode 100644
index 0000000000..2e2a5be64c
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/injection/StepQuizCodeBlanksComponentImpl.kt
@@ -0,0 +1,21 @@
+package org.hyperskill.app.step_quiz_code_blanks.injection
+
+import org.hyperskill.app.core.injection.AppGraph
+import org.hyperskill.app.core.presentation.ActionDispatcherOptions
+import org.hyperskill.app.step.domain.model.StepRoute
+import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksActionDispatcher
+import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksReducer
+
+internal class StepQuizCodeBlanksComponentImpl(
+ private val appGraph: AppGraph,
+ private val stepRoute: StepRoute
+) : StepQuizCodeBlanksComponent {
+ override val stepQuizCodeBlanksReducer: StepQuizCodeBlanksReducer
+ get() = StepQuizCodeBlanksReducer(stepRoute)
+
+ override val stepQuizCodeBlanksActionDispatcher: StepQuizCodeBlanksActionDispatcher
+ get() = StepQuizCodeBlanksActionDispatcher(
+ config = ActionDispatcherOptions(),
+ analyticInteractor = appGraph.analyticComponent.analyticInteractor
+ )
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksActionDispatcher.kt
new file mode 100644
index 0000000000..a4f16141a6
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksActionDispatcher.kt
@@ -0,0 +1,20 @@
+package org.hyperskill.app.step_quiz_code_blanks.presentation
+
+import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor
+import org.hyperskill.app.core.presentation.ActionDispatcherOptions
+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.Message
+import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher
+
+class StepQuizCodeBlanksActionDispatcher(
+ config: ActionDispatcherOptions,
+ private val analyticInteractor: AnalyticInteractor
+) : CoroutineActionDispatcher(config.createConfig()) {
+ override suspend fun doSuspendableAction(action: Action) {
+ when (action) {
+ is InternalAction.LogAnalyticEvent ->
+ analyticInteractor.logEvent(action.analyticEvent)
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000000..03d683d547
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksFeature.kt
@@ -0,0 +1,46 @@
+package org.hyperskill.app.step_quiz_code_blanks.presentation
+
+import org.hyperskill.app.analytic.domain.model.AnalyticEvent
+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 org.hyperskill.app.step_quiz_code_blanks.view.model.StepQuizCodeBlanksViewState
+
+object StepQuizCodeBlanksFeature {
+ internal fun isCodeBlanksFeatureAvailable(step: Step): Boolean =
+ step.block.options.codeBlanksStrings.isNullOrEmpty().not()
+
+ internal fun initialState(): State = State.Idle
+
+ sealed interface State {
+ data object Idle : State
+
+ data class Content(
+ val step: Step,
+ val codeBlocks: List
+ ) : State {
+ internal val codeBlanksStringsSuggestions: List =
+ step.block.options.codeBlanksStrings.orEmpty().map(Suggestion::ConstantString)
+ }
+ }
+
+ sealed interface Message {
+ data class SuggestionClicked(val suggestion: Suggestion) : Message
+
+ data class CodeBlockClicked(val codeBlockItem: StepQuizCodeBlanksViewState.CodeBlockItem) : Message
+
+ data object DeleteButtonClicked : Message
+ }
+
+ internal sealed interface InternalMessage : Message {
+ data class Initialize(val step: Step) : InternalMessage
+ }
+
+ sealed interface Action {
+ sealed interface ViewAction : Action
+ }
+
+ internal sealed interface InternalAction : Action {
+ data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000000..afa2e7b058
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducer.kt
@@ -0,0 +1,178 @@
+package org.hyperskill.app.step_quiz_code_blanks.presentation
+
+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.analytic.StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent
+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.Suggestion
+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.State
+import ru.nobird.app.core.model.mutate
+import ru.nobird.app.presentation.redux.reducer.StateReducer
+
+private typealias StepQuizCodeBlanksReducerResult = Pair>
+
+class StepQuizCodeBlanksReducer(
+ private val stepRoute: StepRoute
+) : StateReducer {
+ override fun reduce(state: State, message: Message): StepQuizCodeBlanksReducerResult =
+ when (message) {
+ is InternalMessage.Initialize -> initialize(message)
+ is Message.SuggestionClicked -> handleSuggestionClicked(state, message)
+ is Message.CodeBlockClicked -> handleCodeBlockClicked(state, message)
+ Message.DeleteButtonClicked -> handleDeleteButtonClicked(state)
+ } ?: (state to emptySet())
+
+ private fun initialize(
+ message: InternalMessage.Initialize
+ ): StepQuizCodeBlanksReducerResult =
+ State.Content(
+ step = message.step,
+ codeBlocks = listOf(CodeBlock.Blank(isActive = true))
+ ) to emptySet()
+
+ private fun handleSuggestionClicked(
+ state: State,
+ message: Message.SuggestionClicked
+ ): StepQuizCodeBlanksReducerResult? {
+ if (state !is State.Content) {
+ return null
+ }
+
+ val activeCodeBlockIndex = state.activeCodeBlockIndex
+
+ val actions = setOf(
+ InternalAction.LogAnalyticEvent(
+ StepQuizCodeBlanksClickedSuggestionHyperskillAnalyticEvent(
+ route = stepRoute.analyticRoute,
+ codeBlock = activeCodeBlockIndex?.let { state.codeBlocks[it] },
+ suggestion = message.suggestion
+ )
+ )
+ )
+
+ if (activeCodeBlockIndex == null) {
+ return state to actions
+ }
+ val activeCodeBlock = state.codeBlocks[activeCodeBlockIndex]
+
+ if (!activeCodeBlock.suggestions.contains(message.suggestion)) {
+ return state to actions
+ }
+
+ val newCodeBlock =
+ when (activeCodeBlock) {
+ is CodeBlock.Blank ->
+ CodeBlock.Print(
+ isActive = true,
+ suggestions = state.codeBlanksStringsSuggestions,
+ selectedSuggestion = null
+ )
+ is CodeBlock.Print ->
+ activeCodeBlock.copy(
+ selectedSuggestion = message.suggestion as? Suggestion.ConstantString
+ )
+ }
+
+ val newCodeBlocks =
+ if (activeCodeBlockIndex == state.codeBlocks.lastIndex && newCodeBlock.selectedSuggestion != null) {
+ state.codeBlocks.mutate {
+ set(activeCodeBlockIndex, newCodeBlock.copy(isActive = false))
+ add(CodeBlock.Blank(isActive = true))
+ }
+ } else {
+ state.codeBlocks.mutate { set(activeCodeBlockIndex, newCodeBlock) }
+ }
+
+ return state.copy(codeBlocks = newCodeBlocks) to actions
+ }
+
+ private fun handleCodeBlockClicked(
+ state: State,
+ message: Message.CodeBlockClicked
+ ): StepQuizCodeBlanksReducerResult? {
+ if (state !is State.Content) {
+ return null
+ }
+
+ val targetCodeBlock = state.codeBlocks.getOrNull(index = message.codeBlockItem.id)
+ val actions = setOf(
+ InternalAction.LogAnalyticEvent(
+ StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent(
+ route = stepRoute.analyticRoute,
+ codeBlock = targetCodeBlock
+ )
+ )
+ )
+
+ if (targetCodeBlock?.isActive == true) {
+ return state to actions
+ }
+
+ val newCodeBlocks = state.codeBlocks.mapIndexed { index, codeBlock ->
+ val isActive = index == message.codeBlockItem.id
+ when (codeBlock) {
+ is CodeBlock.Blank -> codeBlock.copy(isActive = isActive)
+ is CodeBlock.Print -> codeBlock.copy(isActive = isActive)
+ }
+ }
+
+ return state.copy(codeBlocks = newCodeBlocks) to actions
+ }
+
+ private fun handleDeleteButtonClicked(
+ state: State
+ ): StepQuizCodeBlanksReducerResult? {
+ if (state !is State.Content) {
+ return null
+ }
+
+ val activeCodeBlockIndex = state.activeCodeBlockIndex
+
+ val actions = setOf(
+ InternalAction.LogAnalyticEvent(
+ StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent(
+ route = stepRoute.analyticRoute,
+ codeBlock = activeCodeBlockIndex?.let { state.codeBlocks[it] }
+ )
+ )
+ )
+
+ if (activeCodeBlockIndex == null) {
+ return state to actions
+ }
+
+ return when (val activeCodeBlock = state.codeBlocks[activeCodeBlockIndex]) {
+ is CodeBlock.Blank -> state to actions
+ is CodeBlock.Print -> {
+ val newCodeBlocks = state.codeBlocks.mutate {
+ if (activeCodeBlock.selectedSuggestion != null) {
+ set(activeCodeBlockIndex, activeCodeBlock.copy(selectedSuggestion = null))
+ } else if (state.codeBlocks.size > 1) {
+ val nextActiveIndex =
+ if (activeCodeBlockIndex < state.codeBlocks.size - 1) {
+ activeCodeBlockIndex + 1
+ } else {
+ activeCodeBlockIndex - 1
+ }
+
+ val newNextActiveCodeBlock =
+ when (val nextCodeBlock = state.codeBlocks.getOrNull(nextActiveIndex)) {
+ is CodeBlock.Blank -> nextCodeBlock.copy(isActive = true)
+ is CodeBlock.Print -> nextCodeBlock.copy(isActive = true)
+ null -> null
+ }
+ newNextActiveCodeBlock?.let { set(nextActiveIndex, it) }
+
+ removeAt(activeCodeBlockIndex)
+ }
+ }
+ state.copy(codeBlocks = newCodeBlocks) to actions
+ }
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000000..2a3446aaba
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksStateExtensions.kt
@@ -0,0 +1,12 @@
+package org.hyperskill.app.step_quiz_code_blanks.presentation
+
+import org.hyperskill.app.submissions.domain.model.Reply
+
+internal val StepQuizCodeBlanksFeature.State.Content.activeCodeBlockIndex: Int?
+ get() = codeBlocks.indexOfFirst { it.isActive }.takeIf { it != -1 }
+
+fun StepQuizCodeBlanksFeature.State.Content.createReply(): Reply =
+ Reply.code(
+ code = codeBlocks.joinToString(separator = "\n") { it.toString() },
+ language = step.block.options.codeTemplates?.keys?.firstOrNull()
+ )
\ No newline at end of file
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
new file mode 100644
index 0000000000..464fc27dab
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/view/mapper/StepQuizCodeBlanksViewStateMapper.kt
@@ -0,0 +1,65 @@
+package org.hyperskill.app.step_quiz_code_blanks.view.mapper
+
+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.view.model.StepQuizCodeBlanksViewState
+
+object StepQuizCodeBlanksViewStateMapper {
+ fun map(state: StepQuizCodeBlanksFeature.State): StepQuizCodeBlanksViewState =
+ when (state) {
+ StepQuizCodeBlanksFeature.State.Idle -> StepQuizCodeBlanksViewState.Idle
+ is StepQuizCodeBlanksFeature.State.Content -> mapContentState(state)
+ }
+
+ private fun mapContentState(
+ state: StepQuizCodeBlanksFeature.State.Content
+ ): StepQuizCodeBlanksViewState.Content {
+ val codeBlocks = state.codeBlocks.mapIndexed(::mapCodeBlock)
+ val activeCodeBlock = state.codeBlocks.firstOrNull { it.isActive }
+
+ val suggestions =
+ when (activeCodeBlock) {
+ is CodeBlock.Blank -> activeCodeBlock.suggestions
+ is CodeBlock.Print ->
+ if (activeCodeBlock.selectedSuggestion == null) {
+ activeCodeBlock.suggestions
+ } else {
+ emptyList()
+ }
+ null -> emptyList()
+ }
+
+ val isDeleteButtonVisible =
+ when (activeCodeBlock) {
+ is CodeBlock.Blank -> false
+ is CodeBlock.Print ->
+ if (activeCodeBlock.selectedSuggestion != null) {
+ true
+ } else {
+ codeBlocks.size > 1
+ }
+ null -> false
+ }
+
+ return StepQuizCodeBlanksViewState.Content(
+ codeBlocks = codeBlocks,
+ suggestions = suggestions,
+ isDeleteButtonVisible = isDeleteButtonVisible
+ )
+ }
+
+ private fun mapCodeBlock(
+ index: Int,
+ codeBlock: CodeBlock
+ ): StepQuizCodeBlanksViewState.CodeBlockItem =
+ when (codeBlock) {
+ is CodeBlock.Blank ->
+ StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = index, isActive = codeBlock.isActive)
+ is CodeBlock.Print ->
+ StepQuizCodeBlanksViewState.CodeBlockItem.Print(
+ id = index,
+ isActive = codeBlock.isActive,
+ output = codeBlock.selectedSuggestion?.text
+ )
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000000..e65de8934b
--- /dev/null
+++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/view/model/StepQuizCodeBlanksViewState.kt
@@ -0,0 +1,29 @@
+package org.hyperskill.app.step_quiz_code_blanks.view.model
+
+import org.hyperskill.app.step_quiz_code_blanks.domain.model.Suggestion
+
+sealed interface StepQuizCodeBlanksViewState {
+ data object Idle : StepQuizCodeBlanksViewState
+
+ data class Content(
+ val codeBlocks: List,
+ val suggestions: List,
+ val isDeleteButtonVisible: Boolean
+ ) : StepQuizCodeBlanksViewState
+
+ sealed interface CodeBlockItem {
+ val id: Int
+ val isActive: Boolean
+
+ data class Blank(
+ override val id: Int,
+ override val isActive: Boolean
+ ) : CodeBlockItem
+
+ data class Print(
+ override val id: Int,
+ override val isActive: Boolean,
+ val output: String?
+ ) : CodeBlockItem
+ }
+}
\ No newline at end of file
diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/StepQuizTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/StepQuizTest.kt
index fd183e752d..942dd9c575 100644
--- a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/StepQuizTest.kt
+++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/StepQuizTest.kt
@@ -3,10 +3,12 @@ package org.hyperskill.step_quiz
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.hyperskill.app.onboarding.domain.model.ProblemsOnboardingFlags
import org.hyperskill.app.problems_limit_info.domain.model.ProblemsLimitInfoModalContext
import org.hyperskill.app.problems_limit_info.domain.model.ProblemsLimitInfoModalLaunchSource
+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.domain.analytic.StepQuizClickedTheoryToolbarItemHyperskillAnalyticEvent
@@ -15,6 +17,7 @@ import org.hyperskill.app.step_quiz.domain.validation.ReplyValidationResult
import org.hyperskill.app.step_quiz.presentation.StepQuizChildFeatureReducer
import org.hyperskill.app.step_quiz.presentation.StepQuizFeature
import org.hyperskill.app.step_quiz.presentation.StepQuizReducer
+import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksFeature
import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsFeature
import org.hyperskill.app.step_quiz_toolbar.presentation.StepQuizToolbarFeature
import org.hyperskill.app.submissions.domain.model.Reply
@@ -56,7 +59,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -67,7 +71,8 @@ class StepQuizTest {
StepQuizFeature.State(
stepQuizState = StepQuizFeature.StepQuizState.Loading,
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
),
StepQuizFeature.InternalMessage.FetchAttemptSuccess(
step,
@@ -102,7 +107,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -113,7 +119,8 @@ class StepQuizTest {
StepQuizFeature.State(
stepQuizState = StepQuizFeature.StepQuizState.Loading,
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
),
StepQuizFeature.InternalMessage.FetchAttemptSuccess(
step,
@@ -146,7 +153,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -170,7 +178,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
assertEquals(expectedState, actualState)
@@ -193,7 +202,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -217,7 +227,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
assertEquals(expectedState, actualState)
@@ -245,7 +256,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -271,7 +283,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
assertEquals(expectedState, actualState)
@@ -301,7 +314,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -327,7 +341,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
assertEquals(expectedState, actualState)
@@ -355,7 +370,8 @@ class StepQuizTest {
isTheoryAvailable = true
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -367,7 +383,8 @@ class StepQuizTest {
StepQuizFeature.State(
stepQuizState = StepQuizFeature.StepQuizState.Loading,
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
),
StepQuizFeature.InternalMessage.FetchAttemptSuccess(
step,
@@ -418,7 +435,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -430,7 +448,8 @@ class StepQuizTest {
StepQuizFeature.State(
stepQuizState = StepQuizFeature.StepQuizState.Loading,
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
),
StepQuizFeature.InternalMessage.FetchAttemptSuccess(
step,
@@ -469,7 +488,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -500,7 +520,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -535,7 +556,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -571,7 +593,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -608,7 +631,8 @@ class StepQuizTest {
isTheoryAvailable = false
),
stepQuizHintsState = StepQuizHintsFeature.State.Idle,
- stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute)
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
)
val reducer = StepQuizReducer(
@@ -631,4 +655,119 @@ class StepQuizTest {
assertContains(actions, StepQuizFeature.Action.ViewAction.ScrollToCallToActionButton)
}
}
+
+ @Test
+ fun `StepQuizCodeBlanksFeature should be initialized when isCodeBlanksFeatureAvailable returns true`() {
+ val step = Step.stub(
+ id = 1,
+ block = Block.stub(
+ options = Block.Options(
+ codeBlanksStrings = listOf("a", "b")
+ )
+ )
+ )
+ val attempt = Attempt.stub()
+ val submissionState = StepQuizFeature.SubmissionState.Empty()
+ val stepRoute = StepRoute.Learn.Step(step.id, null)
+
+ val expectedState = StepQuizFeature.State(
+ stepQuizState = StepQuizFeature.StepQuizState.AttemptLoaded(
+ step = step,
+ attempt = attempt,
+ submissionState = submissionState,
+ isProblemsLimitReached = false,
+ isTheoryAvailable = false
+ ),
+ stepQuizHintsState = StepQuizHintsFeature.State.Idle,
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.State.Content(
+ step = step,
+ codeBlocks = emptyList()
+ )
+ )
+
+ val reducer = StepQuizReducer(
+ stepRoute = stepRoute,
+ stepQuizChildFeatureReducer = StepQuizChildFeatureReducer.stub(stepRoute)
+ )
+ val (state, _) = reducer.reduce(
+ StepQuizFeature.State(
+ stepQuizState = StepQuizFeature.StepQuizState.Loading,
+ stepQuizHintsState = StepQuizHintsFeature.State.Idle,
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
+ ),
+ StepQuizFeature.InternalMessage.FetchAttemptSuccess(
+ step,
+ attempt,
+ submissionState,
+ subscription = Subscription.stub(),
+ isProblemsLimitReached = false,
+ chargeLimitsStrategy = FreemiumChargeLimitsStrategy.AFTER_WRONG_SUBMISSION,
+ problemsOnboardingFlags = ProblemsOnboardingFlags.stub(),
+ isMobileGptCodeGenerationWithErrorsEnabled = false
+ )
+ )
+
+ assertEquals(expectedState.stepQuizState, state.stepQuizState)
+ assertEquals(expectedState.stepQuizHintsState, state.stepQuizHintsState)
+ assertEquals(expectedState.stepQuizToolbarState, state.stepQuizToolbarState)
+ assertTrue(state.stepQuizCodeBlanksState is StepQuizCodeBlanksFeature.State.Content)
+ }
+
+ @Test
+ fun `StepQuizCodeBlanksFeature should not be initialized when isCodeBlanksFeatureAvailable returns false`() {
+ val step = Step.stub(id = 1)
+ val attempt = Attempt.stub()
+ val submissionState = StepQuizFeature.SubmissionState.Empty()
+ val stepRoute = StepRoute.Learn.Step(step.id, null)
+
+ val expectedState = StepQuizFeature.State(
+ stepQuizState = StepQuizFeature.StepQuizState.AttemptLoaded(
+ step = step,
+ attempt = attempt,
+ submissionState = submissionState,
+ isProblemsLimitReached = false,
+ isTheoryAvailable = false
+ ),
+ stepQuizHintsState = StepQuizHintsFeature.State.Idle,
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.State.Idle
+ )
+
+ val reducer = StepQuizReducer(
+ stepRoute = stepRoute,
+ stepQuizChildFeatureReducer = StepQuizChildFeatureReducer.stub(stepRoute)
+ )
+ val (state, actions) = reducer.reduce(
+ StepQuizFeature.State(
+ stepQuizState = StepQuizFeature.StepQuizState.Loading,
+ stepQuizHintsState = StepQuizHintsFeature.State.Idle,
+ stepQuizToolbarState = StepQuizToolbarFeature.initialState(stepRoute),
+ stepQuizCodeBlanksState = StepQuizCodeBlanksFeature.initialState()
+ ),
+ StepQuizFeature.InternalMessage.FetchAttemptSuccess(
+ step,
+ attempt,
+ submissionState,
+ subscription = Subscription.stub(),
+ isProblemsLimitReached = false,
+ chargeLimitsStrategy = FreemiumChargeLimitsStrategy.AFTER_WRONG_SUBMISSION,
+ problemsOnboardingFlags = ProblemsOnboardingFlags.stub(),
+ isMobileGptCodeGenerationWithErrorsEnabled = false
+ )
+ )
+
+ assertEquals(expectedState.stepQuizState, state.stepQuizState)
+ assertEquals(expectedState.stepQuizHintsState, state.stepQuizHintsState)
+ assertEquals(expectedState.stepQuizToolbarState, state.stepQuizToolbarState)
+ assertTrue(state.stepQuizCodeBlanksState is StepQuizCodeBlanksFeature.State.Idle)
+
+ assertFalse {
+ actions.any {
+ it is StepQuizFeature.Action.StepQuizCodeBlanksAction ||
+ it is StepQuizFeature.Action.ViewAction.StepQuizCodeBlanksViewAction
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/presentation/StepQuizChildFeatureReducerStub.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/presentation/StepQuizChildFeatureReducerStub.kt
index 526b5df32f..5582cfbce8 100644
--- a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/presentation/StepQuizChildFeatureReducerStub.kt
+++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/presentation/StepQuizChildFeatureReducerStub.kt
@@ -2,11 +2,13 @@ package org.hyperskill.step_quiz.presentation
import org.hyperskill.app.step.domain.model.StepRoute
import org.hyperskill.app.step_quiz.presentation.StepQuizChildFeatureReducer
+import org.hyperskill.app.step_quiz_code_blanks.presentation.StepQuizCodeBlanksReducer
import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsReducer
import org.hyperskill.app.step_quiz_toolbar.presentation.StepQuizToolbarReducer
internal fun StepQuizChildFeatureReducer.Companion.stub(stepRoute: StepRoute) =
StepQuizChildFeatureReducer(
stepQuizHintsReducer = StepQuizHintsReducer(stepRoute),
- stepQuizToolbarReducer = StepQuizToolbarReducer(stepRoute)
+ stepQuizToolbarReducer = StepQuizToolbarReducer(stepRoute),
+ stepQuizCodeBlanksReducer = StepQuizCodeBlanksReducer(stepRoute)
)
\ 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/StepQuizCodeBlanksReducerTest.kt
new file mode 100644
index 0000000000..2809cac157
--- /dev/null
+++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksReducerTest.kt
@@ -0,0 +1,323 @@
+package org.hyperskill.step_quiz_code_blanks
+
+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.analytic.StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent
+import org.hyperskill.app.step_quiz_code_blanks.domain.analytic.StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent
+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.Suggestion
+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
+import org.hyperskill.step.domain.model.stub
+
+class StepQuizCodeBlanksReducerTest {
+ private val reducer = StepQuizCodeBlanksReducer(StepRoute.Learn.Step(1, null))
+
+ @Test
+ fun `Initialize should return Content state with active Blank code block`() {
+ 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))
+ )
+
+ 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)))
+
+ 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)))
+
+ 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)))
+
+ val message = StepQuizCodeBlanksFeature.Message.SuggestionClicked(Suggestion.Print)
+ val (state, actions) = reducer.reduce(initialState, message)
+
+ val expectedState = initialState.copy(
+ codeBlocks = listOf(
+ CodeBlock.Print(
+ isActive = true,
+ 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(
+ 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).selectedSuggestion)
+ assertContainsSuggestionClickedAnalyticEvent(actions)
+ }
+
+ @Test
+ fun `SuggestionClicked should add new active Blank code block when selecting last suggestion`() {
+ val suggestion = Suggestion.ConstantString("suggestion")
+ val initialState = stubContentState(
+ codeBlocks = listOf(
+ CodeBlock.Print(
+ 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.Print(
+ isActive = false,
+ suggestions = listOf(suggestion),
+ selectedSuggestion = suggestion
+ ),
+ CodeBlock.Blank(isActive = true)
+ )
+ )
+
+ assertEquals(expectedState, state)
+ 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 code block`() {
+ val initialState = stubContentState(
+ codeBlocks = listOf(
+ CodeBlock.Blank(isActive = false),
+ CodeBlock.Print(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),
+ CodeBlock.Print(isActive = false, suggestions = emptyList(), selectedSuggestion = null)
+ )
+ )
+
+ assertEquals(expectedState, state)
+ assertTrue {
+ actions.any {
+ it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent &&
+ it.analyticEvent is StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent
+ }
+ }
+ }
+
+ @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 not update state if active code block is Blank`() {
+ val initialState = stubContentState(codeBlocks = listOf(CodeBlock.Blank(isActive = true)))
+
+ val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked)
+
+ assertEquals(initialState, state)
+ assertContainsDeleteButtonClickedAnalyticEvent(actions)
+ }
+
+ @Test
+ fun `DeleteButtonClicked should update state if active Print code block has selected suggestion`() {
+ val suggestion = Suggestion.ConstantString("suggestion")
+ val initialState = stubContentState(
+ codeBlocks = listOf(
+ CodeBlock.Print(
+ isActive = true,
+ suggestions = listOf(suggestion),
+ selectedSuggestion = suggestion
+ )
+ )
+ )
+
+ val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked)
+
+ val expectedState = initialState.copy(
+ codeBlocks = listOf(
+ CodeBlock.Print(
+ isActive = true,
+ suggestions = listOf(suggestion),
+ selectedSuggestion = null
+ )
+ )
+ )
+
+ assertEquals(expectedState, state)
+ assertContainsDeleteButtonClickedAnalyticEvent(actions)
+ }
+
+ @Test
+ fun `DeleteButtonClicked should set next code block as active if active Print code block is deleted`() {
+ val initialState = stubContentState(
+ codeBlocks = listOf(
+ CodeBlock.Print(
+ isActive = true,
+ suggestions = listOf(Suggestion.ConstantString("suggestion")),
+ selectedSuggestion = null
+ ),
+ CodeBlock.Blank(isActive = false)
+ )
+ )
+
+ val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked)
+
+ val expectedState = initialState.copy(
+ codeBlocks = listOf(CodeBlock.Blank(isActive = true))
+ )
+
+ assertEquals(expectedState, state)
+ assertContainsDeleteButtonClickedAnalyticEvent(actions)
+ }
+
+ @Test
+ fun `DeleteButtonClicked should set previous code block as active if active Print code block is deleted`() {
+ val initialState = stubContentState(
+ codeBlocks = listOf(
+ CodeBlock.Blank(isActive = false),
+ CodeBlock.Print(
+ isActive = true,
+ suggestions = listOf(Suggestion.ConstantString("suggestion")),
+ selectedSuggestion = null
+ )
+ )
+ )
+
+ val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked)
+
+ val expectedState = initialState.copy(
+ codeBlocks = listOf(CodeBlock.Blank(isActive = true))
+ )
+
+ assertEquals(expectedState, state)
+ assertContainsDeleteButtonClickedAnalyticEvent(actions)
+ }
+
+ @Test
+ fun `DeleteButtonClicked should not update state if no active code block`() {
+ val initialState = stubContentState(
+ codeBlocks = listOf(
+ CodeBlock.Print(
+ isActive = false,
+ suggestions = listOf(Suggestion.ConstantString("suggestion")),
+ selectedSuggestion = null
+ )
+ )
+ )
+
+ val (state, actions) = reducer.reduce(initialState, StepQuizCodeBlanksFeature.Message.DeleteButtonClicked)
+
+ assertEquals(initialState, state)
+ assertContainsDeleteButtonClickedAnalyticEvent(actions)
+ }
+
+ private fun assertContainsSuggestionClickedAnalyticEvent(actions: Set) {
+ assertTrue {
+ actions.any {
+ it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent &&
+ it.analyticEvent is StepQuizCodeBlanksClickedSuggestionHyperskillAnalyticEvent
+ }
+ }
+ }
+
+ private fun assertContainsDeleteButtonClickedAnalyticEvent(actions: Set) {
+ assertTrue {
+ actions.any {
+ it is StepQuizCodeBlanksFeature.InternalAction.LogAnalyticEvent &&
+ it.analyticEvent is StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent
+ }
+ }
+ }
+
+ private fun stubContentState(
+ step: Step = Step.stub(id = 1),
+ codeBlocks: List
+ ): StepQuizCodeBlanksFeature.State.Content =
+ StepQuizCodeBlanksFeature.State.Content(
+ step = step,
+ codeBlocks = codeBlocks
+ )
+}
\ 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/StepQuizCodeBlanksStateExtensionsTest.kt
new file mode 100644
index 0000000000..9d4ce6866d
--- /dev/null
+++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksStateExtensionsTest.kt
@@ -0,0 +1,82 @@
+package org.hyperskill.step_quiz_code_blanks
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+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.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.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),
+ CodeBlock.Print(
+ isActive = false,
+ suggestions = emptyList(),
+ selectedSuggestion = null
+ )
+ )
+ )
+ assertNull(state.activeCodeBlockIndex)
+ }
+
+ @Test
+ fun `activeCodeBlockIndex should return index of the active code block`() {
+ val state = stubContentState(
+ codeBlocks = listOf(
+ CodeBlock.Blank(isActive = false),
+ CodeBlock.Print(
+ isActive = true,
+ suggestions = emptyList(),
+ selectedSuggestion = null
+ )
+ )
+ )
+ assertEquals(1, state.activeCodeBlockIndex)
+ }
+
+ @Test
+ fun `createReply should return Reply with code from code blocks and language from step options`() {
+ val codeBlocks = listOf(
+ CodeBlock.Print(
+ isActive = false,
+ suggestions = emptyList(),
+ selectedSuggestion = Suggestion.ConstantString("\"test\"")
+ ),
+ CodeBlock.Blank(isActive = true)
+ )
+ 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 expectedReply = Reply.code(code = "print(\"test\")\n", language = "python3")
+
+ assertEquals(expectedReply, state.createReply())
+ }
+
+ private fun stubContentState(
+ step: Step = Step.stub(id = 1),
+ codeBlocks: List
+ ): StepQuizCodeBlanksFeature.State.Content =
+ StepQuizCodeBlanksFeature.State.Content(
+ step = step,
+ codeBlocks = codeBlocks
+ )
+}
\ No newline at end of file
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
new file mode 100644
index 0000000000..b20c2fdfb8
--- /dev/null
+++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksViewStateMapperTest.kt
@@ -0,0 +1,142 @@
+package org.hyperskill.step_quiz_code_blanks
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+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 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
+
+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 delete button hidden when active code block is Blank`() {
+ val state = stubState(
+ codeBlocks = listOf(CodeBlock.Blank(isActive = true))
+ )
+ val expectedViewState = StepQuizCodeBlanksViewState.Content(
+ codeBlocks = listOf(StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 0, isActive = true)),
+ suggestions = listOf(Suggestion.Print),
+ isDeleteButtonVisible = false
+ )
+
+ val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state)
+
+ assertEquals(expectedViewState, actualViewState)
+ }
+
+ @Test
+ fun `Content with suggestions and not visible delete button when active code block is Print`() {
+ val suggestions = listOf(
+ Suggestion.ConstantString("1"),
+ Suggestion.ConstantString("2")
+ )
+ val state = stubState(
+ codeBlocks = listOf(
+ CodeBlock.Print(
+ isActive = true,
+ selectedSuggestion = null,
+ suggestions = suggestions
+ )
+ )
+ )
+ val expectedViewState = StepQuizCodeBlanksViewState.Content(
+ codeBlocks = listOf(
+ StepQuizCodeBlanksViewState.CodeBlockItem.Print(id = 0, isActive = true, output = null)
+ ),
+ suggestions = suggestions,
+ isDeleteButtonVisible = false
+ )
+
+ 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(
+ isActive = false,
+ selectedSuggestion = printSuggestions[0],
+ suggestions = printSuggestions
+ ),
+ CodeBlock.Blank(isActive = true)
+ )
+ )
+ val expectedViewState = StepQuizCodeBlanksViewState.Content(
+ codeBlocks = listOf(
+ StepQuizCodeBlanksViewState.CodeBlockItem.Print(
+ id = 0,
+ isActive = false,
+ output = printSuggestions[0].text
+ ),
+ StepQuizCodeBlanksViewState.CodeBlockItem.Blank(id = 1, isActive = true)
+ ),
+ suggestions = listOf(Suggestion.Print),
+ isDeleteButtonVisible = false
+ )
+
+ 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(
+ isActive = false,
+ selectedSuggestion = printSuggestions[0],
+ suggestions = printSuggestions
+ ),
+ CodeBlock.Print(
+ isActive = true,
+ selectedSuggestion = null,
+ suggestions = printSuggestions
+ )
+ )
+ )
+ val expectedViewState = StepQuizCodeBlanksViewState.Content(
+ codeBlocks = listOf(
+ StepQuizCodeBlanksViewState.CodeBlockItem.Print(
+ id = 0,
+ isActive = false,
+ output = printSuggestions[0].text
+ ),
+ StepQuizCodeBlanksViewState.CodeBlockItem.Print(id = 1, isActive = true, output = null)
+ ),
+ suggestions = printSuggestions,
+ isDeleteButtonVisible = true
+ )
+
+ val actualViewState = StepQuizCodeBlanksViewStateMapper.map(state)
+
+ assertEquals(expectedViewState, actualViewState)
+ }
+
+ private fun stubState(codeBlocks: List): StepQuizCodeBlanksFeature.State.Content =
+ StepQuizCodeBlanksFeature.State.Content(
+ step = Step.stub(id = 0),
+ codeBlocks = codeBlocks
+ )
+}
\ No newline at end of file