diff --git a/androidHyperskillApp/build.gradle.kts b/androidHyperskillApp/build.gradle.kts index 0ceca04de4..1ec437f7f1 100644 --- a/androidHyperskillApp/build.gradle.kts +++ b/androidHyperskillApp/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(libs.android.ui.appcompat) implementation(libs.android.ui.constraintlayout) implementation(libs.android.ui.swiperefreshlayout) + implementation(libs.android.ui.flexbox) implementation(libs.android.ui.core.ktx) implementation(libs.android.ui.fragment) implementation(libs.android.ui.fragment.ktx) diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/factory/StepQuizFragmentFactory.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/factory/StepQuizFragmentFactory.kt index 3f6ab457fe..834c94323e 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/factory/StepQuizFragmentFactory.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/factory/StepQuizFragmentFactory.kt @@ -3,6 +3,7 @@ package org.hyperskill.app.android.step_quiz.view.factory import androidx.fragment.app.Fragment import org.hyperskill.app.android.step_quiz_choice.view.fragment.ChoiceStepQuizFragment import org.hyperskill.app.android.step_quiz_code.view.fragment.CodeStepQuizFragment +import org.hyperskill.app.android.step_quiz_fill_blanks.fragment.FillBlanksStepQuizFragment import org.hyperskill.app.android.step_quiz_matching.view.fragment.MatchingStepQuizFragment import org.hyperskill.app.android.step_quiz_parsons.view.fragment.ParsonsStepQuizFragment import org.hyperskill.app.android.step_quiz_sorting.view.fragment.SortingStepQuizFragment @@ -42,6 +43,9 @@ object StepQuizFragmentFactory { BlockName.PARSONS -> ParsonsStepQuizFragment.newInstance(step, stepRoute) + BlockName.FILL_BLANKS -> + FillBlanksStepQuizFragment.newInstance(step, stepRoute) + else -> UnsupportedStepQuizFragment.newInstance() } diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz_fill_blanks/delegate/FillBlanksStepQuizFormDelegate.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz_fill_blanks/delegate/FillBlanksStepQuizFormDelegate.kt new file mode 100644 index 0000000000..c22f5c9bd0 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz_fill_blanks/delegate/FillBlanksStepQuizFormDelegate.kt @@ -0,0 +1,151 @@ +package org.hyperskill.app.android.step_quiz_fill_blanks.delegate + +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.text.HtmlCompat +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.flexbox.FlexboxItemDecoration +import com.google.android.flexbox.FlexboxLayoutManager +import org.hyperskill.app.android.R +import org.hyperskill.app.android.databinding.LayoutStepQuizFillBlanksBindingBinding +import org.hyperskill.app.android.step_quiz.view.delegate.StepQuizFormDelegate +import org.hyperskill.app.android.step_quiz_fill_blanks.dialog.FillBlanksInputDialogFragment +import org.hyperskill.app.step_quiz.domain.model.submissions.Reply +import org.hyperskill.app.step_quiz.presentation.StepQuizFeature +import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksItem +import org.hyperskill.app.step_quiz_fill_blanks.model.InvalidFillBlanksConfigException +import org.hyperskill.app.step_quiz_fill_blanks.presentation.FillBlanksItemMapper +import org.hyperskill.app.step_quiz_fill_blanks.presentation.FillBlanksResolver +import ru.nobird.android.ui.adapterdelegates.dsl.adapterDelegate +import ru.nobird.android.ui.adapters.DefaultDelegateAdapter +import ru.nobird.android.view.base.ui.extension.setTextIfChanged +import ru.nobird.android.view.base.ui.extension.showIfNotExists +import ru.nobird.app.core.model.mutate + +class FillBlanksStepQuizFormDelegate( + private val binding: LayoutStepQuizFillBlanksBindingBinding, + private val fragmentManager: FragmentManager, + private val onQuizChanged: (Reply) -> Unit +) : StepQuizFormDelegate { + + private val fillBlanksAdapter = DefaultDelegateAdapter().apply { + addDelegate(textAdapterDelegate()) + addDelegate( + inputAdapterDelegate(::onInputItemClick) + ) + } + + private val fillBlanksMapper: FillBlanksItemMapper = FillBlanksItemMapper() + + private var resolveState: ResolveState = ResolveState.NOT_RESOLVED + + init { + with(binding.stepQuizFillBlanksRecycler) { + itemAnimator = null + adapter = fillBlanksAdapter + isNestedScrollingEnabled = false + layoutManager = FlexboxLayoutManager(context) + addItemDecoration( + FlexboxItemDecoration(context).apply { + setOrientation(FlexboxItemDecoration.HORIZONTAL) + setDrawable( + ContextCompat.getDrawable(context, R.drawable.bg_step_quiz_fill_blanks_item_vertical_divider) + ) + } + ) + } + } + + override fun setState(state: StepQuizFeature.StepQuizState.AttemptLoaded) { + val resolveState = resolve(resolveState, state) + this.resolveState = resolveState + if (resolveState == ResolveState.RESOLVE_SUCCEED) { + val fillBlanksData = fillBlanksMapper.map( + state.attempt, + (state.submissionState as? StepQuizFeature.SubmissionState.Loaded)?.submission + ) + fillBlanksAdapter.items = fillBlanksData?.fillBlanks ?: emptyList() + binding.root.post { binding.stepQuizFillBlanksRecycler.requestLayout() } + } + } + + private fun resolve( + currentResolveState: ResolveState, + state: StepQuizFeature.StepQuizState.AttemptLoaded + ): ResolveState = + if (currentResolveState == ResolveState.NOT_RESOLVED) { + val dataset = state.attempt.dataset + if (dataset != null) { + try { + FillBlanksResolver.resolve(dataset) + ResolveState.RESOLVE_SUCCEED + } catch (e: InvalidFillBlanksConfigException) { + ResolveState.RESOLVE_FAILED + } + } else { + ResolveState.RESOLVE_FAILED + } + } else { + currentResolveState + } + + override fun createReply(): Reply = + Reply.fillBlanks( + blanks = fillBlanksAdapter.items.mapNotNull { item -> + when (item) { + is FillBlanksItem.Input -> item.inputText + is FillBlanksItem.Text -> null + } + } + ) + + fun onInputItemModified(index: Int, text: String) { + fillBlanksAdapter.items = fillBlanksAdapter.items.mutate { + val inputItem = get(index) as FillBlanksItem.Input + set(index, inputItem.copy(inputText = text)) + } + fillBlanksAdapter.notifyItemChanged(index) + onQuizChanged(createReply()) + } + + private fun textAdapterDelegate() = + adapterDelegate(R.layout.item_step_quiz_fill_blanks_text) { + val textView = itemView as TextView + onBind { textItem -> + textView.updateLayoutParams { + isWrapBefore = textItem.startsWithNewLine + } + textView.setTextIfChanged( + HtmlCompat.fromHtml(textItem.text, HtmlCompat.FROM_HTML_MODE_COMPACT) + ) + } + } + + private fun inputAdapterDelegate(onClick: (index: Int, String) -> Unit) = + adapterDelegate(R.layout.item_step_quiz_fill_blanks_input) { + val textView = itemView as TextView + textView.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + onClick(position, textView.text.toString()) + } + } + onBind { inputItem -> + textView.setTextIfChanged(inputItem.inputText ?: "") + } + } + + private fun onInputItemClick(index: Int, text: String) { + FillBlanksInputDialogFragment + .newInstance(index, text) + .showIfNotExists(fragmentManager, FillBlanksInputDialogFragment.TAG) + } + + private enum class ResolveState { + NOT_RESOLVED, + RESOLVE_SUCCEED, + RESOLVE_FAILED + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz_fill_blanks/dialog/FillBlanksInputDialogFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz_fill_blanks/dialog/FillBlanksInputDialogFragment.kt new file mode 100644 index 0000000000..939e4f019a --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz_fill_blanks/dialog/FillBlanksInputDialogFragment.kt @@ -0,0 +1,94 @@ +package org.hyperskill.app.android.step_quiz_fill_blanks.dialog + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import by.kirich1409.viewbindingdelegate.viewBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.hyperskill.app.android.R +import org.hyperskill.app.android.databinding.FragmentFillBlanksInputBinding +import ru.nobird.android.view.base.ui.extension.argument + +class FillBlanksInputDialogFragment : BottomSheetDialogFragment() { + companion object { + const val TAG: String = "FillBlanksInputDialogFragment" + + private const val ARG_INDEX = "INDEX" + private const val ARG_TEXT = "TEXT" + + fun newInstance( + index: Int, + text: String + ): FillBlanksInputDialogFragment = + FillBlanksInputDialogFragment().apply { + this.index = index + this.text = text + } + } + + private var index: Int by argument() + private var text: String by argument() + + private val fillBlanksInputBinding: FragmentFillBlanksInputBinding by viewBinding( + FragmentFillBlanksInputBinding::bind + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, R.style.TopCornersRoundedBottomSheetDialog) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = + inflater.inflate(R.layout.fragment_fill_blanks_input, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (savedInstanceState != null) { + index = savedInstanceState.getInt(ARG_INDEX) + text = savedInstanceState.getString(ARG_TEXT) ?: return + } + with(fillBlanksInputBinding) { + fillBlanksInputField.append(text) + fillBlanksInputField.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + super.dismiss() + } + false + } + fillBlanksInputField.post { + fillBlanksInputField.requestFocus() + val inputMethodManager = + requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.showSoftInput(fillBlanksInputField, InputMethodManager.SHOW_IMPLICIT) + } + } + } + + override fun onPause() { + (parentFragment as? Callback) + ?.onSyncInputItemWithParent( + index = index, + text = fillBlanksInputBinding.fillBlanksInputField.text.toString() + ) + super.onPause() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(ARG_INDEX, index) + outState.putString(ARG_TEXT, text) + } + + interface Callback { + fun onSyncInputItemWithParent(index: Int, text: String) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz_fill_blanks/fragment/FillBlanksStepQuizFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz_fill_blanks/fragment/FillBlanksStepQuizFragment.kt new file mode 100644 index 0000000000..55a0bb3e54 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz_fill_blanks/fragment/FillBlanksStepQuizFragment.kt @@ -0,0 +1,67 @@ +package org.hyperskill.app.android.step_quiz_fill_blanks.fragment + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.hyperskill.app.android.databinding.LayoutStepQuizDescriptionBinding +import org.hyperskill.app.android.databinding.LayoutStepQuizFillBlanksBindingBinding +import org.hyperskill.app.android.step_quiz.view.delegate.StepQuizFormDelegate +import org.hyperskill.app.android.step_quiz.view.fragment.DefaultStepQuizFragment +import org.hyperskill.app.android.step_quiz_fill_blanks.delegate.FillBlanksStepQuizFormDelegate +import org.hyperskill.app.android.step_quiz_fill_blanks.dialog.FillBlanksInputDialogFragment +import org.hyperskill.app.step.domain.model.Step +import org.hyperskill.app.step.domain.model.StepRoute + +class FillBlanksStepQuizFragment : + DefaultStepQuizFragment(), + FillBlanksInputDialogFragment.Callback { + + companion object { + fun newInstance( + step: Step, + stepRoute: StepRoute + ): FillBlanksStepQuizFragment = + FillBlanksStepQuizFragment().apply { + this.step = step + this.stepRoute = stepRoute + } + } + + private var _binding: LayoutStepQuizFillBlanksBindingBinding? = null + private val binding: LayoutStepQuizFillBlanksBindingBinding + get() = requireNotNull(_binding) + + private var fillBlanksStepQuizFormDelegate: FillBlanksStepQuizFormDelegate? = null + + override val quizViews: Array + get() = arrayOf(binding.stepQuizFillBlanksContainer) + override val skeletonView: View + get() = binding.stepQuizFillBlanksSkeleton.root + + override val descriptionBinding: LayoutStepQuizDescriptionBinding? = null + + override fun createStepView(layoutInflater: LayoutInflater, parent: ViewGroup): View { + val binding = LayoutStepQuizFillBlanksBindingBinding.inflate(layoutInflater, parent, false) + this._binding = binding + return binding.root + } + + override fun createStepQuizFormDelegate(): StepQuizFormDelegate = + FillBlanksStepQuizFormDelegate( + binding = binding, + fragmentManager = childFragmentManager, + onQuizChanged = ::syncReplyState + ).also { + this.fillBlanksStepQuizFormDelegate = it + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + fillBlanksStepQuizFormDelegate = null + } + + override fun onSyncInputItemWithParent(index: Int, text: String) { + fillBlanksStepQuizFormDelegate?.onInputItemModified(index, text) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/drawable/bg_step_quiz_fill_blanks_input_item.xml b/androidHyperskillApp/src/main/res/drawable/bg_step_quiz_fill_blanks_input_item.xml new file mode 100644 index 0000000000..9f3fb9c50e --- /dev/null +++ b/androidHyperskillApp/src/main/res/drawable/bg_step_quiz_fill_blanks_input_item.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/drawable/bg_step_quiz_fill_blanks_input_text_field.xml b/androidHyperskillApp/src/main/res/drawable/bg_step_quiz_fill_blanks_input_text_field.xml new file mode 100644 index 0000000000..f5768edba4 --- /dev/null +++ b/androidHyperskillApp/src/main/res/drawable/bg_step_quiz_fill_blanks_input_text_field.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/drawable/bg_step_quiz_fill_blanks_item_vertical_divider.xml b/androidHyperskillApp/src/main/res/drawable/bg_step_quiz_fill_blanks_item_vertical_divider.xml new file mode 100644 index 0000000000..cc488685a6 --- /dev/null +++ b/androidHyperskillApp/src/main/res/drawable/bg_step_quiz_fill_blanks_item_vertical_divider.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/font/menlo.xml b/androidHyperskillApp/src/main/res/font/menlo.xml new file mode 100644 index 0000000000..a577f87aaa --- /dev/null +++ b/androidHyperskillApp/src/main/res/font/menlo.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/layout/fragment_fill_blanks_input.xml b/androidHyperskillApp/src/main/res/layout/fragment_fill_blanks_input.xml new file mode 100644 index 0000000000..b77591193d --- /dev/null +++ b/androidHyperskillApp/src/main/res/layout/fragment_fill_blanks_input.xml @@ -0,0 +1,31 @@ + + + + + + \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/layout/item_step_quiz_fill_blanks_input.xml b/androidHyperskillApp/src/main/res/layout/item_step_quiz_fill_blanks_input.xml new file mode 100644 index 0000000000..9e7be03992 --- /dev/null +++ b/androidHyperskillApp/src/main/res/layout/item_step_quiz_fill_blanks_input.xml @@ -0,0 +1,22 @@ + + \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/layout/item_step_quiz_fill_blanks_text.xml b/androidHyperskillApp/src/main/res/layout/item_step_quiz_fill_blanks_text.xml new file mode 100644 index 0000000000..8026701a4d --- /dev/null +++ b/androidHyperskillApp/src/main/res/layout/item_step_quiz_fill_blanks_text.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/layout/layout_fill_blanks_skeleton.xml b/androidHyperskillApp/src/main/res/layout/layout_fill_blanks_skeleton.xml new file mode 100644 index 0000000000..4105e8f62a --- /dev/null +++ b/androidHyperskillApp/src/main/res/layout/layout_fill_blanks_skeleton.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/layout/layout_step_quiz_fill_blanks_binding.xml b/androidHyperskillApp/src/main/res/layout/layout_step_quiz_fill_blanks_binding.xml new file mode 100644 index 0000000000..94602061e9 --- /dev/null +++ b/androidHyperskillApp/src/main/res/layout/layout_step_quiz_fill_blanks_binding.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/values/dimens.xml b/androidHyperskillApp/src/main/res/values/dimens.xml index 0e1dadfa50..20cd0b0a7c 100644 --- a/androidHyperskillApp/src/main/res/values/dimens.xml +++ b/androidHyperskillApp/src/main/res/values/dimens.xml @@ -85,6 +85,18 @@ 8dp + + 8dp + 32dp + 48dp + 180dp + 8dp + 16dp + 8dp + 56dp + 14sp + 20dp + 48dp 45dp diff --git a/androidHyperskillApp/src/main/res/values/type.xml b/androidHyperskillApp/src/main/res/values/type.xml index 71133cc460..e079c94e0e 100644 --- a/androidHyperskillApp/src/main/res/values/type.xml +++ b/androidHyperskillApp/src/main/res/values/type.xml @@ -68,4 +68,11 @@ false + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0ca286d169..0e87470862 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ android-ui-material = { module = "com.google.android.material:material", version android-ui-appcompat = { module = "androidx.appcompat:appcompat", version = "1.4.0" } android-ui-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version = "2.1.4" } android-ui-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version = "1.1.0" } +android-ui-flexbox = { module = "com.google.android.flexbox:flexbox", version = "3.0.0" } android-ui-core-ktx = { module = "androidx.core:core-ktx", version = "1.3.0" } android-ui-fragment = { module = "androidx.fragment:fragment", version.ref = "fragment" } android-ui-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment" } diff --git a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj index 99b9415f02..5da0d9b2fb 100644 --- a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj +++ b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj @@ -30,6 +30,9 @@ 2C05AC622A0EDFC20039C7EF /* ProjectSelectionListGridCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C05AC612A0EDFC20039C7EF /* ProjectSelectionListGridCellView.swift */; }; 2C05AC642A0EDFD80039C7EF /* ProjectSelectionListGridCellBadgesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C05AC632A0EDFD80039C7EF /* ProjectSelectionListGridCellBadgesView.swift */; }; 2C069EB128F03782009A3DA1 /* AnalyticExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C069EB028F03782009A3DA1 /* AnalyticExtensions.swift */; }; + 2C078CE52AE26CB400D97E24 /* FillBlanksQuizTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C078CE42AE26CB400D97E24 /* FillBlanksQuizTitleView.swift */; }; + 2C078CE72AE26E2000D97E24 /* UIKitSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C078CE62AE26E2000D97E24 /* UIKitSeparatorView.swift */; }; + 2C078CE92AE29D0600D97E24 /* StepQuizFillBlanksViewDataMapperCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C078CE82AE29D0600D97E24 /* StepQuizFillBlanksViewDataMapperCache.swift */; }; 2C079681285CEEFE00EE0487 /* StepQuizMatchingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C079680285CEEFE00EE0487 /* StepQuizMatchingViewModel.swift */; }; 2C079683285CEF0900EE0487 /* StepQuizMatchingAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C079682285CEF0900EE0487 /* StepQuizMatchingAssembly.swift */; }; 2C079685285CFFEE00EE0487 /* StepQuizSortingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C079684285CFFEE00EE0487 /* StepQuizSortingViewModel.swift */; }; @@ -198,6 +201,7 @@ 2C5CBBE52948FA7400113007 /* StepQuizSQLAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5CBBE42948FA7400113007 /* StepQuizSQLAssembly.swift */; }; 2C5CBBE72948FC7A00113007 /* StepQuizSQLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5CBBE62948FC7A00113007 /* StepQuizSQLView.swift */; }; 2C5EC2C82AC41CAF0098D126 /* StepQuizCodeEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5EC2C72AC41CAF0098D126 /* StepQuizCodeEditorView.swift */; }; + 2C5F19152AE667C90039414D /* LeftAlignedCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5F19142AE667C90039414D /* LeftAlignedCollectionViewFlowLayout.swift */; }; 2C5F4A5A2971C71200677530 /* GamificationToolbarContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5F4A592971C71200677530 /* GamificationToolbarContent.swift */; }; 2C62AD582AB43A8F00F3DD5B /* BadgeRankView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C62AD572AB43A8F00F3DD5B /* BadgeRankView.swift */; }; 2C6672062A527C0D0040EA2F /* ProgressScreenSectionTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6672052A527C0D0040EA2F /* ProgressScreenSectionTitleView.swift */; }; @@ -225,6 +229,18 @@ 2C7994AF2A1299B800874C16 /* TrackSelectionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7994AE2A1299B800874C16 /* TrackSelectionListView.swift */; }; 2C7994B12A129D6100874C16 /* TrackSelectionListSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7994B02A129D6100874C16 /* TrackSelectionListSkeletonView.swift */; }; 2C7A1B1F2922EB070018D72C /* Hyperskill-Mobile_shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7A1B1E2922EB070018D72C /* Hyperskill-Mobile_shared.swift */; settings = {COMPILER_FLAGS = "-w -Xanalyzer -analyzer-disable-all-checks"; }; }; + 2C7CB66B2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB66A2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift */; }; + 2C7CB66D2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB66C2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift */; }; + 2C7CB66F2ADFB96F006F78DA /* StepQuizFillBlanksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB66E2ADFB96F006F78DA /* StepQuizFillBlanksView.swift */; }; + 2C7CB6762ADFCFCC006F78DA /* StepQuizFillBlanksViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB6752ADFCFCC006F78DA /* StepQuizFillBlanksViewData.swift */; }; + 2C7CB6782ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB6772ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift */; }; + 2C7CB67B2ADFD9B4006F78DA /* FillBlanksTextCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB67A2ADFD9B4006F78DA /* FillBlanksTextCollectionViewCell.swift */; }; + 2C7CB67E2ADFDA62006F78DA /* FillBlanksQuizInputContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB67D2ADFDA62006F78DA /* FillBlanksQuizInputContainerView.swift */; }; + 2C7CB6802ADFDADD006F78DA /* FillBlanksInputCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB67F2ADFDADD006F78DA /* FillBlanksInputCollectionViewCell.swift */; }; + 2C7CB6822ADFDB45006F78DA /* UIFont+SizeOfString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB6812ADFDB45006F78DA /* UIFont+SizeOfString.swift */; }; + 2C7CB6842ADFF1A6006F78DA /* FillBlanksQuizCollectionViewAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB6832ADFF1A6006F78DA /* FillBlanksQuizCollectionViewAdapter.swift */; }; + 2C7CB6862ADFF389006F78DA /* FillBlanksQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB6852ADFF389006F78DA /* FillBlanksQuizView.swift */; }; + 2C7CB6882ADFF57D006F78DA /* FillBlanksQuizViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB6872ADFF57D006F78DA /* FillBlanksQuizViewWrapper.swift */; }; 2C80D4FD288C4D0D00B2CD1E /* StepQuizCodeFullScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C80D4FC288C4D0D00B2CD1E /* StepQuizCodeFullScreenViewModel.swift */; }; 2C80D4FF288C4D4400B2CD1E /* StepQuizCodeFullScreenOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C80D4FE288C4D4400B2CD1E /* StepQuizCodeFullScreenOutputProtocol.swift */; }; 2C80D503288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C80D502288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift */; }; @@ -631,6 +647,9 @@ 2C05AC612A0EDFC20039C7EF /* ProjectSelectionListGridCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSelectionListGridCellView.swift; sourceTree = ""; }; 2C05AC632A0EDFD80039C7EF /* ProjectSelectionListGridCellBadgesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSelectionListGridCellBadgesView.swift; sourceTree = ""; }; 2C069EB028F03782009A3DA1 /* AnalyticExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticExtensions.swift; sourceTree = ""; }; + 2C078CE42AE26CB400D97E24 /* FillBlanksQuizTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizTitleView.swift; sourceTree = ""; }; + 2C078CE62AE26E2000D97E24 /* UIKitSeparatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitSeparatorView.swift; sourceTree = ""; }; + 2C078CE82AE29D0600D97E24 /* StepQuizFillBlanksViewDataMapperCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksViewDataMapperCache.swift; sourceTree = ""; }; 2C079680285CEEFE00EE0487 /* StepQuizMatchingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizMatchingViewModel.swift; sourceTree = ""; }; 2C079682285CEF0900EE0487 /* StepQuizMatchingAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizMatchingAssembly.swift; sourceTree = ""; }; 2C079684285CFFEE00EE0487 /* StepQuizSortingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizSortingViewModel.swift; sourceTree = ""; }; @@ -806,6 +825,7 @@ 2C5CBBE42948FA7400113007 /* StepQuizSQLAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizSQLAssembly.swift; sourceTree = ""; }; 2C5CBBE62948FC7A00113007 /* StepQuizSQLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizSQLView.swift; sourceTree = ""; }; 2C5EC2C72AC41CAF0098D126 /* StepQuizCodeEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeEditorView.swift; sourceTree = ""; }; + 2C5F19142AE667C90039414D /* LeftAlignedCollectionViewFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedCollectionViewFlowLayout.swift; sourceTree = ""; }; 2C5F4A592971C71200677530 /* GamificationToolbarContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamificationToolbarContent.swift; sourceTree = ""; }; 2C62AD572AB43A8F00F3DD5B /* BadgeRankView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeRankView.swift; sourceTree = ""; }; 2C6672052A527C0D0040EA2F /* ProgressScreenSectionTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressScreenSectionTitleView.swift; sourceTree = ""; }; @@ -833,6 +853,18 @@ 2C7994AE2A1299B800874C16 /* TrackSelectionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackSelectionListView.swift; sourceTree = ""; }; 2C7994B02A129D6100874C16 /* TrackSelectionListSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackSelectionListSkeletonView.swift; sourceTree = ""; }; 2C7A1B1E2922EB070018D72C /* Hyperskill-Mobile_shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Hyperskill-Mobile_shared.swift"; sourceTree = ""; }; + 2C7CB66A2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksAssembly.swift; sourceTree = ""; }; + 2C7CB66C2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksViewModel.swift; sourceTree = ""; }; + 2C7CB66E2ADFB96F006F78DA /* StepQuizFillBlanksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksView.swift; sourceTree = ""; }; + 2C7CB6752ADFCFCC006F78DA /* StepQuizFillBlanksViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksViewData.swift; sourceTree = ""; }; + 2C7CB6772ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksViewDataMapper.swift; sourceTree = ""; }; + 2C7CB67A2ADFD9B4006F78DA /* FillBlanksTextCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksTextCollectionViewCell.swift; sourceTree = ""; }; + 2C7CB67D2ADFDA62006F78DA /* FillBlanksQuizInputContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizInputContainerView.swift; sourceTree = ""; }; + 2C7CB67F2ADFDADD006F78DA /* FillBlanksInputCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksInputCollectionViewCell.swift; sourceTree = ""; }; + 2C7CB6812ADFDB45006F78DA /* UIFont+SizeOfString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+SizeOfString.swift"; sourceTree = ""; }; + 2C7CB6832ADFF1A6006F78DA /* FillBlanksQuizCollectionViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizCollectionViewAdapter.swift; sourceTree = ""; }; + 2C7CB6852ADFF389006F78DA /* FillBlanksQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizView.swift; sourceTree = ""; }; + 2C7CB6872ADFF57D006F78DA /* FillBlanksQuizViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizViewWrapper.swift; sourceTree = ""; }; 2C80D4FC288C4D0D00B2CD1E /* StepQuizCodeFullScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenViewModel.swift; sourceTree = ""; }; 2C80D4FE288C4D4400B2CD1E /* StepQuizCodeFullScreenOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenOutputProtocol.swift; sourceTree = ""; }; 2C80D502288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeNavigationState.swift; sourceTree = ""; }; @@ -2105,6 +2137,14 @@ path = StepQuizSQL; sourceTree = ""; }; + 2C5F19162AE6857F0039414D /* CollectionViewLayouts */ = { + isa = PBXGroup; + children = ( + 2C5F19142AE667C90039414D /* LeftAlignedCollectionViewFlowLayout.swift */, + ); + path = CollectionViewLayouts; + sourceTree = ""; + }; 2C5F4A582971C6C500677530 /* GamificationToolbar */ = { isa = PBXGroup; children = ( @@ -2212,6 +2252,58 @@ path = sharedSwift; sourceTree = ""; }; + 2C7CB6692ADFB91C006F78DA /* StepQuizFillBlanks */ = { + isa = PBXGroup; + children = ( + 2C7CB66A2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift */, + 2C7CB66C2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift */, + 2C7CB6742ADFCB1E006F78DA /* ViewData */, + 2C7CB6702ADFB985006F78DA /* Views */, + ); + path = StepQuizFillBlanks; + sourceTree = ""; + }; + 2C7CB6702ADFB985006F78DA /* Views */ = { + isa = PBXGroup; + children = ( + 2C7CB6872ADFF57D006F78DA /* FillBlanksQuizViewWrapper.swift */, + 2C7CB66E2ADFB96F006F78DA /* StepQuizFillBlanksView.swift */, + 2C7CB6792ADFD985006F78DA /* UIKit */, + ); + path = Views; + sourceTree = ""; + }; + 2C7CB6742ADFCB1E006F78DA /* ViewData */ = { + isa = PBXGroup; + children = ( + 2C7CB6752ADFCFCC006F78DA /* StepQuizFillBlanksViewData.swift */, + 2C7CB6772ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift */, + 2C078CE82AE29D0600D97E24 /* StepQuizFillBlanksViewDataMapperCache.swift */, + ); + path = ViewData; + sourceTree = ""; + }; + 2C7CB6792ADFD985006F78DA /* UIKit */ = { + isa = PBXGroup; + children = ( + 2C7CB6832ADFF1A6006F78DA /* FillBlanksQuizCollectionViewAdapter.swift */, + 2C7CB67D2ADFDA62006F78DA /* FillBlanksQuizInputContainerView.swift */, + 2C078CE42AE26CB400D97E24 /* FillBlanksQuizTitleView.swift */, + 2C7CB6852ADFF389006F78DA /* FillBlanksQuizView.swift */, + 2C7CB67C2ADFD9BF006F78DA /* Cells */, + ); + path = UIKit; + sourceTree = ""; + }; + 2C7CB67C2ADFD9BF006F78DA /* Cells */ = { + isa = PBXGroup; + children = ( + 2C7CB67F2ADFDADD006F78DA /* FillBlanksInputCollectionViewCell.swift */, + 2C7CB67A2ADFD9B4006F78DA /* FillBlanksTextCollectionViewCell.swift */, + ); + path = Cells; + sourceTree = ""; + }; 2C82BA302844AFED004C9013 /* PlaceholderView */ = { isa = PBXGroup; children = ( @@ -2483,6 +2575,7 @@ 2C5B2A24286596A80097B270 /* UICollectionView+RegisterReusable.swift */, 2C58DE282803D197002A2774 /* UIColor+DynamicColor.swift */, 2C20FBAF284F1D8B006D879E /* UIColor+Hex.swift */, + 2C7CB6812ADFDB45006F78DA /* UIFont+SizeOfString.swift */, 2CDF14D728EF1E080060D972 /* UINavigationControllerExtensions.swift */, 2C5B2A22286596400097B270 /* UITableView+RegisterReusable.swift */, 2CC78D0D28C75A3D0006EF91 /* UIViewControllerExtensions.swift */, @@ -2691,6 +2784,8 @@ 2CBD1918291D399500F5FB0B /* UIKitBounceButton.swift */, 2CE1E188292CCB450041FE14 /* UIKitIntrospectionView.swift */, 2CBD191C291D3BF400F5FB0B /* UIKitRoundedRectangleButton.swift */, + 2C078CE62AE26E2000D97E24 /* UIKitSeparatorView.swift */, + 2C5F19162AE6857F0039414D /* CollectionViewLayouts */, ); path = UIKit; sourceTree = ""; @@ -2937,6 +3032,7 @@ E9802D03281BB5A500CF3AC1 /* StepQuizChoice */, 2C96742C288823EB0091B6C9 /* StepQuizCode */, 2CBFB94828897D970044D1BA /* StepQuizCodeFullScreen */, + 2C7CB6692ADFB91C006F78DA /* StepQuizFillBlanks */, E9F27D7629064456007F16D7 /* StepQuizHints */, E9D2D66E284E0A5D000757AC /* StepQuizMatching */, E96D49382A9CCE9A00BD78FE /* StepQuizParsons */, @@ -3918,6 +4014,7 @@ 2CBC97D22A555F190078E445 /* StageImplementProjectCompletedModalViewController.swift in Sources */, E9950E9328893F1700C4D962 /* ProfileDailyStudyRemindersView.swift in Sources */, 2C8E66D52878771B00D3928D /* ProfilePresentationDescription.swift in Sources */, + 2C5F19152AE667C90039414D /* LeftAlignedCollectionViewFlowLayout.swift in Sources */, E993E9A928426FF2005988EC /* StepQuizSortingViewData.swift in Sources */, E9F59B90289FE053001CEA02 /* ProfileSettingsViewModel.swift in Sources */, 2C967434288824450091B6C9 /* StepQuizCodeView.swift in Sources */, @@ -3989,6 +4086,7 @@ 2C0DB90A2864515B001EA35E /* CodeEditorViewDelegate.swift in Sources */, 2C336D132865C47900C91342 /* ApplicationTheme.swift in Sources */, E9AB310F29DECC7500645376 /* StudyPlanSectionHeaderStatisticsView.swift in Sources */, + 2C7CB66D2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift in Sources */, E99B21812887E535006A6154 /* StepQuizSkeletonViewFactory.swift in Sources */, E9B55A5D29C978E40066900E /* ProblemsLimitReachedModalViewController.swift in Sources */, 2CFD7C6A2925447600902748 /* StepQuizFeatureStateKsExtensions.swift in Sources */, @@ -4026,20 +4124,25 @@ 2C023C86285D927A00D2D5A9 /* StepQuizTableAssembly.swift in Sources */, 2C20FBC4284F67F3006D879E /* ProcessedContentWebView.swift in Sources */, 2C4F639B2A101DCE00D4EE39 /* ProjectSelectionListGridView.swift in Sources */, + 2C7CB6842ADFF1A6006F78DA /* FillBlanksQuizCollectionViewAdapter.swift in Sources */, 2CA5F8EC2994C3AB0013B854 /* DebugView.swift in Sources */, 2C005DCC27EF5B0300DC6503 /* GoogleServiceInfo.swift in Sources */, 2CBD191D291D3BF400F5FB0B /* UIKitRoundedRectangleButton.swift in Sources */, + 2C078CE92AE29D0600D97E24 /* StepQuizFillBlanksViewDataMapperCache.swift in Sources */, E9A6250F28ABAE83009423EE /* OnboardingViewModel.swift in Sources */, 2C0F3CFC2A80A47600947C35 /* BadgeDetailsModalView.swift in Sources */, E9F504D029128B5300B788C7 /* StepQuizHintsAssembly.swift in Sources */, E97BEA1E2977D26F00348EEC /* TopicCompletedModalViewController.swift in Sources */, 2CEEE03128916A3100282849 /* ProblemOfDayAssembly.swift in Sources */, + 2C7CB66F2ADFB96F006F78DA /* StepQuizFillBlanksView.swift in Sources */, 2C8E4FB628490C020011ADFA /* PanModalPresenter.swift in Sources */, 2C55133B28B8DFE8009F7627 /* Debouncer.swift in Sources */, + 2C7CB6882ADFF57D006F78DA /* FillBlanksQuizViewWrapper.swift in Sources */, E9A6250D28ABAE30009423EE /* OnboardingAssembly.swift in Sources */, 2C336D172865C9CD00C91342 /* UIView+TraitCollection.swift in Sources */, 2CD48D872858639500CFCC4A /* StepQuizViewModel.swift in Sources */, E9B55A5929C8A0760066900E /* ProblemsLimitView.swift in Sources */, + 2C7CB6862ADFF389006F78DA /* FillBlanksQuizView.swift in Sources */, E94D238D28057F440003273F /* AuthCredentialsView.swift in Sources */, 2CEEE03728917F1100282849 /* TimeIntervalExtensions.swift in Sources */, 2CE31F4827F1BB79008EEE66 /* AuthSocialAssembly.swift in Sources */, @@ -4047,6 +4150,7 @@ 2C5261A52993CBF100B4E8F6 /* TopicProgressExtensions.swift in Sources */, 2C0DB90728644F2C001EA35E /* CodeEditorView.swift in Sources */, 2CDA98452944590800ADE539 /* ProfileStatisticsView.swift in Sources */, + 2C7CB6822ADFDB45006F78DA /* UIFont+SizeOfString.swift in Sources */, 2CC4AAF1280DB513002276A0 /* WebOAuthService.swift in Sources */, 2CF2DA3A27EC5B2D0055426D /* Assembly.swift in Sources */, 2C27C77C28772F8A006A641A /* ImageDecoders+SVG.swift in Sources */, @@ -4086,6 +4190,7 @@ E9523BF029DA933C0013A661 /* StudyPlanViewModel.swift in Sources */, 2CCF3B5828004FC40075D12C /* UserAgentBuilder.swift in Sources */, 2C963BC52812D1A70036DD53 /* HomeView.swift in Sources */, + 2C078CE52AE26CB400D97E24 /* FillBlanksQuizTitleView.swift in Sources */, 2C93C2D8292EBBB5004D1861 /* AuthSocialFeatureStateKsExtensions.swift in Sources */, E9101713283296F3002E70F5 /* RadioButton.swift in Sources */, 2C68FD7C2ABC1FF700D9EBE2 /* NotificationsOnboardingContentView.swift in Sources */, @@ -4130,6 +4235,7 @@ 2C7994AD2A12940D00874C16 /* TrackSelectionListGridView.swift in Sources */, 2C20FBA8284F193A006D879E /* ContentProcessingRule.swift in Sources */, E9AB311429DED7FE00645376 /* StudyPlanSectionItemIconView.swift in Sources */, + 2C7CB66B2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift in Sources */, 2CBC97CA2A5553330078E445 /* StageImplementStageCompletedModalViewController.swift in Sources */, 2C20FBC9284F6F97006D879E /* UnitConverters.swift in Sources */, 2C336D152865C49D00C91342 /* ApplicationThemeService.swift in Sources */, @@ -4153,6 +4259,7 @@ 2CD48D892858657100CFCC4A /* StepQuizView.swift in Sources */, 2CD4148729A8D92000ACA855 /* CodeInputPasteControl.swift in Sources */, 2C8E4FA12848BF1F0011ADFA /* StepQuizTableSelectColumnsViewController.swift in Sources */, + 2C078CE72AE26E2000D97E24 /* UIKitSeparatorView.swift in Sources */, 2C1F5877280D2B4800372A37 /* ApplicationInfo.swift in Sources */, 2C5CA23A2A201A3900DBF2F9 /* ProjectSelectionDetailsLearningOutcomesView.swift in Sources */, 2C20FBBE284F658E006D879E /* ProcessedContentTextView.swift in Sources */, @@ -4163,6 +4270,7 @@ E94BB0442A9DEEFC00736B7C /* StepQuizParsonsSkeletonView.swift in Sources */, 2CB45764288ED6D4007C2D77 /* StepQuizActionButtonCodeQuizDelegate.swift in Sources */, 2C5B2A25286596A80097B270 /* UICollectionView+RegisterReusable.swift in Sources */, + 2C7CB6802ADFDADD006F78DA /* FillBlanksInputCollectionViewCell.swift in Sources */, 2CA542252ACAE18500EF24B5 /* IntrospectScrollView.swift in Sources */, E900D1052843573A00A77BBC /* StepQuizSortingIcon.swift in Sources */, 2CCCA39B2862E3BB00D98089 /* StepQuizStringDataType.swift in Sources */, @@ -4249,6 +4357,7 @@ 2C8E66D9287896CF00D3928D /* TabNavigationLazyView.swift in Sources */, 2CA8E095281039EB00154088 /* BounceButtonStyle.swift in Sources */, 2CEEE03328916A3D00282849 /* ProblemOfDayViewModel.swift in Sources */, + 2C7CB6762ADFCFCC006F78DA /* StepQuizFillBlanksViewData.swift in Sources */, 2C05AC5F2A0ED9710039C7EF /* BadgeView+ConcreateTypes.swift in Sources */, 2C1061AC285C3C4300EBD614 /* StepQuizChoiceViewModel.swift in Sources */, 2C0EB9502A151B56006DC84B /* TrackSelectionListViewModel.swift in Sources */, @@ -4285,9 +4394,11 @@ 2C1061A4285C34C900EBD614 /* StepQuizChildQuizOutputProtocol.swift in Sources */, 2C20FBA4284F165A006D879E /* ProcessedContent.swift in Sources */, 2C66720D2A5299C30040EA2F /* ProgressScreenCardSkeletonView.swift in Sources */, + 2C7CB67E2ADFDA62006F78DA /* FillBlanksQuizInputContainerView.swift in Sources */, 2C55E1902A056AFC00FE58D7 /* ProblemsLimitSkeletonView.swift in Sources */, E94BB0482A9DF9DD00736B7C /* StepQuizParsonsView.swift in Sources */, E99CCB0B287E945300898BBF /* HomeViewModel.swift in Sources */, + 2C7CB6782ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift in Sources */, 2C0FA879292FD73400A37636 /* ProfileSettingsFeatureStateKsExtensions.swift in Sources */, 2C1061AA285C3C3300EBD614 /* StepQuizChoiceAssembly.swift in Sources */, 2CF72AA828477E0600E1C192 /* StepQuizTableRowView.swift in Sources */, @@ -4348,6 +4459,7 @@ 2C9ECBA3284736090015CFD2 /* StepViewDataMapper.swift in Sources */, 2CAE8CF2280525C900E6C83D /* StepView.swift in Sources */, 2C023C8B285DCA2100D2D5A9 /* ReplyExtensions.swift in Sources */, + 2C7CB67B2ADFD9B4006F78DA /* FillBlanksTextCollectionViewCell.swift in Sources */, E9FB89B02893EA900011EFFB /* UserNotificationsCenterDelegate.swift in Sources */, E9FAF38F299F61AE001FC596 /* View+MeasureSize.swift in Sources */, 2C96744428883E710091B6C9 /* BlockOptionsExtensions.swift in Sources */, diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIFont+SizeOfString.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIFont+SizeOfString.swift new file mode 100644 index 0000000000..144e6b836f --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIFont+SizeOfString.swift @@ -0,0 +1,12 @@ +import UIKit + +extension UIFont { + func sizeOfString(string: String, constrainedToWidth width: Double) -> CGSize { + NSString(string: string).boundingRect( + with: CGSize(width: width, height: Double.greatestFiniteMagnitude), + options: NSStringDrawingOptions.usesLineFragmentOrigin, + attributes: [NSAttributedString.Key.font: self], + context: nil + ).size + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift index 165181ee79..509a69c862 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift @@ -203,6 +203,12 @@ enum Strings { } } + // MARK: - StepQuizFillBlanks- + + enum StepQuizFillBlanks { + static let title = sharedStrings.step_quiz_fill_blanks_title.localized() + } + // MARK: - StageImplement - enum StageImplement { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift index 3fa565503a..3c7849d633 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift @@ -108,6 +108,15 @@ enum StepQuizChildQuizViewFactory { moduleOutput: moduleOutput ) .makeModule() + case .fillBlanks: + StepQuizFillBlanksAssembly( + step: step, + dataset: dataset, + reply: reply, + provideModuleInputCallback: provideModuleInputCallback, + moduleOutput: moduleOutput + ) + .makeModule() case .unsupported(let blockName): fatalError("Unsupported quiz = \(blockName)") } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizType.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizType.swift index ccb2f28d69..9edd4adc15 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizType.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizType.swift @@ -13,6 +13,7 @@ enum StepQuizChildQuizType { case number case math case parsons + case fillBlanks case unsupported(blockName: String) var isCodeRelated: Bool { @@ -49,6 +50,8 @@ enum StepQuizChildQuizType { self = .math case BlockName.shared.PARSONS: self = .parsons + case BlockName.shared.FILL_BLANKS: + self = .fillBlanks default: self = .unsupported(blockName: step.block.name) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift index 1685f1bdcf..212b326f0d 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift @@ -12,13 +12,23 @@ final class StepQuizViewDataMapper { } func mapStepDataToViewData(step: Step, state: StepQuizFeatureStepQuizStateKs) -> StepQuizViewData { - let quizType: StepQuizChildQuizType = { - if state == .unsupported { - return .unsupported(blockName: step.block.name) + let attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded? = { + switch state { + case .attemptLoading(let attemptLoadingState): + return attemptLoadingState.oldState + case .attemptLoaded(let attemptLoadedState): + return attemptLoadedState + default: + return nil } - return StepQuizChildQuizType(step: step) }() + let quizType = resolveQuizType( + step: step, + state: state, + attemptLoadedState: attemptLoadedState + ) + if case .unsupported = quizType { return StepQuizViewData( formattedStats: nil, @@ -35,17 +45,6 @@ final class StepQuizViewDataMapper { millisSinceLastCompleted: step.millisSinceLastCompleted ) - let attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded? = { - switch state { - case .attemptLoading(let attemptLoadingState): - return attemptLoadingState.oldState - case .attemptLoaded(let attemptLoadedState): - return attemptLoadedState - default: - return nil - } - }() - let quizName: String? = { guard let dataset = attemptLoadedState?.attempt.dataset else { return nil @@ -91,4 +90,35 @@ final class StepQuizViewDataMapper { stepHasHints: stepHasHints ) } + + private func resolveQuizType( + step: Step, + state: StepQuizFeatureStepQuizStateKs, + attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded? + ) -> StepQuizChildQuizType { + let unsupportedChildQuizType = StepQuizChildQuizType.unsupported(blockName: step.block.name) + + if state == .unsupported { + return unsupportedChildQuizType + } + + let childQuizType = StepQuizChildQuizType(step: step) + + if case .fillBlanks = childQuizType { + guard let dataset = attemptLoadedState?.attempt.dataset else { + return childQuizType + } + + do { + try FillBlanksResolver.shared.resolve(dataset: dataset) + } catch { + #if DEBUG + print("StepQuizViewDataMapper: failed to resolve fill blanks quiz type, error = \(error)") + #endif + return unsupportedChildQuizType + } + } + + return childQuizType + } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizSkeletonViewFactory.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizSkeletonViewFactory.swift index bb43a1613d..75b92c585c 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizSkeletonViewFactory.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizSkeletonViewFactory.swift @@ -20,6 +20,9 @@ enum StepQuizSkeletonViewFactory { StepQuizStringSkeletonView() case .parsons: StepQuizParsonsSkeletonView() + case .fillBlanks: + #warning("TODO: FillBlanks skeleton view") + StepQuizParsonsSkeletonView() case .unsupported: SkeletonRoundedView() .frame(height: 100) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift new file mode 100644 index 0000000000..5beefa3215 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift @@ -0,0 +1,48 @@ +import Highlightr +import shared +import SwiftUI + +final class StepQuizFillBlanksAssembly: StepQuizChildQuizAssembly { + var moduleInput: StepQuizChildQuizInputProtocol? + weak var moduleOutput: StepQuizChildQuizOutputProtocol? + + private let provideModuleInputCallback: (StepQuizChildQuizInputProtocol?) -> Void + + private let step: Step + private let dataset: Dataset + private let reply: Reply? + + init( + step: Step, + dataset: Dataset, + reply: Reply?, + provideModuleInputCallback: @escaping (StepQuizChildQuizInputProtocol?) -> Void, + moduleOutput: StepQuizChildQuizOutputProtocol? + ) { + self.step = step + self.dataset = dataset + self.reply = reply + self.provideModuleInputCallback = provideModuleInputCallback + self.moduleOutput = moduleOutput + } + + func makeModule() -> StepQuizFillBlanksView { + let viewModel = StepQuizFillBlanksViewModel( + step: step, + dataset: dataset, + reply: reply, + viewDataMapper: StepQuizFillBlanksViewDataMapper( + fillBlanksItemMapper: FillBlanksItemMapper(), + highlightr: Highlightr().require(), + codeEditorThemeService: CodeEditorThemeService(), + cache: StepQuizFillBlanksViewDataMapperCache.shared + ), + provideModuleInputCallback: provideModuleInputCallback + ) + + moduleInput = viewModel + viewModel.moduleOutput = moduleOutput + + return StepQuizFillBlanksView(viewModel: viewModel) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksViewModel.swift new file mode 100644 index 0000000000..89ca19f68e --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksViewModel.swift @@ -0,0 +1,73 @@ +import Combine +import Foundation +import shared + +final class StepQuizFillBlanksViewModel: ObservableObject { + weak var moduleOutput: StepQuizChildQuizOutputProtocol? + private let provideModuleInputCallback: (StepQuizChildQuizInputProtocol?) -> Void + + @Published private(set) var viewData: StepQuizFillBlanksViewData + + init( + step: Step, + dataset: Dataset, + reply: Reply?, + viewDataMapper: StepQuizFillBlanksViewDataMapper, + provideModuleInputCallback: @escaping (StepQuizChildQuizInputProtocol?) -> Void + ) { + self.provideModuleInputCallback = provideModuleInputCallback + self.viewData = viewDataMapper.mapToViewData(dataset: dataset, reply: reply) + } + + func doProvideModuleInput() { + provideModuleInputCallback(self) + } + + func doInputTextUpdate(_ inputText: String, for component: StepQuizFillBlankComponent) { + guard let index = viewData.components.firstIndex( + where: { $0.id == component.id } + ) else { + return + } + + viewData.components[index].inputText = inputText + outputCurrentReply() + } + + func doSelectComponent(at indexPath: IndexPath) { + setIsFirstResponder(true, forComponentAt: indexPath) + } + + func doDeselectComponent(at indexPath: IndexPath) { + setIsFirstResponder(false, forComponentAt: indexPath) + } + + private func setIsFirstResponder(_ isFirstResponder: Bool, forComponentAt indexPath: IndexPath) { + guard viewData.components[indexPath.row].type == .input else { + return + } + + viewData.components[indexPath.row].isFirstResponder = isFirstResponder + } +} + +// MARK: - StepQuizFillBlanksViewModel: StepQuizChildQuizInputProtocol - + +extension StepQuizFillBlanksViewModel: StepQuizChildQuizInputProtocol { + func createReply() -> Reply { + let blanks: [String] = viewData.components.compactMap { component in + switch component.type { + case .text, .lineBreak: + return nil + case .input: + return component.inputText ?? "" + } + } + + return Reply.companion.fillBlanks(blanks: blanks) + } + + private func outputCurrentReply() { + moduleOutput?.handleChildQuizSync(reply: createReply()) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewData.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewData.swift new file mode 100644 index 0000000000..ee3343af52 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewData.swift @@ -0,0 +1,21 @@ +import Foundation + +struct StepQuizFillBlanksViewData: Hashable { + var components: [StepQuizFillBlankComponent] +} + +struct StepQuizFillBlankComponent: Hashable, Identifiable { + var id: Int = 0 + let type: ComponentType + // text + var attributedText: NSAttributedString? + // input + var inputText: String? + var isFirstResponder = false + + enum ComponentType { + case text + case input + case lineBreak + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift new file mode 100644 index 0000000000..7bc5b2eef3 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift @@ -0,0 +1,89 @@ +import Foundation +import Highlightr +import shared + +final class StepQuizFillBlanksViewDataMapper { + private let fillBlanksItemMapper: FillBlanksItemMapper + private let highlightr: Highlightr + private let codeEditorThemeService: CodeEditorThemeServiceProtocol + private let cache: StepQuizFillBlanksViewDataMapperCacheProtocol + + init( + fillBlanksItemMapper: FillBlanksItemMapper, + highlightr: Highlightr, + codeEditorThemeService: CodeEditorThemeServiceProtocol, + cache: StepQuizFillBlanksViewDataMapperCacheProtocol + ) { + let theme = codeEditorThemeService.theme + highlightr.setTheme(to: theme.name) + highlightr.theme.setCodeFont(theme.font) + + self.highlightr = highlightr + self.codeEditorThemeService = codeEditorThemeService + self.cache = cache + self.fillBlanksItemMapper = fillBlanksItemMapper + } + + func mapToViewData(dataset: Dataset, reply: Reply?) -> StepQuizFillBlanksViewData { + guard let fillBlanksData = fillBlanksItemMapper.map(dataset: dataset, reply: reply) else { + return .init(components: []) + } + + return mapFillBlanksDataToViewData(fillBlanksData) + } + + private func mapFillBlanksDataToViewData(_ fillBlanksData: FillBlanksData) -> StepQuizFillBlanksViewData { + let language = fillBlanksData.language + + var components = fillBlanksData.fillBlanks + .map { mapFillBlanksItem($0, language: language) } + .flatMap { $0 } + for index in components.indices { + components[index].id = index + } + + return StepQuizFillBlanksViewData(components: components) + } + + private func mapFillBlanksItem( + _ fillBlanksItem: FillBlanksItem, + language: String? + ) -> [StepQuizFillBlankComponent] { + switch FillBlanksItemKs(fillBlanksItem) { + case .text(let data): + var result = [StepQuizFillBlankComponent]() + + if data.startsWithNewLine { + result.append(StepQuizFillBlankComponent(type: .lineBreak)) + } + + let hash = data.text.hashValue ^ UITraitCollection.current.userInterfaceStyle.hashValue + + if let cachedCode = cache.getHighlightedCode(for: hash) { + result.append(StepQuizFillBlankComponent(type: .text, attributedText: cachedCode)) + } else { + let unescaped = HTMLString.unescape(string: data.text) + + if let highlightedCode = highlight(code: unescaped, language: language) { + cache.setHighlightedCode(highlightedCode, for: hash) + result.append(StepQuizFillBlankComponent(type: .text, attributedText: highlightedCode)) + } else { + let attributedText = NSAttributedString( + string: unescaped, + attributes: [.font: codeEditorThemeService.theme.font] + ) + cache.setHighlightedCode(attributedText, for: hash) + result.append(StepQuizFillBlankComponent(type: .text, attributedText: attributedText)) + } + } + + return result + case .input(let data): + return [StepQuizFillBlankComponent(type: .input, inputText: data.inputText)] + } + } + + private func highlight(code: String, language: String?) -> NSAttributedString? { + highlightr.highlight(code, as: language, fastRender: true) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapperCache.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapperCache.swift new file mode 100644 index 0000000000..7336f17573 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapperCache.swift @@ -0,0 +1,38 @@ +import Foundation + +protocol StepQuizFillBlanksViewDataMapperCacheProtocol: AnyObject { + func getHighlightedCode(for key: Int) -> NSAttributedString? + func setHighlightedCode(_ code: NSAttributedString, for key: Int) +} + +final class StepQuizFillBlanksViewDataMapperCache: StepQuizFillBlanksViewDataMapperCacheProtocol { + static let shared = StepQuizFillBlanksViewDataMapperCache() + + private lazy var cache: NSCache = { + let cache = NSCache() + cache.countLimit = 50 + return cache + }() + + private init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(clearCacheOnEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + func getHighlightedCode(for key: Int) -> NSAttributedString? { + cache.object(forKey: key as NSNumber) + } + + func setHighlightedCode(_ code: NSAttributedString, for key: Int) { + cache.setObject(code, forKey: key as NSNumber) + } + + @objc + private func clearCacheOnEnterBackground() { + cache.removeAllObjects() + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/FillBlanksQuizViewWrapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/FillBlanksQuizViewWrapper.swift new file mode 100644 index 0000000000..869df774cc --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/FillBlanksQuizViewWrapper.swift @@ -0,0 +1,100 @@ +import Foundation +import SwiftUI +import UIKit + +struct FillBlanksQuizViewWrapper: UIViewRepresentable { + let components: [StepQuizFillBlankComponent] + let isUserInteractionEnabled: Bool + + var onInputDidChange: ((String, StepQuizFillBlankComponent) -> Void)? + + var onDidSelectComponent: ((IndexPath) -> Void)? + var onDidDeselectComponent: ((IndexPath) -> Void)? + + static func dismantleUIView(_ uiView: FillBlanksQuizView, coordinator: Coordinator) { + coordinator.onInputDidChange = nil + coordinator.onDidSelectComponent = nil + coordinator.onDidDeselectComponent = nil + coordinator.collectionViewAdapter.delegate = nil + } + + func makeUIView(context: Context) -> FillBlanksQuizView { + FillBlanksQuizView() + } + + func updateUIView(_ uiView: FillBlanksQuizView, context: Context) { + let collectionViewAdapter = context.coordinator.collectionViewAdapter + let shouldUpdateCollectionViewData = collectionViewAdapter.components != components + + collectionViewAdapter.components = components + collectionViewAdapter.isUserInteractionEnabled = isUserInteractionEnabled + + if shouldUpdateCollectionViewData { + uiView.updateCollectionViewData( + delegate: collectionViewAdapter, + dataSource: collectionViewAdapter + ) + } + + context.coordinator.onInputDidChange = { [weak collectionViewAdapter, weak uiView] inputText, component in + guard let collectionViewAdapter, let uiView else { + return + } + + guard let index = collectionViewAdapter.components.firstIndex( + where: { $0.id == component.id } + ) else { + return + } + + collectionViewAdapter.components[index].inputText = inputText + self.onInputDidChange?(inputText, component) + + uiView.invalidateCollectionViewLayout() + } + context.coordinator.onDidSelectComponent = onDidSelectComponent + context.coordinator.onDidDeselectComponent = onDidDeselectComponent + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } +} + +extension FillBlanksQuizViewWrapper { + class Coordinator: NSObject, FillBlanksQuizCollectionViewAdapterDelegate { + private(set) var collectionViewAdapter = FillBlanksQuizCollectionViewAdapter() + + var onInputDidChange: ((String, StepQuizFillBlankComponent) -> Void)? + + var onDidSelectComponent: ((IndexPath) -> Void)? + var onDidDeselectComponent: ((IndexPath) -> Void)? + + override init() { + super.init() + collectionViewAdapter.delegate = self + } + + func fillBlanksQuizCollectionViewAdapter( + _ adapter: FillBlanksQuizCollectionViewAdapter, + inputDidChange inputText: String, + forComponent component: StepQuizFillBlankComponent + ) { + onInputDidChange?(inputText, component) + } + + func fillBlanksQuizCollectionViewAdapter( + _ adapter: FillBlanksQuizCollectionViewAdapter, + didSelectComponentAt indexPath: IndexPath + ) { + onDidSelectComponent?(indexPath) + } + + func fillBlanksQuizCollectionViewAdapter( + _ adapter: FillBlanksQuizCollectionViewAdapter, + didDeselectComponentAt indexPath: IndexPath + ) { + onDidDeselectComponent?(indexPath) + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksView.swift new file mode 100644 index 0000000000..3a5888f093 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct StepQuizFillBlanksView: View { + @StateObject var viewModel: StepQuizFillBlanksViewModel + + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + FillBlanksQuizViewWrapper( + components: viewModel.viewData.components, + isUserInteractionEnabled: isEnabled, + onInputDidChange: viewModel.doInputTextUpdate(_:for:), + onDidSelectComponent: viewModel.doSelectComponent(at:), + onDidDeselectComponent: viewModel.doDeselectComponent(at:) + ) + .onAppear { + viewModel.doProvideModuleInput() + KeyboardManager.setEnableAutoToolbar(true) + } + .onDisappear { + KeyboardManager.setEnableAutoToolbar(false) + } + .opacity(isEnabled ? 1 : 0.5) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksInputCollectionViewCell.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksInputCollectionViewCell.swift new file mode 100644 index 0000000000..46cc224a3e --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksInputCollectionViewCell.swift @@ -0,0 +1,133 @@ +import SnapKit +import UIKit + +extension FillBlanksInputCollectionViewCell { + struct Appearance { + let minWidth: CGFloat = 48 + + let cornerRadius: CGFloat = 8 + + let insets = LayoutInsets.small.uiEdgeInsets + + static let font = CodeEditorThemeService().theme.font + let textColor = UIColor.primaryText + } +} + +final class FillBlanksInputCollectionViewCell: UICollectionViewCell, Reusable { + var appearance = Appearance() + + private lazy var inputContainerView: FillBlanksQuizInputContainerView = { + let view = FillBlanksQuizInputContainerView( + appearance: .init(cornerRadius: self.appearance.cornerRadius) + ) + return view + }() + + private lazy var textField: UITextField = { + let textField = UITextField() + textField.font = Appearance.font + textField.textColor = self.appearance.textColor + textField.textAlignment = .center + textField.delegate = self + textField.addTarget(self, action: #selector(self.textFieldDidChange(_:)), for: .editingChanged) + // Disable features + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.spellCheckingType = .no + textField.smartDashesType = .no + textField.smartQuotesType = .no + textField.smartInsertDeleteType = .no + return textField + }() + + var text: String? { + didSet { + self.textField.text = self.text + } + } + + var isEnabled = true { + didSet { + self.isUserInteractionEnabled = self.isEnabled + } + } + + var state: FillBlanksQuizInputContainerView.State { + get { + self.inputContainerView.state + } + set { + self.inputContainerView.state = newValue + } + } + + var onInputChanged: ((String) -> Void)? + + var onBecameFirstResponder: (() -> Void)? + var onResignedFirstResponder: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func becomeFirstResponder() -> Bool { + self.textField.becomeFirstResponder() + } + + @objc + private func textFieldDidChange(_ sender: UITextField) { + self.onInputChanged?(sender.text ?? "") + } + + static func calculatePreferredContentSize(text: String, maxWidth: CGFloat) -> CGSize { + let appearance = Appearance() + + let sizeOfString = Appearance.font.sizeOfString(string: text, constrainedToWidth: Double(maxWidth)) + let widthOfStringWithInsets = appearance.insets.left + sizeOfString.width.rounded(.up) + appearance.insets.right + + let width = max(appearance.minWidth, min(maxWidth, widthOfStringWithInsets)) + let height = (appearance.insets.top + Appearance.font.pointSize + appearance.insets.bottom).rounded(.up) + + return CGSize(width: width, height: height) + } +} + +extension FillBlanksInputCollectionViewCell: ProgrammaticallyInitializableViewProtocol { + func addSubviews() { + self.contentView.addSubview(self.inputContainerView) + self.inputContainerView.addSubview(self.textField) + } + + func makeConstraints() { + self.inputContainerView.translatesAutoresizingMaskIntoConstraints = false + self.inputContainerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + self.textField.translatesAutoresizingMaskIntoConstraints = false + self.textField.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(self.appearance.insets) + } + } +} + +// MARK: - FillBlanksInputCollectionViewCell: UITextFieldDelegate - + +extension FillBlanksInputCollectionViewCell: UITextFieldDelegate { + func textFieldDidBeginEditing(_ textField: UITextField) { + onBecameFirstResponder?() + } + + func textFieldDidEndEditing(_ textField: UITextField) { + onResignedFirstResponder?() + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksTextCollectionViewCell.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksTextCollectionViewCell.swift new file mode 100644 index 0000000000..208c046e5b --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksTextCollectionViewCell.swift @@ -0,0 +1,88 @@ +import SnapKit +import UIKit + +extension FillBlanksTextCollectionViewCell { + struct Appearance { + let font = UIFont.preferredFont(forTextStyle: .body) + let textColor = UIColor.primaryText + } +} + +final class FillBlanksTextCollectionViewCell: UICollectionViewCell, Reusable { + private static var prototypeTextLabel: UILabel? + + private lazy var textLabel: UILabel = { + Self.makeTextLabel(appearance: self.appearance) + }() + + var appearance = Appearance() + + var attributedText: NSAttributedString? { + didSet { + if let attributedText = self.attributedText { + self.textLabel.attributedText = attributedText + } else { + self.textLabel.attributedText = nil + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + Self.prototypeTextLabel = nil + } + + static func calculatePreferredContentSize(attributedText: NSAttributedString?, maxWidth: CGFloat) -> CGSize { + if Self.prototypeTextLabel == nil { + Self.prototypeTextLabel = Self.makeTextLabel() + } + + guard let label = Self.prototypeTextLabel else { + return .zero + } + + label.frame = CGRect(x: 0, y: 0, width: maxWidth, height: CGFloat.greatestFiniteMagnitude) + + label.attributedText = attributedText + label.sizeToFit() + + var size = label.bounds.size + size.width = size.width.rounded(.up) + size.height = size.height.rounded(.up) + + return size + } + + private static func makeTextLabel(appearance: Appearance = Appearance()) -> UILabel { + let label = UILabel() + label.font = appearance.font + label.textColor = appearance.textColor + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + return label + } +} + +extension FillBlanksTextCollectionViewCell: ProgrammaticallyInitializableViewProtocol { + func addSubviews() { + self.contentView.addSubview(self.textLabel) + } + + func makeConstraints() { + self.textLabel.translatesAutoresizingMaskIntoConstraints = false + self.textLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizCollectionViewAdapter.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizCollectionViewAdapter.swift new file mode 100644 index 0000000000..fdeb08063c --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizCollectionViewAdapter.swift @@ -0,0 +1,154 @@ +import UIKit + +protocol FillBlanksQuizCollectionViewAdapterDelegate: AnyObject { + func fillBlanksQuizCollectionViewAdapter( + _ adapter: FillBlanksQuizCollectionViewAdapter, + inputDidChange inputText: String, + forComponent component: StepQuizFillBlankComponent + ) + + func fillBlanksQuizCollectionViewAdapter( + _ adapter: FillBlanksQuizCollectionViewAdapter, + didSelectComponentAt indexPath: IndexPath + ) + func fillBlanksQuizCollectionViewAdapter( + _ adapter: FillBlanksQuizCollectionViewAdapter, + didDeselectComponentAt indexPath: IndexPath + ) +} + +final class FillBlanksQuizCollectionViewAdapter: NSObject { + weak var delegate: FillBlanksQuizCollectionViewAdapterDelegate? + + var components: [StepQuizFillBlankComponent] + var isUserInteractionEnabled = true + + init(components: [StepQuizFillBlankComponent] = []) { + self.components = components + super.init() + } +} + +// MARK: - FillBlanksQuizCollectionViewAdapter: UICollectionViewDataSource - + +extension FillBlanksQuizCollectionViewAdapter: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + self.components.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + let component = self.components[indexPath.row] + + switch component.type { + case .text, .lineBreak: + let cell: FillBlanksTextCollectionViewCell = collectionView.dequeueReusableCell(for: indexPath) + cell.attributedText = component.attributedText + return cell + case .input: + let cell: FillBlanksInputCollectionViewCell = collectionView.dequeueReusableCell(for: indexPath) + cell.text = component.inputText + cell.isEnabled = self.isUserInteractionEnabled + cell.state = component.isFirstResponder ? .firstResponder : .default + cell.onInputChanged = { [weak self] text in + guard let strongSelf = self else { + return + } + + strongSelf.delegate?.fillBlanksQuizCollectionViewAdapter( + strongSelf, + inputDidChange: text, + forComponent: component + ) + } + cell.onBecameFirstResponder = { [weak self, weak cell] in + guard let strongSelf = self, + let strongCell = cell else { + return + } + + strongCell.state = .firstResponder + strongSelf.components[indexPath.row].isFirstResponder = true + + strongSelf.delegate?.fillBlanksQuizCollectionViewAdapter( + strongSelf, + didSelectComponentAt: indexPath + ) + } + cell.onResignedFirstResponder = { [weak self, weak cell] in + guard let strongSelf = self, + let strongCell = cell else { + return + } + + strongCell.state = .default + strongSelf.components[indexPath.row].isFirstResponder = false + + strongSelf.delegate?.fillBlanksQuizCollectionViewAdapter( + strongSelf, + didDeselectComponentAt: indexPath + ) + } + return cell + } + } +} + +// MARK: - FillBlanksQuizCollectionViewAdapter: UICollectionViewDelegateFlowLayout - + +extension FillBlanksQuizCollectionViewAdapter: UICollectionViewDelegateFlowLayout { + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + guard let flowLayout = collectionViewLayout as? UICollectionViewFlowLayout else { + return .zero + } + + let maxWidth = collectionView.bounds.width + - flowLayout.sectionInset.left + - flowLayout.sectionInset.right + + let component = self.components[indexPath.row] + + switch component.type { + case .lineBreak: + return CGSize(width: maxWidth, height: flowLayout.minimumLineSpacing) + case .text: + return FillBlanksTextCollectionViewCell.calculatePreferredContentSize( + attributedText: component.attributedText, + maxWidth: maxWidth + ) + case .input: + return FillBlanksInputCollectionViewCell.calculatePreferredContentSize( + text: component.inputText ?? "", + maxWidth: maxWidth + ) + } + } + + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + if !self.isUserInteractionEnabled { + return false + } + + switch self.components[indexPath.row].type { + case .text, .lineBreak: + return false + case .input: + return true + } + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + if self.components[indexPath.row].type == .input, + let cell = collectionView.cellForItem(at: indexPath) as? FillBlanksInputCollectionViewCell { + _ = cell.becomeFirstResponder() + } + + self.delegate?.fillBlanksQuizCollectionViewAdapter(self, didSelectComponentAt: indexPath) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizInputContainerView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizInputContainerView.swift new file mode 100644 index 0000000000..93cb702af1 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizInputContainerView.swift @@ -0,0 +1,64 @@ +import SnapKit +import UIKit + +extension FillBlanksQuizInputContainerView { + struct Appearance { + var cornerRadius: CGFloat = 18 + let borderWidth: CGFloat = 1 + + let backgroundColor = UIColor.clear + } +} + +final class FillBlanksQuizInputContainerView: UIView { + let appearance: Appearance + + var state = State.default { + didSet { + self.updateState() + } + } + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance() + ) { + self.appearance = appearance + super.init(frame: frame) + + self.clipsToBounds = true + self.layer.cornerRadius = self.appearance.cornerRadius + self.backgroundColor = self.appearance.backgroundColor + + self.updateState() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + self.performBlockIfAppearanceChanged(from: previousTraitCollection, block: self.updateState) + } + + private func updateState() { + self.layer.borderColor = self.state.borderColor.cgColor + self.layer.borderWidth = self.appearance.borderWidth + } + + enum State { + case `default` + case firstResponder + + fileprivate var borderColor: UIColor { + switch self { + case .default: + return ColorPalette.onSurfaceAlpha12 + case .firstResponder: + return ColorPalette.primary + } + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizTitleView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizTitleView.swift new file mode 100644 index 0000000000..bc07e7742e --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizTitleView.swift @@ -0,0 +1,88 @@ +import SnapKit +import UIKit + +extension FillBlanksQuizTitleView { + struct Appearance { + let textColor = UIColor.primaryText + let font = UIFont.preferredFont(forTextStyle: .headline) + let insets = LayoutInsets(horizontal: LayoutInsets.defaultInset, vertical: LayoutInsets.smallInset) + + var backgroundColor = ColorPalette.background + } +} + +final class FillBlanksQuizTitleView: UIView { + let appearance: Appearance + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.text = Strings.StepQuizFillBlanks.title + label.textColor = self.appearance.textColor + label.font = self.appearance.font + label.numberOfLines = 1 + return label + }() + + private lazy var topSeparatorView = UIKitSeparatorView() + private lazy var bottomSeparatorView = UIKitSeparatorView() + + override var intrinsicContentSize: CGSize { + let titleLabelHeight = self.titleLabel.intrinsicContentSize.height + let height = self.appearance.insets.top + titleLabelHeight + self.appearance.insets.bottom + return CGSize(width: UIView.noIntrinsicMetric, height: height) + } + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance() + ) { + self.appearance = appearance + super.init(frame: frame) + + self.setupView() + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension FillBlanksQuizTitleView: ProgrammaticallyInitializableViewProtocol { + func setupView() { + self.backgroundColor = self.appearance.backgroundColor + } + + func addSubviews() { + self.addSubview(self.titleLabel) + self.addSubview(self.topSeparatorView) + self.addSubview(self.bottomSeparatorView) + } + + func makeConstraints() { + self.titleLabel.translatesAutoresizingMaskIntoConstraints = false + self.titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(self.appearance.insets.top) + make.leading.equalToSuperview().offset(self.appearance.insets.leading) + make.bottom.equalToSuperview().offset(-self.appearance.insets.bottom) + make.trailing.equalToSuperview().offset(-self.appearance.insets.trailing) + } + + self.topSeparatorView.translatesAutoresizingMaskIntoConstraints = false + self.topSeparatorView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + + self.bottomSeparatorView.translatesAutoresizingMaskIntoConstraints = false + self.bottomSeparatorView.snp.makeConstraints { make in + make.leading.bottom.trailing.equalToSuperview() + } + } +} + +@available(iOS 17, *) +#Preview { + FillBlanksQuizTitleView() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift new file mode 100644 index 0000000000..b00abcf8eb --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift @@ -0,0 +1,136 @@ +import SnapKit +import UIKit + +extension FillBlanksQuizView { + struct Appearance { + let horizontalInset = LayoutInsets.defaultInset + + let collectionViewMinHeight: CGFloat = 44 + let collectionViewMinLineSpacing: CGFloat = 4 + let collectionViewMinInteritemSpacing: CGFloat = 4 + let collectionViewSectionInset = LayoutInsets.default.uiEdgeInsets + + let backgroundColor = ColorPalette.background + } +} + +final class FillBlanksQuizView: UIView { + let appearance: Appearance + + private lazy var titleView = FillBlanksQuizTitleView( + appearance: .init(backgroundColor: self.appearance.backgroundColor) + ) + + private lazy var collectionView: UICollectionView = { + let collectionViewLayout = LeftAlignedCollectionViewFlowLayout() + collectionViewLayout.scrollDirection = .vertical + collectionViewLayout.minimumLineSpacing = self.appearance.collectionViewMinLineSpacing + collectionViewLayout.minimumInteritemSpacing = self.appearance.collectionViewMinInteritemSpacing + collectionViewLayout.sectionInset = self.appearance.collectionViewSectionInset + + let collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: collectionViewLayout + ) + collectionView.backgroundColor = self.appearance.backgroundColor + collectionView.isScrollEnabled = false + collectionView.register(cellClass: FillBlanksInputCollectionViewCell.self) + collectionView.register(cellClass: FillBlanksTextCollectionViewCell.self) + + return collectionView + }() + + private lazy var bottomSeparatorView = UIKitSeparatorView() + + override var intrinsicContentSize: CGSize { + let titleViewHeight = self.titleView.intrinsicContentSize.height + let collectionViewHeight = max( + self.appearance.collectionViewMinHeight, + self.collectionView.collectionViewLayout.collectionViewContentSize.height + ) + + let height = titleViewHeight + collectionViewHeight + + return CGSize(width: UIView.noIntrinsicMetric, height: height) + } + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance() + ) { + self.appearance = appearance + super.init(frame: frame) + + self.setupView() + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateCollectionViewData(delegate: UICollectionViewDelegate, dataSource: UICollectionViewDataSource) { + self.collectionView.delegate = delegate + self.collectionView.dataSource = dataSource + self.collectionView.reloadData() + + DispatchQueue.main.async { + self.invalidateIntrinsicContentSize() + } + } + + func invalidateCollectionViewLayout() { + DispatchQueue.main.async { + UIView.performWithoutAnimation { + self.collectionView.collectionViewLayout.invalidateLayout() + self.layoutIfNeeded() + self.invalidateIntrinsicContentSize() + } + } + } +} + +// MARK: - FillBlanksQuizView: ProgrammaticallyInitializableViewProtocol - + +extension FillBlanksQuizView: ProgrammaticallyInitializableViewProtocol { + func setupView() { + self.backgroundColor = self.appearance.backgroundColor + } + + func addSubviews() { + self.addSubview(self.titleView) + self.addSubview(self.collectionView) + self.addSubview(self.bottomSeparatorView) + } + + func makeConstraints() { + self.titleView.translatesAutoresizingMaskIntoConstraints = false + self.titleView.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.equalToSuperview().offset(-self.appearance.horizontalInset) + make.trailing.equalToSuperview().offset(self.appearance.horizontalInset) + } + + self.collectionView.translatesAutoresizingMaskIntoConstraints = false + self.collectionView.snp.makeConstraints { make in + make.top.equalTo(self.titleView.snp.bottom) + make.leading.equalToSuperview().offset(-self.appearance.horizontalInset) + make.bottom.equalToSuperview() + make.trailing.equalToSuperview().offset(self.appearance.horizontalInset) + } + + self.bottomSeparatorView.translatesAutoresizingMaskIntoConstraints = false + self.bottomSeparatorView.snp.makeConstraints { make in + make.bottom.equalToSuperview() + make.leading.equalToSuperview().offset(-self.appearance.horizontalInset) + make.trailing.equalToSuperview().offset(self.appearance.horizontalInset) + } + } +} + +@available(iOS 17, *) +#Preview { + FillBlanksQuizView() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Systems/KeyboardManager.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Systems/KeyboardManager.swift index 7e909788db..41e49ab3c8 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Systems/KeyboardManager.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Systems/KeyboardManager.swift @@ -23,6 +23,10 @@ enum KeyboardManager { IQKeyboardManager.shared.enable = isEnabled } + static func setEnableAutoToolbar(_ enableAutoToolbar: Bool) { + IQKeyboardManager.shared.enableAutoToolbar = enableAutoToolbar + } + static func reloadLayoutIfNeeded() { IQKeyboardManager.shared.reloadLayoutIfNeeded() } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/CollectionViewLayouts/LeftAlignedCollectionViewFlowLayout.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/CollectionViewLayouts/LeftAlignedCollectionViewFlowLayout.swift new file mode 100644 index 0000000000..250e415342 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/CollectionViewLayouts/LeftAlignedCollectionViewFlowLayout.swift @@ -0,0 +1,30 @@ +import UIKit + +class LeftAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout { + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + let attributesCopy = NSArray(array: super.layoutAttributesForElements(in: rect) ?? [], copyItems: true) + let attributes = attributesCopy as? [UICollectionViewLayoutAttributes] + + var leftMargin = self.sectionInset.left + var maxY: CGFloat = -1.0 + + attributes?.forEach { layoutAttribute in + if layoutAttribute.representedElementKind == UICollectionView.elementKindSectionHeader { + leftMargin = self.sectionInset.left + layoutAttribute.frame.size.width -= self.sectionInset.left + self.sectionInset.right + } + + // Detect a new line + if layoutAttribute.frame.origin.y >= maxY { + leftMargin = self.sectionInset.left + } + + layoutAttribute.frame.origin.x = leftMargin + + leftMargin += layoutAttribute.frame.width + self.minimumInteritemSpacing + maxY = max(layoutAttribute.frame.maxY, maxY) + } + + return attributes + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/UIKitSeparatorView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/UIKitSeparatorView.swift new file mode 100644 index 0000000000..3d40629279 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/UIKitSeparatorView.swift @@ -0,0 +1,40 @@ +import SnapKit +import UIKit + +extension UIKitSeparatorView { + struct Appearance { + /// UITableView's default separator height. + let height: CGFloat = 1.0 + /// UITableView's default separator color. + var color = UIColor.separator + } +} + +/// View to make separator consistent appearance. +final class UIKitSeparatorView: UIView { + let appearance: Appearance + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: self.appearance.height / UIScreen.main.scale) + } + + init(frame: CGRect = .zero, appearance: Appearance = Appearance()) { + self.appearance = appearance + super.init(frame: frame) + self.setupView() + } + + override func layoutSubviews() { + super.layoutSubviews() + self.invalidateIntrinsicContentSize() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + self.backgroundColor = self.appearance.color + } +} diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/core/utils/RegexUtils.android.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/core/utils/RegexUtils.android.kt new file mode 100644 index 0000000000..fa032f3662 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/core/utils/RegexUtils.android.kt @@ -0,0 +1,4 @@ +package org.hyperskill.app.core.utils + +actual val DotMatchesAllRegexOption: RegexOption + get() = RegexOption.DOT_MATCHES_ALL \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt index b95c50a5db..18d4e444be 100644 --- a/shared/src/androidMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt @@ -12,5 +12,6 @@ actual val BlockName.supportedBlocksNames: Set STRING, MATH, NUMBER, - PARSONS + PARSONS, + FILL_BLANKS ) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/utils/RegexUtils.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/utils/RegexUtils.kt new file mode 100644 index 0000000000..fd7557fb30 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/utils/RegexUtils.kt @@ -0,0 +1,3 @@ +package org.hyperskill.app.core.utils + +expect val DotMatchesAllRegexOption: RegexOption \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt index 8e15d4a2c6..dbe127f9b5 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt @@ -14,6 +14,7 @@ object BlockName { const val TABLE = "table" const val TEXT = "text" const val PARSONS = "parsons" + const val FILL_BLANKS = "fill-blanks" const val VIDEO = "video" val codeRelatedBlocksNames: Set = diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/model/attempts/Component.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/model/attempts/Component.kt index f9c8ae747c..3ce3633b2e 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/model/attempts/Component.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/model/attempts/Component.kt @@ -6,9 +6,19 @@ import kotlinx.serialization.Serializable @Serializable data class Component( @SerialName("type") - val type: String, + val type: Type, @SerialName("text") - val text: String?, + val text: String? = null, @SerialName("options") - val options: List? -) \ No newline at end of file + val options: List? = null +) { + @Serializable + enum class Type { + @SerialName("text") + TEXT, + @SerialName("input") + INPUT, + @SerialName("select") + SELECT + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/model/submissions/Reply.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/model/submissions/Reply.kt index 0b6ecb697a..63c42458b8 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/model/submissions/Reply.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/model/submissions/Reply.kt @@ -60,6 +60,9 @@ data class Reply( fun parsons(lines: List): Reply = Reply(lines = lines) + + fun fillBlanks(blanks: List): Reply = + Reply(blanks = blanks) } } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/validation/StepQuizReplyValidator.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/validation/StepQuizReplyValidator.kt index 3a4ba2d8cd..db1a0641e7 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/validation/StepQuizReplyValidator.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/validation/StepQuizReplyValidator.kt @@ -50,7 +50,12 @@ class StepQuizReplyValidator(private val resourceProvider: ResourceProvider) { return ReplyValidationResult.Error(getErrorMessage(stepBlockName)) } } - BlockName.CODE, BlockName.SQL, BlockName.PYCHARM -> return ReplyValidationResult.Success + + BlockName.CODE, + BlockName.SQL, + BlockName.PYCHARM, + BlockName.FILL_BLANKS -> return ReplyValidationResult.Success + else -> throw IllegalArgumentException("Unsupported block type = $stepBlockName") } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizResolver.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizResolver.kt index c498f26760..d35003ef99 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizResolver.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizResolver.kt @@ -65,7 +65,8 @@ object StepQuizResolver { BlockName.CODE, BlockName.SQL, BlockName.PYCHARM, - BlockName.MATH -> + BlockName.MATH, + BlockName.FILL_BLANKS -> true else -> false diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksConfig.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksConfig.kt new file mode 100644 index 0000000000..d1ed790d32 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksConfig.kt @@ -0,0 +1,5 @@ +package org.hyperskill.app.step_quiz_fill_blanks.model + +internal object FillBlanksConfig { + const val BLANK_FIELD_CHAR = '▭' +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksData.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksData.kt new file mode 100644 index 0000000000..4da654a3e5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksData.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.step_quiz_fill_blanks.model + +data class FillBlanksData( + val fillBlanks: List, + val language: String? +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksItem.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksItem.kt new file mode 100644 index 0000000000..11c3331e29 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksItem.kt @@ -0,0 +1,31 @@ +package org.hyperskill.app.step_quiz_fill_blanks.model + +import ru.nobird.app.core.model.Identifiable + +sealed interface FillBlanksItem : Identifiable { + /** + * Represents a textual fill-in-the-blank item in a quiz. + * + * @param id The order number in a list of items. + * @param text The content of the fill-in-the-blank text. + * + * @param startsWithNewLine Indicates whether the text item should start with a new line preventive. + * If true, then the '\n' was placed before the text. + */ + data class Text( + override val id: Int, + val text: String, + val startsWithNewLine: Boolean + ) : FillBlanksItem + + /** + * Represents an input fill-in-the-blank item in a quiz. + * + * @param id The order number in a list of items. + * @param inputText The input text provided by the user. + */ + data class Input( + override val id: Int, + val inputText: String? + ) : FillBlanksItem +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/InvalidFillBlanksConfigException.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/InvalidFillBlanksConfigException.kt new file mode 100644 index 0000000000..2b72fc3753 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/InvalidFillBlanksConfigException.kt @@ -0,0 +1,3 @@ +package org.hyperskill.app.step_quiz_fill_blanks.model + +class InvalidFillBlanksConfigException(override val message: String) : Exception() \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/presentation/FillBlanksItemMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/presentation/FillBlanksItemMapper.kt new file mode 100644 index 0000000000..055b08ff69 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/presentation/FillBlanksItemMapper.kt @@ -0,0 +1,203 @@ +package org.hyperskill.app.step_quiz_fill_blanks.presentation + +import org.hyperskill.app.core.utils.DotMatchesAllRegexOption +import org.hyperskill.app.step_quiz.domain.model.attempts.Attempt +import org.hyperskill.app.step_quiz.domain.model.attempts.Component +import org.hyperskill.app.step_quiz.domain.model.attempts.Dataset +import org.hyperskill.app.step_quiz.domain.model.submissions.Reply +import org.hyperskill.app.step_quiz.domain.model.submissions.Submission +import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksConfig +import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksData +import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksItem +import org.hyperskill.app.step_quiz_fill_blanks.presentation.FillBlanksItemMapper.Companion.WHITE_SPACE_HTML_STRING +import ru.nobird.app.core.model.mutate +import ru.nobird.app.core.model.slice + +/** + * Extracts content from
...
 HTML tags.
+ * Extracts the language name from the , if present.
+ *
+ * Maps content into a list of [FillBlanksItem],
+ * replacing the '▭' with [FillBlanksItem.Input], and the rest of the text with [FillBlanksItem.Text].
+ *
+ * Splits the text by '\n'.
+ * It is necessary to make text items smaller, and not occupy the whole width of the widget.
+ * This way, there will be enough space for input items,
+ * so that they will be placed next to the text item, not on the next line.
+ *
+ * Replace the leading whitespaces with the [WHITE_SPACE_HTML_STRING] so that HTML parser keeps them as is.
+ *
+ * Fill the [FillBlanksItem.Input] with the data from [Reply].blanks or [Component].text with the type == "input".
+ */
+class FillBlanksItemMapper {
+    companion object {
+        private const val LINE_BREAK_CHAR = '\n'
+        private const val LANGUAGE_CLASS_PREFIX = "class=\"language-"
+        private const val WHITE_SPACE_HTML_STRING = " "
+
+        private val DELIMITERS = charArrayOf(LINE_BREAK_CHAR, FillBlanksConfig.BLANK_FIELD_CHAR)
+        private val contentRegex: Regex =
+            "
(.*?)
".toRegex(DotMatchesAllRegexOption) + } + + private var cachedLanguage: String? = null + private var cachedItems: List = emptyList() + private var inputItemIndices: List = emptyList() + + fun map(attempt: Attempt, submission: Submission?): FillBlanksData? = + attempt.dataset?.components?.let { + map( + componentsDataset = it, + replyBlanks = submission?.reply?.blanks + ) + } + + fun map(dataset: Dataset, reply: Reply?): FillBlanksData? = + dataset.components?.let { + map( + componentsDataset = it, + replyBlanks = reply?.blanks + ) + } + + internal fun map( + componentsDataset: List, + replyBlanks: List? + ): FillBlanksData? { + if (cachedItems.isNotEmpty()) { + return FillBlanksData( + fillBlanks = getCachedItems(cachedItems, componentsDataset, replyBlanks), + language = cachedLanguage + ) + } + + val textComponent = componentsDataset.first() + val rawText = textComponent.text ?: return null + + val match = contentRegex.find(rawText) + return if (match != null) { + val (langClass, content) = match.destructured + val inputComponents = componentsDataset.slice(from = 1) + val fillBlanksItems = splitContent(content) { id, inputIndex -> + FillBlanksItem.Input( + id = id, + inputText = getInputText(replyBlanks, inputComponents, inputIndex), + ) + } + val language = parseLanguage(langClass) + this.cachedItems = fillBlanksItems + this.cachedLanguage = langClass + this.inputItemIndices = fillBlanksItems.mapIndexedNotNull { index, fillBlanksItem -> + if (fillBlanksItem is FillBlanksItem.Input) index else null + } + this.cachedLanguage = language + FillBlanksData(fillBlanksItems, language) + } else { + null + } + } + + private fun getCachedItems( + cachedItems: List, + components: List, + replyBlanks: List? + ): List = + cachedItems.mutate { + inputItemIndices.forEachIndexed { inputIndex, itemIndex -> + set( + itemIndex, + FillBlanksItem.Input( + id = itemIndex, + inputText = getInputText( + replyBlanks = replyBlanks, + inputComponents = components.slice(from = 1), + inputIndex = inputIndex + ) + ) + ) + } + } + + private fun getInputText( + replyBlanks: List?, + inputComponents: List, + inputIndex: Int + ): String? = + replyBlanks?.getOrNull(inputIndex) + ?: inputComponents.getOrNull(inputIndex)?.text + + private fun parseLanguage(langClass: String): String? = + langClass + .trimIndent() + .removeSurrounding(LANGUAGE_CLASS_PREFIX, "\"") + .takeIf { it.isNotEmpty() } + + private fun splitContent( + content: String, + produceInputItem: (id: Int, inputIndex: Int) -> FillBlanksItem.Input + ): List { + var nextDelimiterIndex = content.indexOfAny(DELIMITERS) + if (nextDelimiterIndex == -1) { + return listOf( + getTextItem( + id = 0, + text = content, + startsWithNewLine = false, + ) + ) + } + return buildList { + var currentOffset = 0 + var previousDelimiterIsLineBreak = false + var inputIndex = 0 + var id = 0 + do { + add( + getTextItem( + id = id++, + text = content.substring(currentOffset, nextDelimiterIndex), + startsWithNewLine = previousDelimiterIsLineBreak + ) + ) + + val delimiter = content[nextDelimiterIndex] + if (delimiter == FillBlanksConfig.BLANK_FIELD_CHAR) { + add( + produceInputItem(id++, inputIndex++) + ) + } + + previousDelimiterIsLineBreak = delimiter == LINE_BREAK_CHAR + currentOffset = nextDelimiterIndex + 1 // skip delimiter, start with the next index + nextDelimiterIndex = content.indexOfAny(DELIMITERS, currentOffset) + } while (nextDelimiterIndex != -1) + add( + getTextItem( + id = id, + text = content.substring(currentOffset, content.length), + startsWithNewLine = previousDelimiterIsLineBreak + ) + ) + } + } + + private fun getTextItem( + id: Int, + text: String, + startsWithNewLine: Boolean + ): FillBlanksItem.Text { + val startWhiteSpacesAmount = text + .indexOfFirst { !it.isWhitespace() } + .let { if (it == -1) text.length else it } + return FillBlanksItem.Text( + id = id, + text = buildString { + repeat(startWhiteSpacesAmount) { + append(WHITE_SPACE_HTML_STRING) + } + append(text.trimStart()) + }, + startsWithNewLine = startsWithNewLine + ) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/presentation/FillBlanksResolver.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/presentation/FillBlanksResolver.kt new file mode 100644 index 0000000000..473946c5e7 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/presentation/FillBlanksResolver.kt @@ -0,0 +1,38 @@ +package org.hyperskill.app.step_quiz_fill_blanks.presentation + +import org.hyperskill.app.step_quiz.domain.model.attempts.Component +import org.hyperskill.app.step_quiz.domain.model.attempts.Dataset +import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksConfig +import org.hyperskill.app.step_quiz_fill_blanks.model.InvalidFillBlanksConfigException +import ru.nobird.app.core.model.slice + +object FillBlanksResolver { + + @Throws(InvalidFillBlanksConfigException::class) + fun resolve(dataset: Dataset) { + if (dataset.components.isNullOrEmpty()) { + throw InvalidFillBlanksConfigException("Components should not be empty") + } + + val textComponent = dataset.components.first() + if (textComponent.type != Component.Type.TEXT) { + throw InvalidFillBlanksConfigException("First component must be of type \"text\"") + } + + val blanksComponents = dataset.components.slice(from = 1) + + val isInputMode = blanksComponents.all { it.type == Component.Type.INPUT } + if (!isInputMode) { + throw InvalidFillBlanksConfigException("All components except the first must be of type \"input\"") + } + + val blankFieldsCount = textComponent.text?.count { it == FillBlanksConfig.BLANK_FIELD_CHAR } + if (blanksComponents.count() != blankFieldsCount) { + throw InvalidFillBlanksConfigException( + """Number of blanks \"$FillBlanksConfig.BLANK_FIELD_CHAR\" in text component + must be equal to number of components of type \"select\" or \"input\" + """.trimMargin() + ) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/resources/MR/base/strings.xml b/shared/src/commonMain/resources/MR/base/strings.xml index 9972eac97b..d7c58d7453 100644 --- a/shared/src/commonMain/resources/MR/base/strings.xml +++ b/shared/src/commonMain/resources/MR/base/strings.xml @@ -164,6 +164,10 @@ You\'ve unlocked a new problem type! Now you can rearrange the lines to make the code work. Use the "Tab" button to add indentation. + + + Fill in the gaps + Work on project. Stage %d/%d IDE required diff --git a/shared/src/commonMain/resources/MR/colors/colors.xml b/shared/src/commonMain/resources/MR/colors/colors.xml index 88dc567e16..e03331c1d4 100644 --- a/shared/src/commonMain/resources/MR/colors/colors.xml +++ b/shared/src/commonMain/resources/MR/colors/colors.xml @@ -23,6 +23,7 @@ #EDEDED #F7F9FA + #F7F9FA99 #EF341E #EF341E1F @@ -61,6 +62,7 @@ #121212 #1E1E1E #C4C4C4 + #12121299 #000000DE #00000099 #00000061 @@ -83,6 +85,10 @@ @color/color_gray_50 @color/color_black_850 + + @color/color_gray_50_alpha_60 + @color/color_black_850_alpha_60 + @color/color_black_900 @color/color_white_50 diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/FillBlanksMapperTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/FillBlanksMapperTest.kt new file mode 100644 index 0000000000..5ab93af28b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/FillBlanksMapperTest.kt @@ -0,0 +1,148 @@ +package org.hyperskill.step_quiz + +import kotlin.test.Test +import kotlin.test.assertEquals +import org.hyperskill.app.step_quiz.domain.model.attempts.Component +import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksItem +import org.hyperskill.app.step_quiz_fill_blanks.presentation.FillBlanksItemMapper + +class FillBlanksMapperTest { + /* ktlint-disable */ + companion object { + private const val LANGUAGE_NAME = "typescript" + private const val CONTENT = "Mark the following function's return type as string:\n\ndef function1() ▭:\n return \"This function should return a string!\" \n\nMark the following function's return type as a set of floats:\n\ndef function2() ▭:\n return {1, 2, 3, 4} " + private const val TEXT = "

def function1() [...]:

$CONTENT
" + } + + private fun expectedItems(firstReply: String? = null, secondReply: String? = null) = + listOf( + FillBlanksItem.Text(0, "Mark the following function's return type as string:", false), + FillBlanksItem.Text(1, "", true), + FillBlanksItem.Text(2, "def function1() ", true), + FillBlanksItem.Input(3, firstReply), + FillBlanksItem.Text(4, ":", startsWithNewLine = false), + FillBlanksItem.Text(5, "    return \"This function should return a string!\" ", true), + FillBlanksItem.Text(6, text = "", startsWithNewLine = true), + FillBlanksItem.Text( + 7, + text = "Mark the following function's return type as a set of floats:", + startsWithNewLine = true + ), + FillBlanksItem.Text(8, text = "", startsWithNewLine = true), + FillBlanksItem.Text(9, text = "def function2() ", startsWithNewLine = true), + FillBlanksItem.Input(10, secondReply), + FillBlanksItem.Text(11, ":", startsWithNewLine = false), + FillBlanksItem.Text(12,"    return {1, 2, 3, 4} ", startsWithNewLine = true) + ) + + @Test + fun `FillBlanksMapper should correctly split text`() { + val result = FillBlanksItemMapper().map( + componentsDataset = listOf( + Component( + type = Component.Type.TEXT, + text = TEXT + ), + Component( + type = Component.Type.INPUT + ), + Component( + type = Component.Type.INPUT + ) + ), + replyBlanks = null + ) + assertEquals(expectedItems(), result?.fillBlanks) + } + + @Test + fun `FillBlanksMapper should use reply for inputs`() { + val firstReply = "1" + val secondReply = "2" + val result = FillBlanksItemMapper().map( + componentsDataset = listOf( + Component( + type = Component.Type.TEXT, + text = TEXT + ), + Component( + type = Component.Type.INPUT + ), + Component( + type = Component.Type.INPUT + ) + ), + replyBlanks = listOf(firstReply, secondReply) + ) + assertEquals( + expectedItems(firstReply, secondReply), + result?.fillBlanks + ) + } + + @Test + fun `FillBlanksMapper should extract language name from the CODE tag`() { + val result = FillBlanksItemMapper().map( + componentsDataset = listOf( + Component( + type = Component.Type.TEXT, + text = TEXT + ) + ), + replyBlanks = null + ) + assertEquals(LANGUAGE_NAME, result?.language) + } + + @Test + fun `Second call to the FillBlanksMapper should return correct result`() { + val mapper = FillBlanksItemMapper() + val components = listOf( + Component( + type = Component.Type.TEXT, + text = TEXT + ), + Component( + type = Component.Type.INPUT + ), + Component( + type = Component.Type.INPUT + ) + ) + mapper.map( + componentsDataset = components, + replyBlanks = listOf("1", "2") + ) + + val firstExpectedInput = "3" + val secondExpectedInput = "4" + val actualResult = mapper.map( + componentsDataset = components, + replyBlanks = listOf(firstExpectedInput, secondExpectedInput) + ) + assertEquals( + expectedItems(firstExpectedInput, secondExpectedInput), + actualResult?.fillBlanks + ) + } + + @Test + fun `Second call to the FillBlanksMapper should return the same language`() { + val mapper = FillBlanksItemMapper() + val componentsDataset = listOf( + Component( + type = Component.Type.TEXT, + text = TEXT + ) + ) + mapper.map( + componentsDataset = componentsDataset, + replyBlanks = null + ) + val secondResult = mapper.map( + componentsDataset = componentsDataset, + replyBlanks = null + ) + assertEquals(LANGUAGE_NAME, secondResult?.language) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/FillBlanksResolverTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/FillBlanksResolverTest.kt new file mode 100644 index 0000000000..fad290ed7e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/FillBlanksResolverTest.kt @@ -0,0 +1,100 @@ +package org.hyperskill.step_quiz + +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import org.hyperskill.app.step_quiz.domain.model.attempts.Component +import org.hyperskill.app.step_quiz.domain.model.attempts.Dataset +import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksConfig +import org.hyperskill.app.step_quiz_fill_blanks.model.InvalidFillBlanksConfigException +import org.hyperskill.app.step_quiz_fill_blanks.presentation.FillBlanksResolver + +class FillBlanksResolverTest { + + companion object { + private val TWO_BLANKS_TEXT = + """ + Begin of the text ${FillBlanksConfig.BLANK_FIELD_CHAR} + and the rest ${FillBlanksConfig.BLANK_FIELD_CHAR} + """.trimIndent() + } + @Test + fun `Data set with empty components must fail resolving`() { + assertResolvingFailed( + Dataset(components = emptyList()) + ) + } + + @Test + fun `The first component should be a TEXT component`() { + listOf( + Component.Type.INPUT, + Component.Type.SELECT + ).forEach { componentType -> + assertResolvingFailed( + getComponentsDataSet(componentType) + ) + } + } + + @Test + fun `All components except of the first one should be of type INPUT`() { + listOf( + Component.Type.TEXT, + Component.Type.SELECT + ).forEach { componentType -> + assertResolvingFailed( + getComponentsDataSet(Component.Type.TEXT, componentType) + ) + } + } + + @Test + fun `Number of blanks in text component should be equal to number of components of type INPUT`() { + val correctTextComponent = Component( + type = Component.Type.TEXT, + text = TWO_BLANKS_TEXT + ) + + listOf( + Component.Type.INPUT, + Component.Type.SELECT + ).forEach { wrongType -> + val wrongDataSet = Dataset( + components = listOf( + correctTextComponent, + Component(type = wrongType) + ) + ) + assertResolvingFailed(wrongDataSet) + } + + assertResolvingPassed( + Dataset( + components = listOf( + correctTextComponent, + Component(type = Component.Type.INPUT), + Component(type = Component.Type.INPUT) + ) + ) + ) + } + + private fun getComponentsDataSet(vararg componentTypes: Component.Type): Dataset = + Dataset( + components = componentTypes.map { Component(type = it) } + ) + + private fun assertResolvingFailed(dataset: Dataset) { + assertFailsWith { + FillBlanksResolver.resolve(dataset) + } + } + + private fun assertResolvingPassed(dataset: Dataset) { + assertTrue { + FillBlanksResolver.resolve(dataset) + true + } + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/core/utils/RegexUtils.ios.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/core/utils/RegexUtils.ios.kt new file mode 100644 index 0000000000..fa032f3662 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/core/utils/RegexUtils.ios.kt @@ -0,0 +1,4 @@ +package org.hyperskill.app.core.utils + +actual val DotMatchesAllRegexOption: RegexOption + get() = RegexOption.DOT_MATCHES_ALL \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt index b95c50a5db..18d4e444be 100644 --- a/shared/src/iosMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt @@ -12,5 +12,6 @@ actual val BlockName.supportedBlocksNames: Set STRING, MATH, NUMBER, - PARSONS + PARSONS, + FILL_BLANKS ) \ No newline at end of file