From 086c0d866d6ce9095bc7fcb7b4fda1edc7f1a06e Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Tue, 23 Jul 2024 20:52:07 +0700 Subject: [PATCH] Shared, iOS: Code blanks problem type first iteration (#1109) ^ALTAPPS-1299 --- .../view/fragment/DefaultStepQuizFragment.kt | 3 + config/detekt/baseline.xml | 5 + .../project.pbxproj | 64 ++++ .../Shared/Model/BlockOptionsExtensions.swift | 6 +- ...pQuizCodeBlanksViewStateKsExtensions.swift | 17 + .../Modules/StepQuiz/StepQuizViewModel.swift | 39 ++- .../Modules/StepQuiz/Views/StepQuizView.swift | 15 +- .../StepQuizCodeBlanksAssembly.swift | 25 ++ .../StepQuizCodeBlanksOutputProtocol.swift | 8 + .../StepQuizCodeBlanksViewModel.swift | 27 ++ .../Views/StepQuizCodeBlanksBlankView.swift | 48 +++ .../Views/StepQuizCodeBlanksOptionView.swift | 37 ++ ...epQuizCodeBlanksPrintInstructionView.swift | 48 +++ .../StepQuizCodeBlanksSuggestionsView.swift | 36 ++ .../Views/StepQuizCodeBlanksView.swift | 183 ++++++++++ .../Views/SwiftUI/Layouts/FlowLayout.swift | 74 ++++ .../step_quiz/AndroidStepQuizTest.kt | 11 +- .../hyperskill/HyperskillAnalyticPart.kt | 6 +- .../hyperskill/HyperskillAnalyticTarget.kt | 4 +- .../hyperskill/app/core/injection/AppGraph.kt | 2 + .../app/core/injection/BaseAppGraph.kt | 5 + .../hyperskill/app/step/domain/model/Block.kt | 4 +- .../injection/StepQuizComponentImpl.kt | 8 +- .../injection/StepQuizFeatureBuilder.kt | 17 +- .../StepQuizChildFeatureReducer.kt | 29 +- .../step_quiz/presentation/StepQuizFeature.kt | 12 +- .../step_quiz/presentation/StepQuizReducer.kt | 73 ++-- .../StepQuizCodeBlanksAnalyticParams.kt | 6 + ...ClickedCodeBlockHyperskillAnalyticEvent.kt | 41 +++ ...nksClickedDeleteHyperskillAnalyticEvent.kt | 41 +++ ...lickedSuggestionHyperskillAnalyticEvent.kt | 45 +++ .../domain/model/CodeBlock.kt | 39 +++ .../domain/model/Suggestion.kt | 21 ++ .../injection/StepQuizCodeBlanksComponent.kt | 9 + .../StepQuizCodeBlanksComponentImpl.kt | 21 ++ .../StepQuizCodeBlanksActionDispatcher.kt | 20 ++ .../presentation/StepQuizCodeBlanksFeature.kt | 46 +++ .../presentation/StepQuizCodeBlanksReducer.kt | 178 ++++++++++ .../StepQuizCodeBlanksStateExtensions.kt | 12 + .../StepQuizCodeBlanksViewStateMapper.kt | 65 ++++ .../view/model/StepQuizCodeBlanksViewState.kt | 29 ++ .../org/hyperskill/step_quiz/StepQuizTest.kt | 181 ++++++++-- .../StepQuizChildFeatureReducerStub.kt | 4 +- .../StepQuizCodeBlanksReducerTest.kt | 323 ++++++++++++++++++ .../StepQuizCodeBlanksStateExtensionsTest.kt | 82 +++++ .../StepQuizCodeBlanksViewStateMapperTest.kt | 142 ++++++++ 46 files changed, 2051 insertions(+), 60 deletions(-) create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/StepQuizCodeBlanksViewStateKsExtensions.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksAssembly.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksOutputProtocol.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/StepQuizCodeBlanksViewModel.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksBlankView.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksOptionView.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksPrintInstructionView.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksSuggestionsView.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCodeBlanks/Views/StepQuizCodeBlanksView.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/Layouts/FlowLayout.swift create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksAnalyticParams.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedCodeBlockHyperskillAnalyticEvent.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedDeleteHyperskillAnalyticEvent.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/analytic/StepQuizCodeBlanksClickedSuggestionHyperskillAnalyticEvent.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/CodeBlock.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/domain/model/Suggestion.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/injection/StepQuizCodeBlanksComponent.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/injection/StepQuizCodeBlanksComponentImpl.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksActionDispatcher.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksFeature.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksReducer.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/presentation/StepQuizCodeBlanksStateExtensions.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/view/mapper/StepQuizCodeBlanksViewStateMapper.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_code_blanks/view/model/StepQuizCodeBlanksViewState.kt create mode 100644 shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksReducerTest.kt create mode 100644 shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksStateExtensionsTest.kt create mode 100644 shared/src/commonTest/kotlin/org/hyperskill/step_quiz_code_blanks/StepQuizCodeBlanksViewStateMapperTest.kt 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