From 8b4b2505e361e069934695b0c37e692553f3807d Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Mon, 30 Oct 2023 16:23:58 +0400 Subject: [PATCH 1/3] Shared fill in the blanks with selects (#724) ^ALTAPPS-1019 --- .../FillBlanksStepQuizFormDelegate.kt | 15 +- .../ViewData/StepQuizViewDataMapper.swift | 12 +- .../StepQuizFillBlanksAssembly.swift | 2 +- .../StepQuizFillBlanksViewDataMapper.swift | 3 + .../model/FillBlanksData.kt | 3 +- .../model/FillBlanksItem.kt | 13 ++ .../model/FillBlanksMode.kt | 6 + .../model/FillBlanksOption.kt | 6 + .../presentation/FillBlanksItemMapper.kt | 155 ++++++++++++---- .../presentation/FillBlanksResolver.kt | 36 +++- .../FillBlanksMapperInputModeTest.kt} | 17 +- .../FillBlanksMapperSelectModeTest.kt | 166 ++++++++++++++++++ .../FillBlanksResolverTest.kt | 75 +++++++- 13 files changed, 459 insertions(+), 50 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksMode.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksOption.kt rename shared/src/commonTest/kotlin/org/hyperskill/{step_quiz/FillBlanksMapperTest.kt => step_quiz_fill_blanks/FillBlanksMapperInputModeTest.kt} (89%) create mode 100644 shared/src/commonTest/kotlin/org/hyperskill/step_quiz_fill_blanks/FillBlanksMapperSelectModeTest.kt rename shared/src/commonTest/kotlin/org/hyperskill/{step_quiz => step_quiz_fill_blanks}/FillBlanksResolverTest.kt (57%) 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 index c22f5c9bd0..158ae6dbdb 100644 --- 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 @@ -15,6 +15,7 @@ import org.hyperskill.app.android.step_quiz_fill_blanks.dialog.FillBlanksInputDi 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.FillBlanksMode 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 @@ -37,7 +38,8 @@ class FillBlanksStepQuizFormDelegate( ) } - private val fillBlanksMapper: FillBlanksItemMapper = FillBlanksItemMapper() + // TODO: ALTAPPS-1021 provide corresponding mode + private val fillBlanksMapper: FillBlanksItemMapper = FillBlanksItemMapper(mode = FillBlanksMode.INPUT) private var resolveState: ResolveState = ResolveState.NOT_RESOLVED @@ -79,8 +81,11 @@ class FillBlanksStepQuizFormDelegate( val dataset = state.attempt.dataset if (dataset != null) { try { - FillBlanksResolver.resolve(dataset) - ResolveState.RESOLVE_SUCCEED + // TODO: ALTAPPS-1021 handle resolve state + when (FillBlanksResolver.resolve(dataset)) { + FillBlanksMode.INPUT -> ResolveState.RESOLVE_SUCCEED + FillBlanksMode.SELECT -> ResolveState.RESOLVE_FAILED + } } catch (e: InvalidFillBlanksConfigException) { ResolveState.RESOLVE_FAILED } @@ -97,6 +102,10 @@ class FillBlanksStepQuizFormDelegate( when (item) { is FillBlanksItem.Input -> item.inputText is FillBlanksItem.Text -> null + is FillBlanksItem.Select -> { + // TODO: ALTAPPS-1021 provide reply blanks for select mode + null + } } } ) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift index 212b326f0d..2feac5aad6 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift @@ -110,7 +110,17 @@ final class StepQuizViewDataMapper { } do { - try FillBlanksResolver.shared.resolve(dataset: dataset) + let mode = try FillBlanksResolver.shared.resolve(dataset: dataset) + + switch mode { + case .input: + return childQuizType + case .select: + return unsupportedChildQuizType + default: + assertionFailure("StepQuizViewDataMapper: unsupported fill blanks mode \(mode)") + return unsupportedChildQuizType + } } catch { #if DEBUG print("StepQuizViewDataMapper: failed to resolve fill blanks quiz type, error = \(error)") diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift index 5beefa3215..99e236dcfa 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift @@ -32,7 +32,7 @@ final class StepQuizFillBlanksAssembly: StepQuizChildQuizAssembly { dataset: dataset, reply: reply, viewDataMapper: StepQuizFillBlanksViewDataMapper( - fillBlanksItemMapper: FillBlanksItemMapper(), + fillBlanksItemMapper: FillBlanksItemMapper(mode: .input), highlightr: Highlightr().require(), codeEditorThemeService: CodeEditorThemeService(), cache: StepQuizFillBlanksViewDataMapperCache.shared diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift index 7bc5b2eef3..9fdf2938c5 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift @@ -80,6 +80,9 @@ final class StepQuizFillBlanksViewDataMapper { return result case .input(let data): return [StepQuizFillBlankComponent(type: .input, inputText: data.inputText)] + case .select(let data): + print(data) + return [] } } 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 index 4da654a3e5..b576e8e639 100644 --- 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 @@ -2,5 +2,6 @@ package org.hyperskill.app.step_quiz_fill_blanks.model data class FillBlanksData( val fillBlanks: List, - val language: String? + val language: String?, + val options: List ) \ 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 index 11c3331e29..9c741cd79a 100644 --- 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 @@ -28,4 +28,17 @@ sealed interface FillBlanksItem : Identifiable { override val id: Int, val inputText: String? ) : FillBlanksItem + + /** + * Represents a select fill-in-the-blank item in a quiz. + * + * @property id The order number in a list of items. + * @property selectedOptionIndex The order number in a list of options. Null if no option is selected. + * + * @see FillBlanksData.options + */ + data class Select( + override val id: Int, + val selectedOptionIndex: Int? + ) : FillBlanksItem } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksMode.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksMode.kt new file mode 100644 index 0000000000..4af9db0c37 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksMode.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.step_quiz_fill_blanks.model + +enum class FillBlanksMode { + INPUT, + SELECT +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksOption.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksOption.kt new file mode 100644 index 0000000000..0e9e860d5f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_fill_blanks/model/FillBlanksOption.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.step_quiz_fill_blanks.model + +data class FillBlanksOption( + val originalText: String, + val displayText: String +) \ 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 index 055b08ff69..707c6b9e11 100644 --- 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 @@ -9,6 +9,8 @@ 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.model.FillBlanksMode +import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksOption 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 @@ -29,7 +31,7 @@ import ru.nobird.app.core.model.slice * * Fill the [FillBlanksItem.Input] with the data from [Reply].blanks or [Component].text with the type == "input". */ -class FillBlanksItemMapper { +class FillBlanksItemMapper(private val mode: FillBlanksMode) { companion object { private const val LINE_BREAK_CHAR = '\n' private const val LANGUAGE_CLASS_PREFIX = "class=\"language-" @@ -38,11 +40,15 @@ class FillBlanksItemMapper { private val DELIMITERS = charArrayOf(LINE_BREAK_CHAR, FillBlanksConfig.BLANK_FIELD_CHAR) private val contentRegex: Regex = "
(.*?)
".toRegex(DotMatchesAllRegexOption) + private val blankOptionRegex: Regex = + "(.*?)".toRegex(DotMatchesAllRegexOption) } private var cachedLanguage: String? = null private var cachedItems: List = emptyList() private var inputItemIndices: List = emptyList() + private var selectItemIndices: List = emptyList() + private var cachedOptions: List = emptyList() fun map(attempt: Attempt, submission: Submission?): FillBlanksData? = attempt.dataset?.components?.let { @@ -67,7 +73,8 @@ class FillBlanksItemMapper { if (cachedItems.isNotEmpty()) { return FillBlanksData( fillBlanks = getCachedItems(cachedItems, componentsDataset, replyBlanks), - language = cachedLanguage + language = cachedLanguage, + options = cachedOptions ) } @@ -77,21 +84,49 @@ class FillBlanksItemMapper { 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 blanksComponents = componentsDataset.slice(from = 1) + + val blankOptions = getBlankOptions(blanksComponents) + + val fillBlanksItems = splitContent( + content = content, + produceInputItem = { id, inputIndex -> + FillBlanksItem.Input( + id = id, + inputText = getInputText(replyBlanks, blanksComponents, inputIndex), + ) + }, + produceSelectItem = { id, optionIndex -> + FillBlanksItem.Select( + id = id, + selectedOptionIndex = getSelectedOptionIndex(replyBlanks, blankOptions, optionIndex) + ) + } + ) + val language = parseLanguage(langClass) + this.cachedItems = fillBlanksItems - this.cachedLanguage = langClass - this.inputItemIndices = fillBlanksItems.mapIndexedNotNull { index, fillBlanksItem -> - if (fillBlanksItem is FillBlanksItem.Input) index else null + when (mode) { + FillBlanksMode.INPUT -> { + this.inputItemIndices = fillBlanksItems.mapIndexedNotNull { index, fillBlanksItem -> + if (fillBlanksItem is FillBlanksItem.Input) index else null + } + } + FillBlanksMode.SELECT -> { + this.selectItemIndices = fillBlanksItems.mapIndexedNotNull { index, fillBlanksItem -> + if (fillBlanksItem is FillBlanksItem.Select) index else null + } + } } this.cachedLanguage = language - FillBlanksData(fillBlanksItems, language) + this.cachedOptions = blankOptions + + FillBlanksData( + fillBlanks = fillBlanksItems, + language = language, + options = blankOptions + ) } else { null } @@ -103,18 +138,37 @@ class FillBlanksItemMapper { 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 + when (mode) { + FillBlanksMode.INPUT -> { + inputItemIndices.forEachIndexed { inputIndex, itemIndex -> + set( + itemIndex, + FillBlanksItem.Input( + id = itemIndex, + inputText = getInputText( + replyBlanks = replyBlanks, + inputComponents = components.slice(from = 1), + inputIndex = inputIndex + ) + ) ) - ) - ) + } + } + FillBlanksMode.SELECT -> { + selectItemIndices.forEachIndexed { optionIndex, itemIndex -> + set( + itemIndex, + FillBlanksItem.Select( + id = itemIndex, + selectedOptionIndex = getSelectedOptionIndex( + replyBlanks = replyBlanks, + blankOptions = cachedOptions, + optionIndex = optionIndex + ) + ) + ) + } + } } } @@ -126,6 +180,17 @@ class FillBlanksItemMapper { replyBlanks?.getOrNull(inputIndex) ?: inputComponents.getOrNull(inputIndex)?.text + private fun getSelectedOptionIndex( + replyBlanks: List?, + blankOptions: List, + optionIndex: Int + ): Int? { + val replyOption = replyBlanks?.getOrNull(optionIndex) ?: return null + return blankOptions + .indexOfFirst { it.originalText == replyOption } + .takeIf { it != -1 } + } + private fun parseLanguage(langClass: String): String? = langClass .trimIndent() @@ -134,7 +199,8 @@ class FillBlanksItemMapper { private fun splitContent( content: String, - produceInputItem: (id: Int, inputIndex: Int) -> FillBlanksItem.Input + produceInputItem: (id: Int, inputIndex: Int) -> FillBlanksItem.Input, + produceSelectItem: (id: Int, optionIndex: Int) -> FillBlanksItem.Select ): List { var nextDelimiterIndex = content.indexOfAny(DELIMITERS) if (nextDelimiterIndex == -1) { @@ -149,7 +215,7 @@ class FillBlanksItemMapper { return buildList { var currentOffset = 0 var previousDelimiterIsLineBreak = false - var inputIndex = 0 + var blankIndex = 0 var id = 0 do { add( @@ -159,14 +225,17 @@ class FillBlanksItemMapper { startsWithNewLine = previousDelimiterIsLineBreak ) ) - val delimiter = content[nextDelimiterIndex] if (delimiter == FillBlanksConfig.BLANK_FIELD_CHAR) { - add( - produceInputItem(id++, inputIndex++) - ) + when (mode) { + FillBlanksMode.INPUT -> { + add(produceInputItem(id++, blankIndex++)) + } + FillBlanksMode.SELECT -> { + add(produceSelectItem(id++, blankIndex++)) + } + } } - previousDelimiterIsLineBreak = delimiter == LINE_BREAK_CHAR currentOffset = nextDelimiterIndex + 1 // skip delimiter, start with the next index nextDelimiterIndex = content.indexOfAny(DELIMITERS, currentOffset) @@ -200,4 +269,28 @@ class FillBlanksItemMapper { startsWithNewLine = startsWithNewLine ) } + + private fun getBlankOptions(blanksComponents: List): List = + when (mode) { + FillBlanksMode.INPUT -> emptyList() + FillBlanksMode.SELECT -> { + val blankOptions = blanksComponents.first().options ?: emptyList() + blankOptions.map(::mapBlankOption) + } + } + + private fun mapBlankOption(originalText: String): FillBlanksOption { + var displayText = originalText + + val match = blankOptionRegex.find(originalText) + if (match != null) { + val (option) = match.destructured + displayText = option + } + + return FillBlanksOption( + originalText = originalText, + displayText = displayText + ) + } } \ 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 index 473946c5e7..b5ac8c46c6 100644 --- 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 @@ -3,13 +3,14 @@ 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.FillBlanksMode 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) { + fun resolve(dataset: Dataset): FillBlanksMode { if (dataset.components.isNullOrEmpty()) { throw InvalidFillBlanksConfigException("Components should not be empty") } @@ -22,17 +23,44 @@ object FillBlanksResolver { 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 isSelectMode = blanksComponents.all { it.type == Component.Type.SELECT } + + if (!isInputMode && !isSelectMode) { + throw InvalidFillBlanksConfigException( + "All components except the first must be of type \"select\" or \"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 + """Number of blanks \"${FillBlanksConfig.BLANK_FIELD_CHAR}\" in text component must be equal to number of components of type \"select\" or \"input\" """.trimMargin() ) } + + if (isSelectMode) { + // It's impossible to use Set here because there could be duplicate options + val blankOptions = blanksComponents.first().options?.sorted() ?: emptyList() + + if (blankOptions.count() < blankFieldsCount) { + throw InvalidFillBlanksConfigException( + """"Number of options in component of type \"select\" must be greater or equal to number of blanks + \"${FillBlanksConfig.BLANK_FIELD_CHAR}\" in text component" + """.trimMargin() + ) + } + + if (blanksComponents.any { it.options?.sorted() != blankOptions }) { + throw InvalidFillBlanksConfigException( + "All components of type \"select\" must have the same options" + ) + } + + return FillBlanksMode.SELECT + } + + return FillBlanksMode.INPUT } } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/FillBlanksMapperTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_fill_blanks/FillBlanksMapperInputModeTest.kt similarity index 89% rename from shared/src/commonTest/kotlin/org/hyperskill/step_quiz/FillBlanksMapperTest.kt rename to shared/src/commonTest/kotlin/org/hyperskill/step_quiz_fill_blanks/FillBlanksMapperInputModeTest.kt index 5ab93af28b..52614f139d 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/FillBlanksMapperTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_fill_blanks/FillBlanksMapperInputModeTest.kt @@ -1,12 +1,13 @@ -package org.hyperskill.step_quiz +package org.hyperskill.step_quiz_fill_blanks 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.model.FillBlanksMode import org.hyperskill.app.step_quiz_fill_blanks.presentation.FillBlanksItemMapper -class FillBlanksMapperTest { +class FillBlanksMapperInputModeTest { /* ktlint-disable */ companion object { private const val LANGUAGE_NAME = "typescript" @@ -32,12 +33,12 @@ class FillBlanksMapperTest { 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) + FillBlanksItem.Text(12, "    return {1, 2, 3, 4} ", startsWithNewLine = true) ) @Test fun `FillBlanksMapper should correctly split text`() { - val result = FillBlanksItemMapper().map( + val result = FillBlanksItemMapper(FillBlanksMode.INPUT).map( componentsDataset = listOf( Component( type = Component.Type.TEXT, @@ -59,7 +60,7 @@ class FillBlanksMapperTest { fun `FillBlanksMapper should use reply for inputs`() { val firstReply = "1" val secondReply = "2" - val result = FillBlanksItemMapper().map( + val result = FillBlanksItemMapper(FillBlanksMode.INPUT).map( componentsDataset = listOf( Component( type = Component.Type.TEXT, @@ -82,7 +83,7 @@ class FillBlanksMapperTest { @Test fun `FillBlanksMapper should extract language name from the CODE tag`() { - val result = FillBlanksItemMapper().map( + val result = FillBlanksItemMapper(FillBlanksMode.INPUT).map( componentsDataset = listOf( Component( type = Component.Type.TEXT, @@ -96,7 +97,7 @@ class FillBlanksMapperTest { @Test fun `Second call to the FillBlanksMapper should return correct result`() { - val mapper = FillBlanksItemMapper() + val mapper = FillBlanksItemMapper(FillBlanksMode.INPUT) val components = listOf( Component( type = Component.Type.TEXT, @@ -128,7 +129,7 @@ class FillBlanksMapperTest { @Test fun `Second call to the FillBlanksMapper should return the same language`() { - val mapper = FillBlanksItemMapper() + val mapper = FillBlanksItemMapper(FillBlanksMode.INPUT) val componentsDataset = listOf( Component( type = Component.Type.TEXT, diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_fill_blanks/FillBlanksMapperSelectModeTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_fill_blanks/FillBlanksMapperSelectModeTest.kt new file mode 100644 index 0000000000..cecd940d85 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_fill_blanks/FillBlanksMapperSelectModeTest.kt @@ -0,0 +1,166 @@ +package org.hyperskill.step_quiz_fill_blanks + +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.model.FillBlanksMode +import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksOption +import org.hyperskill.app.step_quiz_fill_blanks.presentation.FillBlanksItemMapper + +class FillBlanksMapperSelectModeTest { + /* ktlint-disable */ + companion object { + private const val LANGUAGE_NAME = "python" + private const val CONTENT = + "#...\nclass Role(db.Model): \n id = db.Column(db.Integer, primary_key = True)\n name = db.Column(db.String(20), unique = True)\n permissions = db.Column(db.Integer)\n ▭ \n \n #...\n\nclass User(db.Model):\n id = db.Column(db.Integer, primary_key = True) \n username = db.Column(db.String(20), unique = True)\n ▭ " + private const val TEXT = "
$CONTENT
" + + private val OPTIONS = listOf( + "users = db.relationship(\"User\", backref = \"role\")", + "role_id = db.Column(db.Integer, db.ForeignKey(\"role.id\"))", + "user_id = db.Column(db.Integer, db.ForeignKey(\"user.id\"))", + "roles = db.relationship(\"User\", backref = \"user)\"" + ) + private val EXPECTED_OPTIONS = listOf( + FillBlanksOption( + originalText = "users = db.relationship(\"User\", backref = \"role\")", + displayText = "users = db.relationship(\"User\", backref = \"role\")" + ), + FillBlanksOption( + originalText = "role_id = db.Column(db.Integer, db.ForeignKey(\"role.id\"))", + displayText = "role_id = db.Column(db.Integer, db.ForeignKey(\"role.id\"))" + ), + FillBlanksOption( + originalText = "user_id = db.Column(db.Integer, db.ForeignKey(\"user.id\"))", + displayText = "user_id = db.Column(db.Integer, db.ForeignKey(\"user.id\"))" + ), + FillBlanksOption( + originalText = "roles = db.relationship(\"User\", backref = \"user)\"", + displayText = "roles = db.relationship(\"User\", backref = \"user)\"" + ) + ) + } + + private fun expectedItems(firstReply: Int? = null, secondReply: Int? = null) = + listOf( + FillBlanksItem.Text(0, "#...", false), + FillBlanksItem.Text(1, "class Role(db.Model): ", true), + FillBlanksItem.Text(2, "    id = db.Column(db.Integer, primary_key = True)", true), + FillBlanksItem.Text(3, "    name = db.Column(db.String(20), unique = True)", true), + FillBlanksItem.Text( + 4, + "    permissions = db.Column(db.Integer)", + startsWithNewLine = true + ), + FillBlanksItem.Text(5, "    ", true), + FillBlanksItem.Select(6, selectedOptionIndex = firstReply), + FillBlanksItem.Text( + 7, + text = "           ", + startsWithNewLine = false + ), + FillBlanksItem.Text(8, text = "    ", startsWithNewLine = true), + FillBlanksItem.Text(9, text = "    #...", startsWithNewLine = true), + FillBlanksItem.Text(10, text = "", startsWithNewLine = true), + FillBlanksItem.Text(11, text = "class User(db.Model):", startsWithNewLine = true), + FillBlanksItem.Text( + 12, + text = "    id = db.Column(db.Integer, primary_key = True) ", + startsWithNewLine = true + ), + FillBlanksItem.Text( + id = 13, + text = "    username = db.Column(db.String(20), unique = True)", + true + ), + FillBlanksItem.Text(id = 14, text = "    ", true), + FillBlanksItem.Select(id = 15, selectedOptionIndex = secondReply), + FillBlanksItem.Text(id = 16, text = "    ", false) + ) + + @Test + fun `FillBlanksMapper should correctly split text`() { + val result = FillBlanksItemMapper(FillBlanksMode.SELECT).map( + componentsDataset = listOf( + Component( + type = Component.Type.TEXT, + text = TEXT + ), + Component( + type = Component.Type.SELECT, + options = OPTIONS + ), + Component( + type = Component.Type.SELECT, + options = OPTIONS + ) + ), + replyBlanks = null + ) + + assertEquals(expectedItems(), result?.fillBlanks) + assertEquals(LANGUAGE_NAME, result?.language) + assertEquals(EXPECTED_OPTIONS, result?.options) + } + + @Test + fun `FillBlanksMapper should use reply for selects`() { + val firstSelectedOptionIndex = 2 + val secondSelectedOptionIndex = 0 + + val result = FillBlanksItemMapper(FillBlanksMode.SELECT).map( + componentsDataset = listOf( + Component( + type = Component.Type.TEXT, + text = TEXT + ), + Component( + type = Component.Type.SELECT, + options = OPTIONS + ), + Component( + type = Component.Type.SELECT, + options = OPTIONS + ) + ), + replyBlanks = listOf(OPTIONS[firstSelectedOptionIndex], OPTIONS[secondSelectedOptionIndex]) + ) + assertEquals( + expectedItems(firstSelectedOptionIndex, secondSelectedOptionIndex), + result?.fillBlanks + ) + } + + @Test + fun `Second call to the FillBlanksMapper should return correct result`() { + val mapper = FillBlanksItemMapper(FillBlanksMode.SELECT) + val components = listOf( + Component( + type = Component.Type.TEXT, + text = TEXT + ), + Component( + type = Component.Type.SELECT, + options = OPTIONS + ), + Component( + type = Component.Type.SELECT, + options = OPTIONS + ) + ) + mapper.map( + componentsDataset = components, + replyBlanks = listOf(OPTIONS[0], OPTIONS[1]) + ) + + val actualResult = mapper.map( + componentsDataset = components, + replyBlanks = listOf(OPTIONS[1], OPTIONS[2]) + ) + assertEquals( + expectedItems(1, 2), + actualResult?.fillBlanks + ) + } +} \ 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_fill_blanks/FillBlanksResolverTest.kt similarity index 57% rename from shared/src/commonTest/kotlin/org/hyperskill/step_quiz/FillBlanksResolverTest.kt rename to shared/src/commonTest/kotlin/org/hyperskill/step_quiz_fill_blanks/FillBlanksResolverTest.kt index fad290ed7e..6db5e44d02 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/FillBlanksResolverTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz_fill_blanks/FillBlanksResolverTest.kt @@ -1,4 +1,4 @@ -package org.hyperskill.step_quiz +package org.hyperskill.step_quiz_fill_blanks import kotlin.test.Test import kotlin.test.assertFailsWith @@ -18,6 +18,7 @@ class FillBlanksResolverTest { and the rest ${FillBlanksConfig.BLANK_FIELD_CHAR} """.trimIndent() } + @Test fun `Data set with empty components must fail resolving`() { assertResolvingFailed( @@ -80,6 +81,78 @@ class FillBlanksResolverTest { ) } + @Test + fun `All components of type SELECT must have the same options`() { + val correctTextComponent = Component( + type = Component.Type.TEXT, + text = TWO_BLANKS_TEXT + ) + + Dataset( + components = listOf( + correctTextComponent, + Component( + type = Component.Type.SELECT, + options = listOf("1", "2") + ), + Component( + type = Component.Type.SELECT, + options = listOf("2", "1") + ) + ) + ).let(::assertResolvingPassed) + + Dataset( + components = listOf( + correctTextComponent, + Component( + type = Component.Type.SELECT, + options = listOf("1", "2") + ), + Component( + type = Component.Type.SELECT, + options = listOf("1") + ) + ) + ).let(::assertResolvingFailed) + } + + @Test + fun `Number of options in SELECT component must be greater or equal to number of blanks`() { + val correctTextComponent = Component( + type = Component.Type.TEXT, + text = TWO_BLANKS_TEXT + ) + + Dataset( + components = listOf( + correctTextComponent, + Component( + type = Component.Type.SELECT, + options = listOf("1") + ), + Component( + type = Component.Type.SELECT, + options = listOf("1") + ) + ) + ).let(::assertResolvingFailed) + + Dataset( + components = listOf( + correctTextComponent, + Component( + type = Component.Type.SELECT, + options = listOf("1", "1") + ), + Component( + type = Component.Type.SELECT, + options = listOf("1", "1") + ) + ) + ).let(::assertResolvingPassed) + } + private fun getComponentsDataSet(vararg componentTypes: Component.Type): Dataset = Dataset( components = componentTypes.map { Component(type = it) } From 8d9df6caf69eaf0d02b6a66b6efb6cdcd0929e5f Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Mon, 30 Oct 2023 16:28:24 +0400 Subject: [PATCH 2/3] iOS fill in the blanks with selects (#725) ^ALTAPPS-1020 --- .../project.pbxproj | 122 +++++++++++++- .../Frameworks/Core/Injection/AppGraph.swift | 2 +- .../Injection/IosFCMTokenProviderImpl.swift | 2 + .../StepQuizChildQuizAssembly.swift | 3 +- .../StepQuizChildQuizType.swift | 35 +++- .../Modules/StepQuiz/StepQuizViewModel.swift | 24 ++- .../ViewData/StepQuizViewDataMapper.swift | 47 +----- .../Views/StepQuizSkeletonViewFactory.swift | 3 +- .../Modules/StepQuiz/Views/StepQuizView.swift | 115 ++++++++++---- .../StepQuizFillBlanksOutputProtocol.swift | 9 ++ .../StepQuizFillBlanksAssembly.swift | 18 ++- .../StepQuizFillBlanksViewModel.swift | 123 +++++++++++++- .../ViewData/FillBlanksModeWrapper.swift | 30 ++++ .../ViewData/StepQuizFillBlanksViewData.swift | 9 ++ .../StepQuizFillBlanksViewDataMapper.swift | 11 +- .../Views/FillBlanksQuizViewWrapper.swift | 2 + .../StepQuizFillBlanksSkeletonView.swift | 82 ++++++++++ .../Views/StepQuizFillBlanksView.swift | 1 + .../FillBlanksInputCollectionViewCell.swift | 6 +- .../Input/FillBlanksInputContainerView.swift} | 4 +- .../FillBlanksSelectCollectionViewCell.swift | 136 ++++++++++++++++ .../FillBlanksSelectContainerView.swift | 101 ++++++++++++ .../FillBlanksQuizCollectionViewAdapter.swift | 30 +++- .../Views/UIKit/FillBlanksQuizView.swift | 1 + ...FillBlanksSelectOptionsInputProtocol.swift | 5 + ...illBlanksSelectOptionsOutputProtocol.swift | 5 + ...izFillBlanksSelectOptionsViewWrapper.swift | 150 ++++++++++++++++++ ...lanksSelectOptionsCollectionViewCell.swift | 136 ++++++++++++++++ ...tionsCollectionViewCellContainerView.swift | 91 +++++++++++ ...ksSelectOptionsCollectionViewAdapter.swift | 84 ++++++++++ .../StepQuizFillBlanksSelectOptionsView.swift | 114 +++++++++++++ .../Views/StepQuizHintsView.swift | 10 +- .../StepQuizStringAssembly.swift | 9 +- .../presentation/StepQuizStateExtentions.kt | 11 ++ 34 files changed, 1417 insertions(+), 114 deletions(-) create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/InputOutput/StepQuizFillBlanksOutputProtocol.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/FillBlanksModeWrapper.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksSkeletonView.swift rename iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/{ => Input}/FillBlanksInputCollectionViewCell.swift (95%) rename iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/{FillBlanksQuizInputContainerView.swift => Cells/Input/FillBlanksInputContainerView.swift} (94%) create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Select/FillBlanksSelectCollectionViewCell.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Select/FillBlanksSelectContainerView.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/InputOutput/StepQuizFillBlanksSelectOptionsInputProtocol.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/InputOutput/StepQuizFillBlanksSelectOptionsOutputProtocol.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/StepQuizFillBlanksSelectOptionsViewWrapper.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/Cell/StepQuizFillBlanksSelectOptionsCollectionViewCell.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/Cell/StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/StepQuizFillBlanksSelectOptionsView.swift create mode 100644 shared/src/iosMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizStateExtentions.kt diff --git a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj index 6c09d5588c..1eb6575723 100644 --- a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj +++ b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj @@ -67,6 +67,12 @@ 2C11D5CA2A11311900C59238 /* FeedbackGeneratorPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C11D5C92A11311900C59238 /* FeedbackGeneratorPreviewView.swift */; }; 2C177EC32837B65500D841DB /* View+Frame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C177EC22837B65500D841DB /* View+Frame.swift */; }; 2C1860FC2923C540007D4EBF /* AppFeatureStateKsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1860FB2923C540007D4EBF /* AppFeatureStateKsExtensions.swift */; }; + 2C198DFE2AEA444100DCD35A /* FillBlanksSelectContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C198DFD2AEA444100DCD35A /* FillBlanksSelectContainerView.swift */; }; + 2C198E012AEA835F00DCD35A /* StepQuizFillBlanksSelectOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C198E002AEA835F00DCD35A /* StepQuizFillBlanksSelectOptionsView.swift */; }; + 2C198E032AEA869300DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C198E022AEA869300DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCell.swift */; }; + 2C198E052AEA86DF00DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C198E042AEA86DF00DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift */; }; + 2C198E082AEA8BB900DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C198E072AEA8BB900DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift */; }; + 2C198E0A2AEA904800DCD35A /* StepQuizFillBlanksSelectOptionsViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C198E092AEA904800DCD35A /* StepQuizFillBlanksSelectOptionsViewWrapper.swift */; }; 2C1F5869280D063800372A37 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1F5868280D063800372A37 /* WebViewController.swift */; }; 2C1F586B280D094A00372A37 /* WebControllerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1F586A280D094A00372A37 /* WebControllerManager.swift */; }; 2C1F5870280D0CB700372A37 /* WebCacheCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1F586F280D0CB700372A37 /* WebCacheCleaner.swift */; }; @@ -160,6 +166,7 @@ 2C48232B2AC172600047999B /* LaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C48232A2AC172600047999B /* LaunchTests.swift */; }; 2C4823322AC1768A0047999B /* LaunchPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4823312AC1768A0047999B /* LaunchPerformanceTests.swift */; }; 2C49CC922851D4AC0040BA7F /* altcontent.css in Resources */ = {isa = PBXBuildFile; fileRef = 2C49CC912851D4AC0040BA7F /* altcontent.css */; }; + 2C4D6EF42AEF9ECC000064C7 /* StepQuizFillBlanksSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4D6EF32AEF9ECC000064C7 /* StepQuizFillBlanksSkeletonView.swift */; }; 2C4F639B2A101DCE00D4EE39 /* ProjectSelectionListGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4F639A2A101DCE00D4EE39 /* ProjectSelectionListGridView.swift */; }; 2C4F639E2A10203000D4EE39 /* ProjectSelectionListGridSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4F639D2A10203000D4EE39 /* ProjectSelectionListGridSectionView.swift */; }; 2C4F63A12A102D3300D4EE39 /* SharedProjectLevelWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4F63A02A102D3300D4EE39 /* SharedProjectLevelWrapper.swift */; }; @@ -178,6 +185,7 @@ 2C55133B28B8DFE8009F7627 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C55133A28B8DFE8009F7627 /* Debouncer.swift */; }; 2C55E1902A056AFC00FE58D7 /* ProblemsLimitSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C55E18F2A056AFC00FE58D7 /* ProblemsLimitSkeletonView.swift */; }; 2C55E1922A05706300FE58D7 /* HomeSubheadlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C55E1912A05706300FE58D7 /* HomeSubheadlineView.swift */; }; + 2C56611A2AEA418D00D607FB /* FillBlanksSelectCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5661192AEA418D00D607FB /* FillBlanksSelectCollectionViewCell.swift */; }; 2C58DE232803BF97002A2774 /* AuthLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C58DE222803BF97002A2774 /* AuthLogoView.swift */; }; 2C58DE252803C185002A2774 /* AuthSocialControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C58DE242803C185002A2774 /* AuthSocialControlsView.swift */; }; 2C58DE292803D197002A2774 /* UIColor+DynamicColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C58DE282803D197002A2774 /* UIColor+DynamicColor.swift */; }; @@ -209,6 +217,8 @@ 2C66720B2A52974A0040EA2F /* ProgressScreenSectionTitleSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C66720A2A52974A0040EA2F /* ProgressScreenSectionTitleSkeletonView.swift */; }; 2C66720D2A5299C30040EA2F /* ProgressScreenCardSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C66720C2A5299C30040EA2F /* ProgressScreenCardSkeletonView.swift */; }; 2C66720F2A529A7C0040EA2F /* ProgressScreenTrackProgressSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C66720E2A529A7C0040EA2F /* ProgressScreenTrackProgressSkeletonView.swift */; }; + 2C6770992AEBDF3A00E832AC /* StepQuizFillBlanksSelectOptionsInputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6770982AEBDF3A00E832AC /* StepQuizFillBlanksSelectOptionsInputProtocol.swift */; }; + 2C67709B2AEBDF5200E832AC /* StepQuizFillBlanksSelectOptionsOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C67709A2AEBDF5200E832AC /* StepQuizFillBlanksSelectOptionsOutputProtocol.swift */; }; 2C688C032A4E8F900061AFFD /* ProgressScreenTrackProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C688C022A4E8F900061AFFD /* ProgressScreenTrackProgressView.swift */; }; 2C688C052A4E97750061AFFD /* ProgressScreenProjectProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C688C042A4E97750061AFFD /* ProgressScreenProjectProgressView.swift */; }; 2C68FD7C2ABC1FF700D9EBE2 /* NotificationsOnboardingContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C68FD7B2ABC1FF700D9EBE2 /* NotificationsOnboardingContentView.swift */; }; @@ -235,7 +245,7 @@ 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 */; }; + 2C7CB67E2ADFDA62006F78DA /* FillBlanksInputContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB67D2ADFDA62006F78DA /* FillBlanksInputContainerView.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 */; }; @@ -321,6 +331,7 @@ 2CA3B0362888955F00EEF716 /* StepQuizCodeSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA3B0352888955F00EEF716 /* StepQuizCodeSkeletonView.swift */; }; 2CA542252ACAE18500EF24B5 /* IntrospectScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA542242ACAE18500EF24B5 /* IntrospectScrollView.swift */; }; 2CA542272ACAE32A00EF24B5 /* IntrospectScrollViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA542262ACAE32A00EF24B5 /* IntrospectScrollViewTests.swift */; }; + 2CA5E4C42AEBAAD80021DF9F /* StepQuizFillBlanksOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA5E4C32AEBAAD80021DF9F /* StepQuizFillBlanksOutputProtocol.swift */; }; 2CA5F8EC2994C3AB0013B854 /* DebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA5F8EB2994C3AB0013B854 /* DebugView.swift */; }; 2CA5F8EE2994C3D00013B854 /* DebugViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA5F8ED2994C3D00013B854 /* DebugViewModel.swift */; }; 2CA5F8F02994C4A90013B854 /* DebugAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA5F8EF2994C4A90013B854 /* DebugAssembly.swift */; }; @@ -352,6 +363,7 @@ 2CB1962428EF27F30075F7EF /* UIKitViewControllerPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB1962328EF27F30075F7EF /* UIKitViewControllerPreview.swift */; }; 2CB279AD28C72A9500EDDCC8 /* TabBarRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB279AC28C72A9500EDDCC8 /* TabBarRouter.swift */; }; 2CB279AF28C72AA400EDDCC8 /* DeepLinkRouterProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB279AE28C72AA400EDDCC8 /* DeepLinkRouterProtocol.swift */; }; + 2CB2BF272AE91F38000A144F /* FillBlanksModeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB2BF262AE91F38000A144F /* FillBlanksModeWrapper.swift */; }; 2CB45762288EC29D007C2D77 /* StepQuizActionButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB45761288EC29D007C2D77 /* StepQuizActionButtons.swift */; }; 2CB45764288ED6D4007C2D77 /* StepQuizActionButtonCodeQuizDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB45763288ED6D4007C2D77 /* StepQuizActionButtonCodeQuizDelegate.swift */; }; 2CB64A3F2ABC47590053A998 /* NotificationsOnboardingOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CB64A3E2ABC47590053A998 /* NotificationsOnboardingOutputProtocol.swift */; }; @@ -685,6 +697,12 @@ 2C1572092AB98F9400DD02D3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 2C177EC22837B65500D841DB /* View+Frame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Frame.swift"; sourceTree = ""; }; 2C1860FB2923C540007D4EBF /* AppFeatureStateKsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFeatureStateKsExtensions.swift; sourceTree = ""; }; + 2C198DFD2AEA444100DCD35A /* FillBlanksSelectContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksSelectContainerView.swift; sourceTree = ""; }; + 2C198E002AEA835F00DCD35A /* StepQuizFillBlanksSelectOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsView.swift; sourceTree = ""; }; + 2C198E022AEA869300DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsCollectionViewCell.swift; sourceTree = ""; }; + 2C198E042AEA86DF00DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift; sourceTree = ""; }; + 2C198E072AEA8BB900DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift; sourceTree = ""; }; + 2C198E092AEA904800DCD35A /* StepQuizFillBlanksSelectOptionsViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsViewWrapper.swift; sourceTree = ""; }; 2C1F5868280D063800372A37 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; 2C1F586A280D094A00372A37 /* WebControllerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebControllerManager.swift; sourceTree = ""; }; 2C1F586F280D0CB700372A37 /* WebCacheCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebCacheCleaner.swift; sourceTree = ""; }; @@ -784,6 +802,7 @@ 2C4823332AC177AF0047999B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 2C49CC912851D4AC0040BA7F /* altcontent.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = altcontent.css; sourceTree = ""; }; 2C4D3CB2289689F40079B461 /* iosHyperskillApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iosHyperskillApp.entitlements; sourceTree = ""; }; + 2C4D6EF32AEF9ECC000064C7 /* StepQuizFillBlanksSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSkeletonView.swift; sourceTree = ""; }; 2C4F639A2A101DCE00D4EE39 /* ProjectSelectionListGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSelectionListGridView.swift; sourceTree = ""; }; 2C4F639D2A10203000D4EE39 /* ProjectSelectionListGridSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSelectionListGridSectionView.swift; sourceTree = ""; }; 2C4F63A02A102D3300D4EE39 /* SharedProjectLevelWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedProjectLevelWrapper.swift; sourceTree = ""; }; @@ -802,6 +821,7 @@ 2C55133A28B8DFE8009F7627 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; 2C55E18F2A056AFC00FE58D7 /* ProblemsLimitSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemsLimitSkeletonView.swift; sourceTree = ""; }; 2C55E1912A05706300FE58D7 /* HomeSubheadlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSubheadlineView.swift; sourceTree = ""; }; + 2C5661192AEA418D00D607FB /* FillBlanksSelectCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksSelectCollectionViewCell.swift; sourceTree = ""; }; 2C58DE222803BF97002A2774 /* AuthLogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthLogoView.swift; sourceTree = ""; }; 2C58DE242803C185002A2774 /* AuthSocialControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSocialControlsView.swift; sourceTree = ""; }; 2C58DE282803D197002A2774 /* UIColor+DynamicColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+DynamicColor.swift"; sourceTree = ""; }; @@ -833,6 +853,8 @@ 2C66720A2A52974A0040EA2F /* ProgressScreenSectionTitleSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressScreenSectionTitleSkeletonView.swift; sourceTree = ""; }; 2C66720C2A5299C30040EA2F /* ProgressScreenCardSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressScreenCardSkeletonView.swift; sourceTree = ""; }; 2C66720E2A529A7C0040EA2F /* ProgressScreenTrackProgressSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressScreenTrackProgressSkeletonView.swift; sourceTree = ""; }; + 2C6770982AEBDF3A00E832AC /* StepQuizFillBlanksSelectOptionsInputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsInputProtocol.swift; sourceTree = ""; }; + 2C67709A2AEBDF5200E832AC /* StepQuizFillBlanksSelectOptionsOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsOutputProtocol.swift; sourceTree = ""; }; 2C688C022A4E8F900061AFFD /* ProgressScreenTrackProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressScreenTrackProgressView.swift; sourceTree = ""; }; 2C688C042A4E97750061AFFD /* ProgressScreenProjectProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressScreenProjectProgressView.swift; sourceTree = ""; }; 2C68FD7B2ABC1FF700D9EBE2 /* NotificationsOnboardingContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsOnboardingContentView.swift; sourceTree = ""; }; @@ -859,7 +881,7 @@ 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 = ""; }; + 2C7CB67D2ADFDA62006F78DA /* FillBlanksInputContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksInputContainerView.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 = ""; }; @@ -946,6 +968,7 @@ 2CA3B0352888955F00EEF716 /* StepQuizCodeSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeSkeletonView.swift; sourceTree = ""; }; 2CA542242ACAE18500EF24B5 /* IntrospectScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntrospectScrollView.swift; sourceTree = ""; }; 2CA542262ACAE32A00EF24B5 /* IntrospectScrollViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntrospectScrollViewTests.swift; sourceTree = ""; }; + 2CA5E4C32AEBAAD80021DF9F /* StepQuizFillBlanksOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksOutputProtocol.swift; sourceTree = ""; }; 2CA5F8EB2994C3AB0013B854 /* DebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugView.swift; sourceTree = ""; }; 2CA5F8ED2994C3D00013B854 /* DebugViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewModel.swift; sourceTree = ""; }; 2CA5F8EF2994C4A90013B854 /* DebugAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugAssembly.swift; sourceTree = ""; }; @@ -977,6 +1000,7 @@ 2CB1962328EF27F30075F7EF /* UIKitViewControllerPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitViewControllerPreview.swift; sourceTree = ""; }; 2CB279AC28C72A9500EDDCC8 /* TabBarRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarRouter.swift; sourceTree = ""; }; 2CB279AE28C72AA400EDDCC8 /* DeepLinkRouterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkRouterProtocol.swift; sourceTree = ""; }; + 2CB2BF262AE91F38000A144F /* FillBlanksModeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksModeWrapper.swift; sourceTree = ""; }; 2CB45761288EC29D007C2D77 /* StepQuizActionButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizActionButtons.swift; sourceTree = ""; }; 2CB45763288ED6D4007C2D77 /* StepQuizActionButtonCodeQuizDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizActionButtonCodeQuizDelegate.swift; sourceTree = ""; }; 2CB64A3E2ABC47590053A998 /* NotificationsOnboardingOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsOnboardingOutputProtocol.swift; sourceTree = ""; }; @@ -1484,6 +1508,33 @@ path = Extensions; sourceTree = ""; }; + 2C198DFC2AEA441E00DCD35A /* Select */ = { + isa = PBXGroup; + children = ( + 2C5661192AEA418D00D607FB /* FillBlanksSelectCollectionViewCell.swift */, + 2C198DFD2AEA444100DCD35A /* FillBlanksSelectContainerView.swift */, + ); + path = Select; + sourceTree = ""; + }; + 2C198DFF2AEA831C00DCD35A /* StepQuizFillBlanksSelectOptions */ = { + isa = PBXGroup; + children = ( + 2C6770972AEBDF2000E832AC /* InputOutput */, + 2C44B32E2AECFF9600AE8C2B /* Views */, + ); + path = StepQuizFillBlanksSelectOptions; + sourceTree = ""; + }; + 2C198E062AEA86E800DCD35A /* Cell */ = { + isa = PBXGroup; + children = ( + 2C198E022AEA869300DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCell.swift */, + 2C198E042AEA86DF00DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift */, + ); + path = Cell; + sourceTree = ""; + }; 2C1DEE2C27EC3FE00001A567 /* Sources */ = { isa = PBXGroup; children = ( @@ -1950,6 +2001,33 @@ path = MokoResources; sourceTree = ""; }; + 2C44B32E2AECFF9600AE8C2B /* Views */ = { + isa = PBXGroup; + children = ( + 2C198E092AEA904800DCD35A /* StepQuizFillBlanksSelectOptionsViewWrapper.swift */, + 2C44B32F2AECFF9D00AE8C2B /* UIKit */, + ); + path = Views; + sourceTree = ""; + }; + 2C44B32F2AECFF9D00AE8C2B /* UIKit */ = { + isa = PBXGroup; + children = ( + 2C198E072AEA8BB900DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift */, + 2C198E002AEA835F00DCD35A /* StepQuizFillBlanksSelectOptionsView.swift */, + 2C198E062AEA86E800DCD35A /* Cell */, + ); + path = UIKit; + sourceTree = ""; + }; + 2C44B3302AECFFFC00AE8C2B /* InputOutput */ = { + isa = PBXGroup; + children = ( + 2CA5E4C32AEBAAD80021DF9F /* StepQuizFillBlanksOutputProtocol.swift */, + ); + path = InputOutput; + sourceTree = ""; + }; 2C45E7C62A0FF7C900DFF32D /* Header */ = { isa = PBXGroup; children = ( @@ -2040,6 +2118,15 @@ path = Content; sourceTree = ""; }; + 2C56611B2AEA435000D607FB /* Input */ = { + isa = PBXGroup; + children = ( + 2C7CB67F2ADFDADD006F78DA /* FillBlanksInputCollectionViewCell.swift */, + 2C7CB67D2ADFDA62006F78DA /* FillBlanksInputContainerView.swift */, + ); + path = Input; + sourceTree = ""; + }; 2C58DE212803BE84002A2774 /* Views */ = { isa = PBXGroup; children = ( @@ -2174,6 +2261,15 @@ path = Skeleton; sourceTree = ""; }; + 2C6770972AEBDF2000E832AC /* InputOutput */ = { + isa = PBXGroup; + children = ( + 2C6770982AEBDF3A00E832AC /* StepQuizFillBlanksSelectOptionsInputProtocol.swift */, + 2C67709A2AEBDF5200E832AC /* StepQuizFillBlanksSelectOptionsOutputProtocol.swift */, + ); + path = InputOutput; + sourceTree = ""; + }; 2C68FD7D2ABC351D00D9EBE2 /* Views */ = { isa = PBXGroup; children = ( @@ -2257,6 +2353,7 @@ children = ( 2C7CB66A2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift */, 2C7CB66C2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift */, + 2C44B3302AECFFFC00AE8C2B /* InputOutput */, 2C7CB6742ADFCB1E006F78DA /* ViewData */, 2C7CB6702ADFB985006F78DA /* Views */, ); @@ -2267,6 +2364,7 @@ isa = PBXGroup; children = ( 2C7CB6872ADFF57D006F78DA /* FillBlanksQuizViewWrapper.swift */, + 2C4D6EF32AEF9ECC000064C7 /* StepQuizFillBlanksSkeletonView.swift */, 2C7CB66E2ADFB96F006F78DA /* StepQuizFillBlanksView.swift */, 2C7CB6792ADFD985006F78DA /* UIKit */, ); @@ -2276,6 +2374,7 @@ 2C7CB6742ADFCB1E006F78DA /* ViewData */ = { isa = PBXGroup; children = ( + 2CB2BF262AE91F38000A144F /* FillBlanksModeWrapper.swift */, 2C7CB6752ADFCFCC006F78DA /* StepQuizFillBlanksViewData.swift */, 2C7CB6772ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift */, 2C078CE82AE29D0600D97E24 /* StepQuizFillBlanksViewDataMapperCache.swift */, @@ -2287,7 +2386,6 @@ isa = PBXGroup; children = ( 2C7CB6832ADFF1A6006F78DA /* FillBlanksQuizCollectionViewAdapter.swift */, - 2C7CB67D2ADFDA62006F78DA /* FillBlanksQuizInputContainerView.swift */, 2C078CE42AE26CB400D97E24 /* FillBlanksQuizTitleView.swift */, 2C7CB6852ADFF389006F78DA /* FillBlanksQuizView.swift */, 2C7CB67C2ADFD9BF006F78DA /* Cells */, @@ -2298,8 +2396,9 @@ 2C7CB67C2ADFD9BF006F78DA /* Cells */ = { isa = PBXGroup; children = ( - 2C7CB67F2ADFDADD006F78DA /* FillBlanksInputCollectionViewCell.swift */, 2C7CB67A2ADFD9B4006F78DA /* FillBlanksTextCollectionViewCell.swift */, + 2C56611B2AEA435000D607FB /* Input */, + 2C198DFC2AEA441E00DCD35A /* Select */, ); path = Cells; sourceTree = ""; @@ -3033,6 +3132,7 @@ 2C96742C288823EB0091B6C9 /* StepQuizCode */, 2CBFB94828897D970044D1BA /* StepQuizCodeFullScreen */, 2C7CB6692ADFB91C006F78DA /* StepQuizFillBlanks */, + 2C198DFF2AEA831C00DCD35A /* StepQuizFillBlanksSelectOptions */, E9F27D7629064456007F16D7 /* StepQuizHints */, E9D2D66E284E0A5D000757AC /* StepQuizMatching */, E96D49382A9CCE9A00BD78FE /* StepQuizParsons */, @@ -4138,6 +4238,7 @@ 2C8E4FB628490C020011ADFA /* PanModalPresenter.swift in Sources */, 2C55133B28B8DFE8009F7627 /* Debouncer.swift in Sources */, 2C7CB6882ADFF57D006F78DA /* FillBlanksQuizViewWrapper.swift in Sources */, + 2C56611A2AEA418D00D607FB /* FillBlanksSelectCollectionViewCell.swift in Sources */, E9A6250D28ABAE30009423EE /* OnboardingAssembly.swift in Sources */, 2C336D172865C9CD00C91342 /* UIView+TraitCollection.swift in Sources */, 2CD48D872858639500CFCC4A /* StepQuizViewModel.swift in Sources */, @@ -4190,8 +4291,10 @@ E9523BF029DA933C0013A661 /* StudyPlanViewModel.swift in Sources */, 2CCF3B5828004FC40075D12C /* UserAgentBuilder.swift in Sources */, 2C963BC52812D1A70036DD53 /* HomeView.swift in Sources */, + 2C6770992AEBDF3A00E832AC /* StepQuizFillBlanksSelectOptionsInputProtocol.swift in Sources */, 2C078CE52AE26CB400D97E24 /* FillBlanksQuizTitleView.swift in Sources */, 2C93C2D8292EBBB5004D1861 /* AuthSocialFeatureStateKsExtensions.swift in Sources */, + 2C4D6EF42AEF9ECC000064C7 /* StepQuizFillBlanksSkeletonView.swift in Sources */, E9101713283296F3002E70F5 /* RadioButton.swift in Sources */, 2C68FD7C2ABC1FF700D9EBE2 /* NotificationsOnboardingContentView.swift in Sources */, 2C20FBC2284F66FC006D879E /* NSAttributedString+TrimmingCharacters.swift in Sources */, @@ -4211,6 +4314,7 @@ 2C93AF2329B34F66004639E0 /* StepQuizPyCharmView.swift in Sources */, 2C20FBAA284F1951006D879E /* ContentProcessor.swift in Sources */, 2C20B28F286C4515000F458A /* CodeLanguageSamples.swift in Sources */, + 2CA5E4C42AEBAAD80021DF9F /* StepQuizFillBlanksOutputProtocol.swift in Sources */, 2C9674302888242D0091B6C9 /* StepQuizCodeViewData.swift in Sources */, 2C66720F2A529A7C0040EA2F /* ProgressScreenTrackProgressSkeletonView.swift in Sources */, 2C1F5888280D5D6200372A37 /* SocialAuthSDKProvider.swift in Sources */, @@ -4221,6 +4325,8 @@ 2C9E5E8629B215CA003AEC16 /* StageImplementViewModel.swift in Sources */, 2CAF254C2AB9C2E500595582 /* ShineEffect.swift in Sources */, 2C0EC2DF28F9E92D004A36B2 /* ModalRouter.swift in Sources */, + 2C67709B2AEBDF5200E832AC /* StepQuizFillBlanksSelectOptionsOutputProtocol.swift in Sources */, + 2C198E032AEA869300DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCell.swift in Sources */, 2C919E3527EEFF110022A2F2 /* Queue.swift in Sources */, 2CC7833E295DAE3E00A867CD /* OnboardingFeatureStateKsExtensions.swift in Sources */, 2C45E7C52A0FEE7A00DFF32D /* StarRatingView.swift in Sources */, @@ -4245,6 +4351,7 @@ 2C8E4F9C2848A1550011ADFA /* PanModalViewModifier.swift in Sources */, 2CA7B88F2893295A00A789EF /* CodeEditorSuggestionsPresentationContextProviding.swift in Sources */, 2C8E4F9A284897360011ADFA /* PanModalSwiftUIViewController.swift in Sources */, + 2C198DFE2AEA444100DCD35A /* FillBlanksSelectContainerView.swift in Sources */, 2C20FBA6284F1924006D879E /* ContentProcessingInjection.swift in Sources */, 2C1061A8285C3A2D00EBD614 /* StepQuizChildQuizType.swift in Sources */, 2CE31F4D27F1E0C8008EEE66 /* AppAssembly.swift in Sources */, @@ -4306,6 +4413,7 @@ 2CDBE6F528C10DCE00033679 /* NotificationPermissionStatusSettingsObserver.swift in Sources */, 2C5B2A21286596030097B270 /* Reusable.swift in Sources */, 2C4605B12ABD75FC003C17E9 /* View+ScrollBounceBehavior.swift in Sources */, + 2C198E012AEA835F00DCD35A /* StepQuizFillBlanksSelectOptionsView.swift in Sources */, E96D493B2A9CCF3600BD78FE /* StepQuizParsonsItemView.swift in Sources */, 2C7994AF2A1299B800874C16 /* TrackSelectionListView.swift in Sources */, 2CB1962428EF27F30075F7EF /* UIKitViewControllerPreview.swift in Sources */, @@ -4394,7 +4502,7 @@ 2C1061A4285C34C900EBD614 /* StepQuizChildQuizOutputProtocol.swift in Sources */, 2C20FBA4284F165A006D879E /* ProcessedContent.swift in Sources */, 2C66720D2A5299C30040EA2F /* ProgressScreenCardSkeletonView.swift in Sources */, - 2C7CB67E2ADFDA62006F78DA /* FillBlanksQuizInputContainerView.swift in Sources */, + 2C7CB67E2ADFDA62006F78DA /* FillBlanksInputContainerView.swift in Sources */, 2C55E1902A056AFC00FE58D7 /* ProblemsLimitSkeletonView.swift in Sources */, E94BB0482A9DF9DD00736B7C /* StepQuizParsonsView.swift in Sources */, E99CCB0B287E945300898BBF /* HomeViewModel.swift in Sources */, @@ -4436,6 +4544,7 @@ E9B3A18C2828093800FE248B /* StepQuizActionButton.swift in Sources */, 2C93C2D2292E574F004D1861 /* HyperskillSentryLevel+SentryLevel.swift in Sources */, 2C5CA2442A202F1400DBF2F9 /* ProjectSelectionDetailsSkeletonView.swift in Sources */, + 2C198E052AEA86DF00DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift in Sources */, E9CC6C0429893D8400D8D070 /* TopicCompletedModalViewControllerDelegate.swift in Sources */, E9317A0228A23FD100F59373 /* UNNotificationTrigger+nextTriggerDate.swift in Sources */, 2C725B632809198000A49043 /* BackgroundView.swift in Sources */, @@ -4455,11 +4564,13 @@ E94A6D0B28748C3E005F9ACC /* StreakIcon.swift in Sources */, 2CD3652C287987FA00D61855 /* ProfileSocialAccount.swift in Sources */, E90DF95929AE288600EC40DA /* StageImplementUnsupportedModalViewController.swift in Sources */, + 2C198E0A2AEA904800DCD35A /* StepQuizFillBlanksSelectOptionsViewWrapper.swift in Sources */, E9F655CA2875914200291143 /* StreakCardView.swift in Sources */, 2C9ECBA3284736090015CFD2 /* StepViewDataMapper.swift in Sources */, 2CAE8CF2280525C900E6C83D /* StepView.swift in Sources */, 2C023C8B285DCA2100D2D5A9 /* ReplyExtensions.swift in Sources */, 2C7CB67B2ADFD9B4006F78DA /* FillBlanksTextCollectionViewCell.swift in Sources */, + 2CB2BF272AE91F38000A144F /* FillBlanksModeWrapper.swift in Sources */, E9FB89B02893EA900011EFFB /* UserNotificationsCenterDelegate.swift in Sources */, E9FAF38F299F61AE001FC596 /* View+MeasureSize.swift in Sources */, 2C96744428883E710091B6C9 /* BlockOptionsExtensions.swift in Sources */, @@ -4492,6 +4603,7 @@ 2CE1C4DF27EDFB3800220041 /* FeatureViewModel.swift in Sources */, E9B2CF5B2822960D00B2DC6F /* StepQuizBottomControls.swift in Sources */, 2C05AC5D2A0ED7AD0039C7EF /* BadgeView.swift in Sources */, + 2C198E082AEA8BB900DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift in Sources */, 2C82BA322844B01D004C9013 /* PlaceholderView+Configurations.swift in Sources */, 2C25BFD52851F8F00036C689 /* UIColor+DesignSystem.swift in Sources */, 2C023C8D285DCA4300D2D5A9 /* DatasetExtensions.swift in Sources */, diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Core/Injection/AppGraph.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Core/Injection/AppGraph.swift index c7eb498bfa..39a1652bb2 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Core/Injection/AppGraph.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Core/Injection/AppGraph.swift @@ -3,7 +3,7 @@ import shared private final class AppGraph: IosAppComponentImpl { override func getIosFCMTokenProvider() -> IosFCMTokenProvider { - IosFCMTokenProviderImpl() + IosFCMTokenProviderImpl.shared } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Core/Injection/IosFCMTokenProviderImpl.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Core/Injection/IosFCMTokenProviderImpl.swift index f7ad93a1c6..1b9d626b03 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Core/Injection/IosFCMTokenProviderImpl.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Core/Injection/IosFCMTokenProviderImpl.swift @@ -3,6 +3,8 @@ import Foundation import shared final class IosFCMTokenProviderImpl: IosFCMTokenProvider { + static let shared = IosFCMTokenProviderImpl() + func getFCMToken() async throws -> String? { guard Messaging.messaging().apnsToken != nil else { return nil diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift index 3c7849d633..c94ec0c7e6 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift @@ -108,8 +108,9 @@ enum StepQuizChildQuizViewFactory { moduleOutput: moduleOutput ) .makeModule() - case .fillBlanks: + case .fillBlanks(let mode): StepQuizFillBlanksAssembly( + mode: mode.require(), step: step, dataset: dataset, reply: reply, diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizType.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizType.swift index 9edd4adc15..ef74ae3c99 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizType.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizType.swift @@ -13,7 +13,7 @@ enum StepQuizChildQuizType { case number case math case parsons - case fillBlanks + case fillBlanks(mode: FillBlanksModeWrapper?) case unsupported(blockName: String) var isCodeRelated: Bool { @@ -25,7 +25,13 @@ enum StepQuizChildQuizType { } } - init(step: Step) { + static func resolve(step: Step, datasetOrNil: Dataset?) -> StepQuizChildQuizType { + StepQuizChildQuizType(step: step, datasetOrNil: datasetOrNil) + } + + private init(step: Step, datasetOrNil: Dataset?) { + let unsupportedQuizType = StepQuizChildQuizType.unsupported(blockName: step.block.name) + if StepQuizResolver.shared.isQuizSupportable(step: step) { switch step.block.name { case BlockName.shared.CHOICE: @@ -51,12 +57,31 @@ enum StepQuizChildQuizType { case BlockName.shared.PARSONS: self = .parsons case BlockName.shared.FILL_BLANKS: - self = .fillBlanks + guard let dataset = datasetOrNil else { + self = .fillBlanks(mode: nil) + return + } + + do { + let sharedMode = try FillBlanksResolver.shared.resolve(dataset: dataset) + + switch FillBlanksModeWrapper(shared: sharedMode) { + case .input: + self = .fillBlanks(mode: .input) + case .select: + self = .fillBlanks(mode: .select) + default: + self = unsupportedQuizType + } + } catch { + assertionFailure("FillBlanksResolver: failed to resolve fill blanks with error = \(error)") + self = unsupportedQuizType + } default: - self = .unsupported(blockName: step.block.name) + self = unsupportedQuizType } } else { - self = .unsupported(blockName: step.block.name) + self = unsupportedQuizType } } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift index 8d48b91bea..59cfcbb659 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift @@ -19,6 +19,8 @@ final class StepQuizViewModel: FeatureViewModel< private let stepQuizViewDataMapper: StepQuizViewDataMapper var stepQuizStateKs: StepQuizFeatureStepQuizStateKs { .init(state.stepQuizState) } + weak var fillBlanksSelectOptionsModuleInput: StepQuizFillBlanksSelectOptionsInputProtocol? + @Published var isPracticingLoading = false init( @@ -142,7 +144,7 @@ final class StepQuizViewModel: FeatureViewModel< } } -// MARK: - StepQuizViewModel: StepQuizChildQuizDelegate - +// MARK: - StepQuizViewModel: StepQuizChildQuizOutputProtocol - extension StepQuizViewModel: StepQuizChildQuizOutputProtocol { func handleChildQuizSync(reply: Reply) { @@ -162,6 +164,26 @@ extension StepQuizViewModel: StepQuizChildQuizOutputProtocol { } } +// MARK: - StepQuizViewModel: StepQuizFillBlanksOutputProtocol - + +extension StepQuizViewModel: StepQuizFillBlanksOutputProtocol { + func handleStepQuizFillBlanksCurrentSelectModeState( + options: [StepQuizFillBlankOption], + selectedIndices: Set, + blanksCount: Int + ) { + guard let fillBlanksSelectOptionsModuleInput else { + return assertionFailure("StepQuizViewModel: fillBlanksSelectOptionsModuleInput is nil") + } + + fillBlanksSelectOptionsModuleInput.update( + options: options, + selectedIndices: selectedIndices, + blanksCount: blanksCount + ) + } +} + // MARK: - StepQuizViewModel: StepQuizInputProtocol - extension StepQuizViewModel: StepQuizInputProtocol { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift index 2feac5aad6..7e9b3fcf75 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift @@ -12,16 +12,7 @@ final class StepQuizViewDataMapper { } func mapStepDataToViewData(step: Step, state: StepQuizFeatureStepQuizStateKs) -> StepQuizViewData { - let attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded? = { - switch state { - case .attemptLoading(let attemptLoadingState): - return attemptLoadingState.oldState - case .attemptLoaded(let attemptLoadedState): - return attemptLoadedState - default: - return nil - } - }() + let attemptLoadedState = StepQuizStateExtentionsKt.attemptLoadedState(state.sealed) let quizType = resolveQuizType( step: step, @@ -96,39 +87,13 @@ final class StepQuizViewDataMapper { 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 { - let mode = try FillBlanksResolver.shared.resolve(dataset: dataset) - - switch mode { - case .input: - return childQuizType - case .select: - return unsupportedChildQuizType - default: - assertionFailure("StepQuizViewDataMapper: unsupported fill blanks mode \(mode)") - return unsupportedChildQuizType - } - } catch { - #if DEBUG - print("StepQuizViewDataMapper: failed to resolve fill blanks quiz type, error = \(error)") - #endif - return unsupportedChildQuizType - } + return .unsupported(blockName: step.block.name) } - return childQuizType + return StepQuizChildQuizType.resolve( + step: step, + datasetOrNil: attemptLoadedState?.attempt.dataset + ) } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizSkeletonViewFactory.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizSkeletonViewFactory.swift index 75b92c585c..86c7c55035 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizSkeletonViewFactory.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizSkeletonViewFactory.swift @@ -21,8 +21,7 @@ enum StepQuizSkeletonViewFactory { case .parsons: StepQuizParsonsSkeletonView() case .fillBlanks: - #warning("TODO: FillBlanks skeleton view") - StepQuizParsonsSkeletonView() + StepQuizFillBlanksSkeletonView() case .unsupported: SkeletonRoundedView() .frame(height: 100) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizView.swift index 1263b1be27..05e7b2899c 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizView.swift @@ -17,6 +17,15 @@ struct StepQuizView: View { @EnvironmentObject private var stackRouter: SwiftUIStackRouter @EnvironmentObject private var panModalPresenter: PanModalPresenter + @State private var fillBlanksSelectOptionsViewHeight: CGFloat = 0 + private var shouldRenderFillBlanksSpacer: Bool { + if #available(iOS 15.0, *) { + return false + } else { + return true + } + } + var body: some View { buildBody() .navigationBarTitleDisplayMode(.inline) @@ -53,15 +62,11 @@ struct StepQuizView: View { StepTextView(text: viewData.stepText) } else { - if viewData.quizType.isCodeRelated { - ExpandableStepTextView( - text: viewData.stepText, - isExpanded: true, - onExpandButtonTap: viewModel.logClickedStepTextDetailsEvent - ) - } else { - StepTextView(text: viewData.stepText) - } + ExpandableStepTextView( + text: viewData.stepText, + isExpanded: true, + onExpandButtonTap: viewModel.logClickedStepTextDetailsEvent + ) if viewData.stepHasHints { StepQuizHintsAssembly( @@ -84,7 +89,17 @@ struct StepQuizView: View { .introspectScrollView { scrollView in scrollView.shouldIgnoreScrollingAdjustment = true } + + if shouldRenderFillBlanksSpacer { + Spacer(minLength: fillBlanksSelectOptionsViewHeight) + } } + .bottomFillBlanksSelectOptionsOverlay( + buildFillBlanksSelectOptionsView( + quizType: viewData.quizType, + attemptLoadedState: StepQuizStateExtentionsKt.attemptLoadedState(viewModel.state.stepQuizState) + ) + ) .if(StepQuizResolver.shared.isTheoryToolbarItemAvailable(state: viewModel.state.stepQuizState)) { $0.toolbar { // buildIf is only available in iOS 16.0 or newer @@ -114,32 +129,20 @@ struct StepQuizView: View { .padding(.top) } - let attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded? = { - if let attemptLoadedState = state.stepQuizState as? StepQuizFeatureStepQuizStateAttemptLoaded { - return attemptLoadedState - } else if let attemptLoadingState = state.stepQuizState as? StepQuizFeatureStepQuizStateAttemptLoading { - return attemptLoadingState.oldState - } - return nil - }() - - if let attemptLoadedState { - if case .unsupported = quizType { - // it's rendered before step text - } else { - buildChildQuiz(quizType: quizType, step: step, attemptLoadedState: attemptLoadedState) - if let formattedStats { - StepQuizStatsView(text: formattedStats) - } + if let attemptLoadedState = StepQuizStateExtentionsKt.attemptLoadedState(state.stepQuizState) { + buildChildQuiz(quizType: quizType, step: step, attemptLoadedState: attemptLoadedState) - buildQuizStatusView(state: state.stepQuizState, attemptLoadedState: attemptLoadedState) + if let formattedStats { + StepQuizStatsView(text: formattedStats) + } - if let feedbackHintText { - StepQuizFeedbackView(text: feedbackHintText) - } + buildQuizStatusView(state: state.stepQuizState, attemptLoadedState: attemptLoadedState) - buildQuizActionButtons(quizType: quizType, state: state, attemptLoadedState: attemptLoadedState) + if let feedbackHintText { + StepQuizFeedbackView(text: feedbackHintText) } + + buildQuizActionButtons(quizType: quizType, state: state, attemptLoadedState: attemptLoadedState) } else { StepQuizSkeletonViewFactory.makeSkeleton(for: quizType) } @@ -253,6 +256,37 @@ struct StepQuizView: View { } } + @ViewBuilder + private func buildFillBlanksSelectOptionsView( + quizType: StepQuizChildQuizType, + attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded? + ) -> some View { + if case .fillBlanks(let mode) = quizType, mode == .select, + let attemptLoadedState { + StepQuizFillBlanksSelectOptionsViewWrapper( + moduleOutput: viewModel.childQuizModuleInput as? StepQuizFillBlanksSelectOptionsOutputProtocol, + moduleInput: { [weak viewModel] moduleInput in + guard let viewModel else { + return + } + + viewModel.fillBlanksSelectOptionsModuleInput = moduleInput + }, + isUserInteractionEnabled: StepQuizResolver.shared.isQuizEnabled(state: attemptLoadedState), + onNewHeight: { height in + if fillBlanksSelectOptionsViewHeight != height { + DispatchQueue.main.async { + fillBlanksSelectOptionsViewHeight = height + } + } + } + ) + .edgesIgnoringSafeArea(.all) + .frame(height: fillBlanksSelectOptionsViewHeight) + .disabled(!StepQuizResolver.shared.isQuizEnabled(state: attemptLoadedState)) + } + } + private func handleViewAction(_ viewAction: StepQuizFeatureActionViewAction) { switch StepQuizFeatureActionViewActionKs(viewAction) { case .showNetworkError: @@ -320,3 +354,22 @@ private extension StepQuizView { panModalPresenter.presentPanModal(panModal) } } + +// MARK: - View (bottomFillBlanksSelectOptionsOverlay) - + +@available(iOS, introduced: 13, deprecated: 15, message: "Use .safeAreaInset() directly") +private extension View { + @ViewBuilder + func bottomFillBlanksSelectOptionsOverlay(_ overlayContent: OverlayContent) -> some View { + if #available(iOS 15.0, *) { + self.safeAreaInset( + edge: .bottom, + alignment: .center, + spacing: 0, + content: { overlayContent } + ) + } else { + self.overlay(overlayContent, alignment: .bottom) + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/InputOutput/StepQuizFillBlanksOutputProtocol.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/InputOutput/StepQuizFillBlanksOutputProtocol.swift new file mode 100644 index 0000000000..e82bb0f3b4 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/InputOutput/StepQuizFillBlanksOutputProtocol.swift @@ -0,0 +1,9 @@ +import Foundation + +protocol StepQuizFillBlanksOutputProtocol: StepQuizChildQuizOutputProtocol { + func handleStepQuizFillBlanksCurrentSelectModeState( + options: [StepQuizFillBlankOption], + selectedIndices: Set, + blanksCount: Int + ) +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift index 99e236dcfa..624023ac42 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift @@ -12,13 +12,17 @@ final class StepQuizFillBlanksAssembly: StepQuizChildQuizAssembly { private let dataset: Dataset private let reply: Reply? + private let mode: FillBlanksModeWrapper + init( + mode: FillBlanksModeWrapper, step: Step, dataset: Dataset, reply: Reply?, provideModuleInputCallback: @escaping (StepQuizChildQuizInputProtocol?) -> Void, moduleOutput: StepQuizChildQuizOutputProtocol? ) { + self.mode = mode self.step = step self.dataset = dataset self.reply = reply @@ -26,13 +30,25 @@ final class StepQuizFillBlanksAssembly: StepQuizChildQuizAssembly { self.moduleOutput = moduleOutput } + // swiftlint:disable:next unavailable_function + init( + step: Step, + dataset: Dataset, + reply: Reply?, + provideModuleInputCallback: @escaping (StepQuizChildQuizInputProtocol?) -> Void, + moduleOutput: StepQuizChildQuizOutputProtocol? + ) { + fatalError("init(step:dataset:reply:provideModuleInputCallback:moduleOutput:) has not been implemented") + } + func makeModule() -> StepQuizFillBlanksView { let viewModel = StepQuizFillBlanksViewModel( step: step, dataset: dataset, reply: reply, + mode: mode, viewDataMapper: StepQuizFillBlanksViewDataMapper( - fillBlanksItemMapper: FillBlanksItemMapper(mode: .input), + fillBlanksItemMapper: FillBlanksItemMapper(mode: mode.sharedValue), highlightr: Highlightr().require(), codeEditorThemeService: CodeEditorThemeService(), cache: StepQuizFillBlanksViewDataMapperCache.shared diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksViewModel.swift index 89ca19f68e..b05ee60a7c 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksViewModel.swift @@ -6,21 +6,37 @@ final class StepQuizFillBlanksViewModel: ObservableObject { weak var moduleOutput: StepQuizChildQuizOutputProtocol? private let provideModuleInputCallback: (StepQuizChildQuizInputProtocol?) -> Void + private let mode: FillBlanksModeWrapper + @Published private(set) var viewData: StepQuizFillBlanksViewData + private var currentSelectBlankComponentIndex: Int? + init( step: Step, dataset: Dataset, reply: Reply?, + mode: FillBlanksModeWrapper, viewDataMapper: StepQuizFillBlanksViewDataMapper, provideModuleInputCallback: @escaping (StepQuizChildQuizInputProtocol?) -> Void ) { + self.mode = mode self.provideModuleInputCallback = provideModuleInputCallback - self.viewData = viewDataMapper.mapToViewData(dataset: dataset, reply: reply) + + var viewData = viewDataMapper.mapToViewData(dataset: dataset, reply: reply) + + if mode == .select, + let index = Self.getFirstSelectBlankComponentIndex(components: viewData.components) { + self.currentSelectBlankComponentIndex = index + viewData.components[index].isFirstResponder = true + } + + self.viewData = viewData } func doProvideModuleInput() { provideModuleInputCallback(self) + outputCurrentSelectModeState() } func doInputTextUpdate(_ inputText: String, for component: StepQuizFillBlankComponent) { @@ -34,15 +50,23 @@ final class StepQuizFillBlanksViewModel: ObservableObject { outputCurrentReply() } + @MainActor func doSelectComponent(at indexPath: IndexPath) { - setIsFirstResponder(true, forComponentAt: indexPath) + switch viewData.components[indexPath.row].type { + case .input: + inputModeSetIsFirstResponder(true, forComponentAt: indexPath) + case .select: + selectModeHandleDidSelectComponent(at: indexPath) + default: + break + } } func doDeselectComponent(at indexPath: IndexPath) { - setIsFirstResponder(false, forComponentAt: indexPath) + inputModeSetIsFirstResponder(false, forComponentAt: indexPath) } - private func setIsFirstResponder(_ isFirstResponder: Bool, forComponentAt indexPath: IndexPath) { + private func inputModeSetIsFirstResponder(_ isFirstResponder: Bool, forComponentAt indexPath: IndexPath) { guard viewData.components[indexPath.row].type == .input else { return } @@ -61,6 +85,12 @@ extension StepQuizFillBlanksViewModel: StepQuizChildQuizInputProtocol { return nil case .input: return component.inputText ?? "" + case .select: + guard let selectedOptionIndex = component.selectedOptionIndex else { + return nil + } + + return viewData.options[selectedOptionIndex].originalText } } @@ -71,3 +101,88 @@ extension StepQuizFillBlanksViewModel: StepQuizChildQuizInputProtocol { moduleOutput?.handleChildQuizSync(reply: createReply()) } } + +// MARK: - StepQuizFillBlanksViewModel (Select Mode) - + +extension StepQuizFillBlanksViewModel { + private func outputCurrentSelectModeState() { + guard mode == .select else { + return + } + + guard let fillBlanksModuleOutput = moduleOutput as? StepQuizFillBlanksOutputProtocol else { + assertionFailure(""" +StepQuizFillBlanksViewModel: expected StepQuizFillBlanksOutputProtocol, \(String(describing: moduleOutput)) +""") + return + } + + let options = viewData.options + let selectedIndices = Set(viewData.components.compactMap(\.selectedOptionIndex)) + let blanksCount = viewData.components.filter({ $0.type == .select }).count + + fillBlanksModuleOutput.handleStepQuizFillBlanksCurrentSelectModeState( + options: options, + selectedIndices: selectedIndices, + blanksCount: blanksCount + ) + } + + @MainActor + private func selectModeHandleDidSelectComponent(at indexPath: IndexPath) { + var components = viewData.components + + let feedbackType: FeedbackGenerator.FeedbackType + + if viewData.components[indexPath.row].selectedOptionIndex != nil { + components[indexPath.row].selectedOptionIndex = nil + feedbackType = .impact(.light) + } else { + feedbackType = .selection + } + + currentSelectBlankComponentIndex = indexPath.row + + viewData.components = components.enumerated().map { index, component in + var component = component + component.isFirstResponder = index == indexPath.row + return component + } + + outputCurrentReply() + outputCurrentSelectModeState() + + FeedbackGenerator(feedbackType: feedbackType).triggerFeedback() + } + + private static func getFirstSelectBlankComponentIndex(components: [StepQuizFillBlankComponent]) -> Int? { + components.firstIndex(where: { $0.type == .select && $0.selectedOptionIndex == nil }) + } +} + +extension StepQuizFillBlanksViewModel: StepQuizFillBlanksSelectOptionsOutputProtocol { + func handleStepQuizFillBlanksSelectOptionsDidSelectOption(option: StepQuizFillBlankOption, at index: Int) { + guard mode == .select else { + return assertionFailure("StepQuizFillBlanksViewModel: unexpected state") + } + + guard let currentSelectBlankComponentIndex else { + return assertionFailure("StepQuizFillBlanksViewModel: currentSelectBlankComponentIndex is nil") + } + + var components = viewData.components + + components[currentSelectBlankComponentIndex].selectedOptionIndex = index + components[currentSelectBlankComponentIndex].isFirstResponder = false + + if let index = Self.getFirstSelectBlankComponentIndex(components: components) { + components[index].isFirstResponder = true + self.currentSelectBlankComponentIndex = index + } else { + self.currentSelectBlankComponentIndex = nil + } + + viewData.components = components + outputCurrentReply() + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/FillBlanksModeWrapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/FillBlanksModeWrapper.swift new file mode 100644 index 0000000000..6e20b5a9e9 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/FillBlanksModeWrapper.swift @@ -0,0 +1,30 @@ +import Foundation +import shared + +enum FillBlanksModeWrapper { + case input + case select +} + +extension FillBlanksModeWrapper { + var sharedValue: FillBlanksMode { + switch self { + case .input: + .input + case .select: + .select + } + } + + init?(shared: FillBlanksMode) { + switch shared { + case .input: + self = .input + case .select: + self = .select + default: + assertionFailure("FillBlanksModeWrapper: Did receive unsupported FillBlanksMode = \(shared)") + return nil + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewData.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewData.swift index ee3343af52..5fe8d5d95a 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewData.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewData.swift @@ -2,6 +2,7 @@ import Foundation struct StepQuizFillBlanksViewData: Hashable { var components: [StepQuizFillBlankComponent] + var options: [StepQuizFillBlankOption] } struct StepQuizFillBlankComponent: Hashable, Identifiable { @@ -12,10 +13,18 @@ struct StepQuizFillBlankComponent: Hashable, Identifiable { // input var inputText: String? var isFirstResponder = false + // select + var selectedOptionIndex: Int? enum ComponentType { case text case input + case select case lineBreak } } + +struct StepQuizFillBlankOption: Hashable { + let originalText: String + let displayText: String +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift index 9fdf2938c5..f87a60c547 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift @@ -26,7 +26,7 @@ final class StepQuizFillBlanksViewDataMapper { func mapToViewData(dataset: Dataset, reply: Reply?) -> StepQuizFillBlanksViewData { guard let fillBlanksData = fillBlanksItemMapper.map(dataset: dataset, reply: reply) else { - return .init(components: []) + return .init(components: [], options: []) } return mapFillBlanksDataToViewData(fillBlanksData) @@ -42,7 +42,11 @@ final class StepQuizFillBlanksViewDataMapper { components[index].id = index } - return StepQuizFillBlanksViewData(components: components) + let options = fillBlanksData.options.map { + StepQuizFillBlankOption(originalText: $0.originalText, displayText: $0.displayText) + } + + return StepQuizFillBlanksViewData(components: components, options: options) } private func mapFillBlanksItem( @@ -81,8 +85,7 @@ final class StepQuizFillBlanksViewDataMapper { case .input(let data): return [StepQuizFillBlankComponent(type: .input, inputText: data.inputText)] case .select(let data): - print(data) - return [] + return [StepQuizFillBlankComponent(type: .select, selectedOptionIndex: data.selectedOptionIndex?.intValue)] } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/FillBlanksQuizViewWrapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/FillBlanksQuizViewWrapper.swift index 0626d84193..0406575aa0 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/FillBlanksQuizViewWrapper.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/FillBlanksQuizViewWrapper.swift @@ -4,6 +4,7 @@ import UIKit struct FillBlanksQuizViewWrapper: UIViewRepresentable { let components: [StepQuizFillBlankComponent] + let options: [StepQuizFillBlankOption] let isUserInteractionEnabled: Bool var onInputDidChange: ((String, StepQuizFillBlankComponent) -> Void)? @@ -29,6 +30,7 @@ struct FillBlanksQuizViewWrapper: UIViewRepresentable { let shouldUpdateCollectionViewData = collectionViewAdapter.components != components collectionViewAdapter.components = components + collectionViewAdapter.options = options collectionViewAdapter.isUserInteractionEnabled = isUserInteractionEnabled if shouldUpdateCollectionViewData { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksSkeletonView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksSkeletonView.swift new file mode 100644 index 0000000000..9146ad245d --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksSkeletonView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +extension StepQuizFillBlanksSkeletonView { + struct Appearance { + let blankSize = FillBlanksSelectCollectionViewCell.Appearance.defaultSize + + let textHeight: CGFloat = 22 + let textWidthSmall: CGFloat = 35 + let textWidthDefault: CGFloat = 50 + let textWidthLarge: CGFloat = 100 + let textWidthExtraLarge: CGFloat = 150 + + let spacing = LayoutInsets.defaultInset + let interitemSpacing = LayoutInsets.smallInset + } +} + +struct StepQuizFillBlanksSkeletonView: View { + private(set) var appearance = Appearance() + + var body: some View { + VStack(spacing: 0) { + FillBlanksQuizTitleViewWrapper() + content + Divider() + } + .padding(.horizontal, -LayoutInsets.defaultInset) + } + + private var content: some View { + VStack(alignment: .leading, spacing: LayoutInsets.defaultInset) { + HStack(spacing: appearance.interitemSpacing) { + textView(width: appearance.textWidthDefault) + blankView + textView(width: appearance.textWidthSmall) + textView(width: appearance.textWidthLarge) + textView(width: appearance.textWidthSmall) + } + + HStack(spacing: appearance.interitemSpacing) { + textView(width: appearance.textWidthLarge) + textView(width: appearance.textWidthSmall) + textView(width: appearance.textWidthLarge) + blankView + } + + HStack(spacing: appearance.interitemSpacing) { + blankView + textView(width: appearance.textWidthSmall) + textView(width: appearance.textWidthExtraLarge) + textView(width: appearance.textWidthSmall) + Spacer() + } + } + .padding() + .background(BackgroundView()) + } + + private var blankView: some View { + Color.clear + .frame(size: appearance.blankSize) + .addBorder() + } + + private func textView(width: CGFloat) -> some View { + SkeletonRoundedView() + .frame(width: width, height: appearance.textHeight) + } +} + +#Preview { + StepQuizFillBlanksSkeletonView() + .padding() +} + +// MARK: - FillBlanksQuizTitleViewWrapper - + +private struct FillBlanksQuizTitleViewWrapper: UIViewRepresentable { + func makeUIView(context: Context) -> FillBlanksQuizTitleView { FillBlanksQuizTitleView() } + + func updateUIView(_ uiView: FillBlanksQuizTitleView, context: Context) {} +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksView.swift index 3a5888f093..bb1c615958 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksView.swift @@ -8,6 +8,7 @@ struct StepQuizFillBlanksView: View { var body: some View { FillBlanksQuizViewWrapper( components: viewModel.viewData.components, + options: viewModel.viewData.options, isUserInteractionEnabled: isEnabled, onInputDidChange: viewModel.doInputTextUpdate(_:for:), onDidSelectComponent: viewModel.doSelectComponent(at:), diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksInputCollectionViewCell.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Input/FillBlanksInputCollectionViewCell.swift similarity index 95% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksInputCollectionViewCell.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Input/FillBlanksInputCollectionViewCell.swift index 46cc224a3e..fe5a67d76c 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksInputCollectionViewCell.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Input/FillBlanksInputCollectionViewCell.swift @@ -17,8 +17,8 @@ extension FillBlanksInputCollectionViewCell { final class FillBlanksInputCollectionViewCell: UICollectionViewCell, Reusable { var appearance = Appearance() - private lazy var inputContainerView: FillBlanksQuizInputContainerView = { - let view = FillBlanksQuizInputContainerView( + private lazy var inputContainerView: FillBlanksInputContainerView = { + let view = FillBlanksInputContainerView( appearance: .init(cornerRadius: self.appearance.cornerRadius) ) return view @@ -53,7 +53,7 @@ final class FillBlanksInputCollectionViewCell: UICollectionViewCell, Reusable { } } - var state: FillBlanksQuizInputContainerView.State { + var state: FillBlanksInputContainerView.State { get { self.inputContainerView.state } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizInputContainerView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Input/FillBlanksInputContainerView.swift similarity index 94% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizInputContainerView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Input/FillBlanksInputContainerView.swift index 93cb702af1..72b749c9e8 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizInputContainerView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Input/FillBlanksInputContainerView.swift @@ -1,7 +1,7 @@ import SnapKit import UIKit -extension FillBlanksQuizInputContainerView { +extension FillBlanksInputContainerView { struct Appearance { var cornerRadius: CGFloat = 18 let borderWidth: CGFloat = 1 @@ -10,7 +10,7 @@ extension FillBlanksQuizInputContainerView { } } -final class FillBlanksQuizInputContainerView: UIView { +final class FillBlanksInputContainerView: UIView { let appearance: Appearance var state = State.default { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Select/FillBlanksSelectCollectionViewCell.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Select/FillBlanksSelectCollectionViewCell.swift new file mode 100644 index 0000000000..73c35af33a --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Select/FillBlanksSelectCollectionViewCell.swift @@ -0,0 +1,136 @@ +import SnapKit +import UIKit + +extension FillBlanksSelectCollectionViewCell { + struct Appearance { + static let defaultSize = CGSize(width: 48, height: 32) + + static var defaultWidth: CGFloat { Self.defaultSize.width } + static var defaultHeight: CGFloat { Self.defaultSize.height } + + let cornerRadius: CGFloat = 8 + let insets = LayoutInsets.small + + let textColor = UIColor.primaryText + static let font = CodeEditorThemeService().theme.font + } +} + +final class FillBlanksSelectCollectionViewCell: UICollectionViewCell, Reusable { + private static var prototypeTextLabel: UILabel? + + var appearance = Appearance() + + private lazy var inputContainerView: FillBlanksSelectContainerView = { + let view = FillBlanksSelectContainerView( + appearance: .init(cornerRadius: self.appearance.cornerRadius) + ) + return view + }() + + private lazy var textLabel: UILabel = { + Self.makeTextLabel(appearance: self.appearance) + }() + + var text: String? { + didSet { + self.textLabel.text = self.text + } + } + + var isEnabled = true { + didSet { + self.isUserInteractionEnabled = self.isEnabled + } + } + + var state: FillBlanksSelectContainerView.State { + get { + self.inputContainerView.state + } + set { + self.inputContainerView.state = newValue + } + } + + override var isHighlighted: Bool { + didSet { + self.textLabel.alpha = self.isHighlighted ? 0.5 : 1.0 + } + } + + 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") + } + + static func calculatePreferredContentSize(text: String, maxWidth: CGFloat) -> CGSize { + if Self.prototypeTextLabel == nil { + Self.prototypeTextLabel = Self.makeTextLabel() + } + + guard let label = Self.prototypeTextLabel else { + return .zero + } + + let appearance = Appearance() + + var maxWidth = maxWidth + maxWidth -= appearance.insets.leading + appearance.insets.trailing + + label.frame = CGRect(x: 0, y: 0, width: maxWidth, height: CGFloat.greatestFiniteMagnitude) + + label.text = text + label.sizeToFit() + + let labelSize = label.bounds.size + + let widthWithInsets = appearance.insets.leading + + labelSize.width.rounded(.up) + + appearance.insets.trailing + let heightWithInsets = appearance.insets.top + + labelSize.height.rounded(.up) + + appearance.insets.bottom + + let width = max(Appearance.defaultWidth, widthWithInsets) + let height = max(Appearance.defaultHeight, heightWithInsets) + + return CGSize(width: width, height: height) + } + + 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 FillBlanksSelectCollectionViewCell: ProgrammaticallyInitializableViewProtocol { + func addSubviews() { + self.contentView.addSubview(self.inputContainerView) + self.inputContainerView.addSubview(self.textLabel) + } + + func makeConstraints() { + self.inputContainerView.translatesAutoresizingMaskIntoConstraints = false + self.inputContainerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + self.textLabel.translatesAutoresizingMaskIntoConstraints = false + self.textLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.edges.equalToSuperview().inset(self.appearance.insets.uiEdgeInsets) + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Select/FillBlanksSelectContainerView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Select/FillBlanksSelectContainerView.swift new file mode 100644 index 0000000000..b28f04fbfe --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Select/FillBlanksSelectContainerView.swift @@ -0,0 +1,101 @@ +import SnapKit +import UIKit + +extension FillBlanksSelectContainerView { + struct Appearance { + var cornerRadius: CGFloat = 8 + let borderWidth: CGFloat = 1 + } +} + +final class FillBlanksSelectContainerView: 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.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.backgroundColor = self.state.backgroundColor + + if let borderColor = self.state.borderColor { + self.layer.borderColor = borderColor.cgColor + self.layer.borderWidth = self.appearance.borderWidth + } else { + self.layer.borderColor = nil + self.layer.borderWidth = 0 + } + } + + enum State { + case `default` + case firstResponder + case filled + + fileprivate var borderColor: UIColor? { + switch self { + case .default: + nil + case .firstResponder: + ColorPalette.primary + case .filled: + ColorPalette.onSurfaceAlpha12 + } + } + + fileprivate var backgroundColor: UIColor { + switch self { + case .default, .firstResponder: + ColorPalette.onSurfaceAlpha9 + case .filled: + .clear + } + } + } +} + +@available(iOS 17, *) +#Preview { + let view = FillBlanksSelectContainerView() + view.state = .default + return view +} + +@available(iOS 17, *) +#Preview { + let view = FillBlanksSelectContainerView() + view.state = .firstResponder + return view +} + +@available(iOS 17, *) +#Preview { + let view = FillBlanksSelectContainerView() + view.state = .filled + return view +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizCollectionViewAdapter.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizCollectionViewAdapter.swift index fdeb08063c..695174c886 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizCollectionViewAdapter.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizCollectionViewAdapter.swift @@ -21,10 +21,13 @@ final class FillBlanksQuizCollectionViewAdapter: NSObject { weak var delegate: FillBlanksQuizCollectionViewAdapterDelegate? var components: [StepQuizFillBlankComponent] + var options: [StepQuizFillBlankOption] + var isUserInteractionEnabled = true - init(components: [StepQuizFillBlankComponent] = []) { + init(components: [StepQuizFillBlankComponent] = [], options: [StepQuizFillBlankOption] = []) { self.components = components + self.options = options super.init() } } @@ -91,6 +94,20 @@ extension FillBlanksQuizCollectionViewAdapter: UICollectionViewDataSource { didDeselectComponentAt: indexPath ) } + return cell + case .select: + let cell: FillBlanksSelectCollectionViewCell = collectionView.dequeueReusableCell(for: indexPath) + + if let selectedOptionIndex = component.selectedOptionIndex { + cell.text = self.options[selectedOptionIndex].displayText + cell.state = .filled + } else { + cell.text = nil + cell.state = component.isFirstResponder ? .firstResponder : .default + } + + cell.isEnabled = self.isUserInteractionEnabled + return cell } } @@ -127,6 +144,15 @@ extension FillBlanksQuizCollectionViewAdapter: UICollectionViewDelegateFlowLayou text: component.inputText ?? "", maxWidth: maxWidth ) + case .select: + if let selectedOptionIndex = component.selectedOptionIndex { + return FillBlanksSelectCollectionViewCell.calculatePreferredContentSize( + text: self.options[selectedOptionIndex].displayText, + maxWidth: maxWidth + ) + } else { + return FillBlanksSelectCollectionViewCell.Appearance.defaultSize + } } } @@ -138,7 +164,7 @@ extension FillBlanksQuizCollectionViewAdapter: UICollectionViewDelegateFlowLayou switch self.components[indexPath.row].type { case .text, .lineBreak: return false - case .input: + case .input, .select: return true } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift index b00abcf8eb..15f0de43a9 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift @@ -36,6 +36,7 @@ final class FillBlanksQuizView: UIView { collectionView.isScrollEnabled = false collectionView.register(cellClass: FillBlanksInputCollectionViewCell.self) collectionView.register(cellClass: FillBlanksTextCollectionViewCell.self) + collectionView.register(cellClass: FillBlanksSelectCollectionViewCell.self) return collectionView }() diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/InputOutput/StepQuizFillBlanksSelectOptionsInputProtocol.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/InputOutput/StepQuizFillBlanksSelectOptionsInputProtocol.swift new file mode 100644 index 0000000000..d38bdbb32a --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/InputOutput/StepQuizFillBlanksSelectOptionsInputProtocol.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol StepQuizFillBlanksSelectOptionsInputProtocol: AnyObject { + func update(options: [StepQuizFillBlankOption], selectedIndices: Set, blanksCount: Int) +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/InputOutput/StepQuizFillBlanksSelectOptionsOutputProtocol.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/InputOutput/StepQuizFillBlanksSelectOptionsOutputProtocol.swift new file mode 100644 index 0000000000..93a3ed4dae --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/InputOutput/StepQuizFillBlanksSelectOptionsOutputProtocol.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol StepQuizFillBlanksSelectOptionsOutputProtocol: AnyObject { + func handleStepQuizFillBlanksSelectOptionsDidSelectOption(option: StepQuizFillBlankOption, at index: Int) +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/StepQuizFillBlanksSelectOptionsViewWrapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/StepQuizFillBlanksSelectOptionsViewWrapper.swift new file mode 100644 index 0000000000..7ae7a5a8b0 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/StepQuizFillBlanksSelectOptionsViewWrapper.swift @@ -0,0 +1,150 @@ +import Foundation +import SwiftUI +import UIKit + +struct StepQuizFillBlanksSelectOptionsViewWrapper: UIViewRepresentable { + var moduleOutput: StepQuizFillBlanksSelectOptionsOutputProtocol? + var moduleInput: ((StepQuizFillBlanksSelectOptionsInputProtocol) -> Void)? + + var isUserInteractionEnabled = true + + var onNewHeight: ((CGFloat) -> Void)? + + static func dismantleUIView(_ uiView: StepQuizFillBlanksSelectOptionsView, coordinator: Coordinator) { + uiView.onNewHeight = nil + coordinator.onUpdateCollectionViewData = nil + coordinator.onDidSelectComponent = nil + coordinator.onDeviceOrientationDidChange = nil + coordinator.collectionViewAdapter.delegate = nil + } + + func makeUIView(context: Context) -> StepQuizFillBlanksSelectOptionsView { + StepQuizFillBlanksSelectOptionsView() + } + + func updateUIView(_ uiView: StepQuizFillBlanksSelectOptionsView, context: Context) { + moduleInput?(context.coordinator) + + uiView.onNewHeight = onNewHeight + + let collectionViewAdapter = context.coordinator.collectionViewAdapter + + collectionViewAdapter.isUserInteractionEnabled = isUserInteractionEnabled + context.coordinator.onUpdateCollectionViewData = { [weak uiView] collectionViewAdapter in + guard let uiView else { + return + } + + uiView.updateCollectionViewData( + delegate: collectionViewAdapter, + dataSource: collectionViewAdapter + ) + } + context.coordinator.onDidSelectComponent = { + [weak uiView, weak collectionViewAdapter, weak moduleOutput] indexPath in + guard let uiView, let collectionViewAdapter else { + return + } + + uiView.updateCollectionViewData( + delegate: collectionViewAdapter, + dataSource: collectionViewAdapter + ) + + guard let moduleOutput else { + assertionFailure("StepQuizFillBlanksSelectOptionsViewWrapper: moduleOutput is nil") + return + } + + moduleOutput.handleStepQuizFillBlanksSelectOptionsDidSelectOption( + option: collectionViewAdapter.options[indexPath.row], + at: indexPath.row + ) + + FeedbackGenerator(feedbackType: .selection).triggerFeedback() + } + context.coordinator.onDeviceOrientationDidChange = { [weak uiView] in + guard let uiView else { + return + } + + uiView.invalidateCollectionViewLayout() + } + } + + func makeCoordinator() -> Coordinator { + let coordinator = Coordinator() + moduleInput?(coordinator) + return coordinator + } +} + +// MARK: - Coordinator - + +extension StepQuizFillBlanksSelectOptionsViewWrapper { + class Coordinator: NSObject { + private(set) var collectionViewAdapter = StepQuizFillBlanksSelectOptionsCollectionViewAdapter() + + private var blanksCount: Int = 0 + + var onUpdateCollectionViewData: ((StepQuizFillBlanksSelectOptionsCollectionViewAdapter) -> Void)? + var onDidSelectComponent: ((IndexPath) -> Void)? + + var onDeviceOrientationDidChange: (() -> Void)? + + override init() { + super.init() + + collectionViewAdapter.delegate = self + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDeviceOrientationDidChange), + name: UIDevice.orientationDidChangeNotification, + object: nil + ) + } + + @objc + private func handleDeviceOrientationDidChange() { + onDeviceOrientationDidChange?() + } + } +} + +extension StepQuizFillBlanksSelectOptionsViewWrapper.Coordinator: StepQuizFillBlanksSelectOptionsInputProtocol { + func update(options: [StepQuizFillBlankOption], selectedIndices: Set, blanksCount: Int) { + self.blanksCount = blanksCount + + let isOptionsChanged = collectionViewAdapter.options != options + let isSelectedIndicesChanged = collectionViewAdapter.selectedIndices != selectedIndices + + let shouldUpdateCollectionViewData = isOptionsChanged || isSelectedIndicesChanged + + collectionViewAdapter.options = options + collectionViewAdapter.selectedIndices = selectedIndices + + if shouldUpdateCollectionViewData { + onUpdateCollectionViewData?(collectionViewAdapter) + } + } +} + +extension StepQuizFillBlanksSelectOptionsViewWrapper.Coordinator: + StepQuizFillBlanksSelectOptionsCollectionViewAdapterDelegate { + func fillBlanksSelectOptionsCollectionViewAdapter( + _ adapter: StepQuizFillBlanksSelectOptionsCollectionViewAdapter, + didSelectComponentAt indexPath: IndexPath + ) { + guard (blanksCount - collectionViewAdapter.selectedIndices.count) > 0 else { + return + } + + let (isInserted, _) = collectionViewAdapter.selectedIndices.insert(indexPath.row) + guard isInserted else { + return + } + + onDidSelectComponent?(indexPath) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/Cell/StepQuizFillBlanksSelectOptionsCollectionViewCell.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/Cell/StepQuizFillBlanksSelectOptionsCollectionViewCell.swift new file mode 100644 index 0000000000..def7e55bb2 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/Cell/StepQuizFillBlanksSelectOptionsCollectionViewCell.swift @@ -0,0 +1,136 @@ +import SnapKit +import UIKit + +extension StepQuizFillBlanksSelectOptionsCollectionViewCell { + struct Appearance { + static let defaultSize = CGSize(width: 48, height: 32) + + static var defaultWidth: CGFloat { Self.defaultSize.width } + static var defaultHeight: CGFloat { Self.defaultSize.height } + + let cornerRadius: CGFloat = 8 + let insets = LayoutInsets.small + + let textColor = UIColor.primaryText + static let font = CodeEditorThemeService().theme.font + } +} + +final class StepQuizFillBlanksSelectOptionsCollectionViewCell: UICollectionViewCell, Reusable { + private static var prototypeTextLabel: UILabel? + + var appearance = Appearance() + + private lazy var inputContainerView: StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView = { + let view = StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView( + appearance: .init(cornerRadius: self.appearance.cornerRadius) + ) + return view + }() + + private lazy var textLabel: UILabel = { + Self.makeTextLabel(appearance: self.appearance) + }() + + var text: String? { + didSet { + self.textLabel.text = self.text + } + } + + var isEnabled = true { + didSet { + self.isUserInteractionEnabled = self.isEnabled + } + } + + var state: StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.State { + get { + self.inputContainerView.state + } + set { + self.inputContainerView.state = newValue + } + } + + override var isHighlighted: Bool { + didSet { + self.textLabel.alpha = self.isHighlighted ? 0.5 : 1.0 + } + } + + 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") + } + + static func calculatePreferredContentSize(text: String, maxWidth: CGFloat) -> CGSize { + if Self.prototypeTextLabel == nil { + Self.prototypeTextLabel = Self.makeTextLabel() + } + + guard let label = Self.prototypeTextLabel else { + return .zero + } + + let appearance = Appearance() + + var maxWidth = maxWidth + maxWidth -= appearance.insets.leading + appearance.insets.trailing + + label.frame = CGRect(x: 0, y: 0, width: maxWidth, height: CGFloat.greatestFiniteMagnitude) + + label.text = text + label.sizeToFit() + + let labelSize = label.bounds.size + + let widthWithInsets = appearance.insets.leading + + labelSize.width.rounded(.up) + + appearance.insets.trailing + let heightWithInsets = appearance.insets.top + + labelSize.height.rounded(.up) + + appearance.insets.bottom + + let width = max(Appearance.defaultWidth, widthWithInsets) + let height = max(Appearance.defaultHeight, heightWithInsets) + + return CGSize(width: width, height: height) + } + + 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 StepQuizFillBlanksSelectOptionsCollectionViewCell: ProgrammaticallyInitializableViewProtocol { + func addSubviews() { + self.contentView.addSubview(self.inputContainerView) + self.inputContainerView.addSubview(self.textLabel) + } + + func makeConstraints() { + self.inputContainerView.translatesAutoresizingMaskIntoConstraints = false + self.inputContainerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + self.textLabel.translatesAutoresizingMaskIntoConstraints = false + self.textLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.edges.equalToSuperview().inset(self.appearance.insets.uiEdgeInsets) + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/Cell/StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/Cell/StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift new file mode 100644 index 0000000000..ea6e81f7e1 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/Cell/StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift @@ -0,0 +1,91 @@ +import SnapKit +import UIKit + +extension StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView { + struct Appearance { + var cornerRadius: CGFloat = 8 + let borderWidth: CGFloat = 1 + } +} + +final class StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView: 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.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.backgroundColor = self.state.backgroundColor + + if let borderColor = self.state.borderColor { + self.layer.borderColor = borderColor.cgColor + self.layer.borderWidth = self.appearance.borderWidth + } else { + self.layer.borderColor = nil + self.layer.borderWidth = 0 + } + } + + enum State { + case `default` + case selected + + fileprivate var borderColor: UIColor? { + switch self { + case .default: + ColorPalette.onSurfaceAlpha12 + case .selected: + nil + } + } + + fileprivate var backgroundColor: UIColor { + switch self { + case .default: + ColorPalette.background + case .selected: + ColorPalette.onSurfaceAlpha9 + } + } + } +} + +@available(iOS 17, *) +#Preview { + let view = StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView() + view.state = .default + return view +} + +@available(iOS 17, *) +#Preview { + let view = StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView() + view.state = .selected + return view +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift new file mode 100644 index 0000000000..29732e60fa --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift @@ -0,0 +1,84 @@ +import UIKit + +protocol StepQuizFillBlanksSelectOptionsCollectionViewAdapterDelegate: AnyObject { + func fillBlanksSelectOptionsCollectionViewAdapter( + _ adapter: StepQuizFillBlanksSelectOptionsCollectionViewAdapter, + didSelectComponentAt indexPath: IndexPath + ) +} + +final class StepQuizFillBlanksSelectOptionsCollectionViewAdapter: NSObject { + weak var delegate: StepQuizFillBlanksSelectOptionsCollectionViewAdapterDelegate? + + var options: [StepQuizFillBlankOption] + var selectedIndices = Set() + + var isUserInteractionEnabled = true + + init(options: [StepQuizFillBlankOption] = [], selectedIndices: Set = []) { + self.options = options + self.selectedIndices = selectedIndices + super.init() + } +} + +// MARK: - StepQuizFillBlanksSelectOptionsCollectionViewAdapter: UICollectionViewDataSource - + +extension StepQuizFillBlanksSelectOptionsCollectionViewAdapter: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + self.options.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + let option = self.options[indexPath.row] + + let cell: StepQuizFillBlanksSelectOptionsCollectionViewCell = collectionView.dequeueReusableCell(for: indexPath) + cell.isEnabled = self.isUserInteractionEnabled + + let isSelected = selectedIndices.contains(indexPath.row) + cell.state = isSelected ? .selected : .default + cell.text = isSelected ? nil : option.displayText + + return cell + } +} + +// MARK: - StepQuizFillBlanksSelectOptionsCollectionViewAdapter: UICollectionViewDelegateFlowLayout - + +extension StepQuizFillBlanksSelectOptionsCollectionViewAdapter: 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 option = self.options[indexPath.row] + + return StepQuizFillBlanksSelectOptionsCollectionViewCell.calculatePreferredContentSize( + text: option.displayText, + maxWidth: maxWidth + ) + } + + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + if !self.isUserInteractionEnabled { + return false + } + + return !selectedIndices.contains(indexPath.row) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + delegate?.fillBlanksSelectOptionsCollectionViewAdapter(self, didSelectComponentAt: indexPath) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/StepQuizFillBlanksSelectOptionsView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/StepQuizFillBlanksSelectOptionsView.swift new file mode 100644 index 0000000000..d5f3c2b8df --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/StepQuizFillBlanksSelectOptionsView.swift @@ -0,0 +1,114 @@ +import SnapKit +import UIKit + +extension StepQuizFillBlanksSelectOptionsView { + struct Appearance { + var collectionViewMaxHeight: CGFloat { UIScreen.main.bounds.height / 3 } + let collectionViewMinHeight: CGFloat = 44 + let collectionViewMinLineSpacing = LayoutInsets.defaultInset + let collectionViewMinInteritemSpacing = LayoutInsets.defaultInset + let collectionViewSectionInset = LayoutInsets.default.uiEdgeInsets + + let backgroundColor = UIColor.systemBackground + } +} + +final class StepQuizFillBlanksSelectOptionsView: UIView { + let appearance: Appearance + + 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.automaticallyAdjustsScrollIndicatorInsets = false + collectionView.register(cellClass: StepQuizFillBlanksSelectOptionsCollectionViewCell.self) + + return collectionView + }() + + private lazy var topSeparatorView = UIKitSeparatorView() + + var onNewHeight: ((CGFloat) -> Void)? + + override var intrinsicContentSize: CGSize { + let collectionViewContentSize = self.collectionView.collectionViewLayout.collectionViewContentSize + let collectionViewHeight = max( + self.appearance.collectionViewMinHeight, + min(self.appearance.collectionViewMaxHeight, collectionViewContentSize.height) + ) + + onNewHeight?(collectionViewHeight) + self.collectionView.isScrollEnabled = collectionViewContentSize.height > self.appearance.collectionViewMaxHeight + + return CGSize(width: UIView.noIntrinsicMetric, height: collectionViewHeight) + } + + 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() + } + } + } +} + +extension StepQuizFillBlanksSelectOptionsView: ProgrammaticallyInitializableViewProtocol { + func setupView() { + self.backgroundColor = self.appearance.backgroundColor + } + + func addSubviews() { + self.addSubview(self.collectionView) + self.addSubview(self.topSeparatorView) + } + + func makeConstraints() { + self.collectionView.translatesAutoresizingMaskIntoConstraints = false + self.collectionView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + self.topSeparatorView.translatesAutoresizingMaskIntoConstraints = false + self.topSeparatorView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizHints/Views/StepQuizHintsView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizHints/Views/StepQuizHintsView.swift index 65444e79da..8be4288329 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizHints/Views/StepQuizHintsView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizHints/Views/StepQuizHintsView.swift @@ -4,8 +4,8 @@ import SwiftUI extension StepQuizHintsView { struct Appearance { - var skeletonInitialHeight: CGFloat = 34 - var skeletonHintHeight: CGFloat = 152 + let skeletonInitialSize = CGSize(width: 146, height: 34) + let skeletonHintHeight: CGFloat = 152 } } @@ -31,10 +31,10 @@ struct StepQuizHintsView: View { @ViewBuilder private func buildBody() -> some View { switch viewModel.stateKs { - case .idle: + case .idle, .initialLoading: SkeletonRoundedView() - .frame(height: appearance.skeletonInitialHeight) - case .initialLoading, .hintLoading: + .frame(size: appearance.skeletonInitialSize) + case .hintLoading: SkeletonRoundedView() .frame(height: appearance.skeletonHintHeight) case .error: diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizString/StepQuizStringAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizString/StepQuizStringAssembly.swift index cd94cb9109..f8160c1fd9 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizString/StepQuizStringAssembly.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizString/StepQuizStringAssembly.swift @@ -15,7 +15,7 @@ final class StepQuizStringAssembly: StepQuizChildQuizAssembly { private let dataset: Dataset private let reply: Reply? - private var dataType = StepQuizStringDataType.string + private let dataType: StepQuizStringDataType init( dataType: StepQuizStringDataType, @@ -33,6 +33,7 @@ final class StepQuizStringAssembly: StepQuizChildQuizAssembly { self.moduleOutput = moduleOutput } + // swiftlint:disable:next unavailable_function init( step: Step, dataset: Dataset, @@ -40,11 +41,7 @@ final class StepQuizStringAssembly: StepQuizChildQuizAssembly { provideModuleInputCallback: @escaping (StepQuizChildQuizInputProtocol?) -> Void, moduleOutput: StepQuizChildQuizOutputProtocol? ) { - self.step = step - self.dataset = dataset - self.reply = reply - self.provideModuleInputCallback = provideModuleInputCallback - self.moduleOutput = moduleOutput + fatalError("init(step:dataset:reply:provideModuleInputCallback:moduleOutput:) has not been implemented") } func makeModule() -> StepQuizStringView { diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizStateExtentions.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizStateExtentions.kt new file mode 100644 index 0000000000..d0664d8070 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizStateExtentions.kt @@ -0,0 +1,11 @@ +package org.hyperskill.app.step_quiz.presentation + +val StepQuizFeature.StepQuizState.attemptLoadedState: StepQuizFeature.StepQuizState.AttemptLoaded? + get() = when (this) { + is StepQuizFeature.StepQuizState.AttemptLoaded -> this + is StepQuizFeature.StepQuizState.AttemptLoading -> this.oldState + StepQuizFeature.StepQuizState.Idle, + StepQuizFeature.StepQuizState.Loading, + StepQuizFeature.StepQuizState.NetworkError, + StepQuizFeature.StepQuizState.Unsupported -> null + } \ No newline at end of file From 698e9938ef46c9bd5b1f7b929a430bc282a47d53 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Mon, 30 Oct 2023 16:37:43 +0400 Subject: [PATCH 3/3] GitHub Actions: update Xcode version from 15.0.0 to 15.0.1 --- .github/actions/setup-ios/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-ios/action.yml b/.github/actions/setup-ios/action.yml index f03086bdef..120c70c6f9 100644 --- a/.github/actions/setup-ios/action.yml +++ b/.github/actions/setup-ios/action.yml @@ -20,7 +20,7 @@ runs: - name: Setup Xcode version uses: maxim-lobanov/setup-xcode@v1.6.0 with: - xcode-version: '15.0.0' + xcode-version: '15.0.1' - name: Homebrew install git-crypt run: brew install git-crypt