Skip to content

Commit

Permalink
iOS fill in the blanks with inputs (#720)
Browse files Browse the repository at this point in the history
^ALTAPPS-1016
  • Loading branch information
ivan-magda authored Oct 24, 2023
1 parent e1ddecc commit 368d166
Show file tree
Hide file tree
Showing 23 changed files with 1,321 additions and 15 deletions.
112 changes: 112 additions & 0 deletions iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import UIKit

extension UIFont {
func sizeOfString(string: String, constrainedToWidth width: Double) -> CGSize {
NSString(string: string).boundingRect(
with: CGSize(width: width, height: Double.greatestFiniteMagnitude),
options: NSStringDrawingOptions.usesLineFragmentOrigin,
attributes: [NSAttributedString.Key.font: self],
context: nil
).size
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ enum Strings {
}
}

// MARK: - StepQuizFillBlanks-

enum StepQuizFillBlanks {
static let title = sharedStrings.step_quiz_fill_blanks_title.localized()
}

// MARK: - StageImplement -

enum StageImplement {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ enum StepQuizChildQuizViewFactory {
moduleOutput: moduleOutput
)
.makeModule()
case .fillBlanks:
StepQuizFillBlanksAssembly(
step: step,
dataset: dataset,
reply: reply,
provideModuleInputCallback: provideModuleInputCallback,
moduleOutput: moduleOutput
)
.makeModule()
case .unsupported(let blockName):
fatalError("Unsupported quiz = \(blockName)")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ enum StepQuizChildQuizType {
case number
case math
case parsons
case fillBlanks
case unsupported(blockName: String)

var isCodeRelated: Bool {
Expand Down Expand Up @@ -49,6 +50,8 @@ enum StepQuizChildQuizType {
self = .math
case BlockName.shared.PARSONS:
self = .parsons
case BlockName.shared.FILL_BLANKS:
self = .fillBlanks
default:
self = .unsupported(blockName: step.block.name)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,23 @@ final class StepQuizViewDataMapper {
}

func mapStepDataToViewData(step: Step, state: StepQuizFeatureStepQuizStateKs) -> StepQuizViewData {
let quizType: StepQuizChildQuizType = {
if state == .unsupported {
return .unsupported(blockName: step.block.name)
let attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded? = {
switch state {
case .attemptLoading(let attemptLoadingState):
return attemptLoadingState.oldState
case .attemptLoaded(let attemptLoadedState):
return attemptLoadedState
default:
return nil
}
return StepQuizChildQuizType(step: step)
}()

let quizType = resolveQuizType(
step: step,
state: state,
attemptLoadedState: attemptLoadedState
)

if case .unsupported = quizType {
return StepQuizViewData(
formattedStats: nil,
Expand All @@ -35,17 +45,6 @@ final class StepQuizViewDataMapper {
millisSinceLastCompleted: step.millisSinceLastCompleted
)

let attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded? = {
switch state {
case .attemptLoading(let attemptLoadingState):
return attemptLoadingState.oldState
case .attemptLoaded(let attemptLoadedState):
return attemptLoadedState
default:
return nil
}
}()

let quizName: String? = {
guard let dataset = attemptLoadedState?.attempt.dataset else {
return nil
Expand Down Expand Up @@ -91,4 +90,35 @@ final class StepQuizViewDataMapper {
stepHasHints: stepHasHints
)
}

private func resolveQuizType(
step: Step,
state: StepQuizFeatureStepQuizStateKs,
attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded?
) -> StepQuizChildQuizType {
let unsupportedChildQuizType = StepQuizChildQuizType.unsupported(blockName: step.block.name)

if state == .unsupported {
return unsupportedChildQuizType
}

let childQuizType = StepQuizChildQuizType(step: step)

if case .fillBlanks = childQuizType {
guard let dataset = attemptLoadedState?.attempt.dataset else {
return childQuizType
}

do {
try FillBlanksResolver.shared.resolve(dataset: dataset)
} catch {
#if DEBUG
print("StepQuizViewDataMapper: failed to resolve fill blanks quiz type, error = \(error)")
#endif
return unsupportedChildQuizType
}
}

return childQuizType
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ enum StepQuizSkeletonViewFactory {
StepQuizStringSkeletonView()
case .parsons:
StepQuizParsonsSkeletonView()
case .fillBlanks:
#warning("TODO: FillBlanks skeleton view")
StepQuizParsonsSkeletonView()
case .unsupported:
SkeletonRoundedView()
.frame(height: 100)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Highlightr
import shared
import SwiftUI

final class StepQuizFillBlanksAssembly: StepQuizChildQuizAssembly {
var moduleInput: StepQuizChildQuizInputProtocol?
weak var moduleOutput: StepQuizChildQuizOutputProtocol?

private let provideModuleInputCallback: (StepQuizChildQuizInputProtocol?) -> Void

private let step: Step
private let dataset: Dataset
private let reply: Reply?

init(
step: Step,
dataset: Dataset,
reply: Reply?,
provideModuleInputCallback: @escaping (StepQuizChildQuizInputProtocol?) -> Void,
moduleOutput: StepQuizChildQuizOutputProtocol?
) {
self.step = step
self.dataset = dataset
self.reply = reply
self.provideModuleInputCallback = provideModuleInputCallback
self.moduleOutput = moduleOutput
}

func makeModule() -> StepQuizFillBlanksView {
let viewModel = StepQuizFillBlanksViewModel(
step: step,
dataset: dataset,
reply: reply,
viewDataMapper: StepQuizFillBlanksViewDataMapper(
fillBlanksItemMapper: FillBlanksItemMapper(),
highlightr: Highlightr().require(),
codeEditorThemeService: CodeEditorThemeService(),
cache: StepQuizFillBlanksViewDataMapperCache.shared
),
provideModuleInputCallback: provideModuleInputCallback
)

moduleInput = viewModel
viewModel.moduleOutput = moduleOutput

return StepQuizFillBlanksView(viewModel: viewModel)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Combine
import Foundation
import shared

final class StepQuizFillBlanksViewModel: ObservableObject {
weak var moduleOutput: StepQuizChildQuizOutputProtocol?
private let provideModuleInputCallback: (StepQuizChildQuizInputProtocol?) -> Void

@Published private(set) var viewData: StepQuizFillBlanksViewData

init(
step: Step,
dataset: Dataset,
reply: Reply?,
viewDataMapper: StepQuizFillBlanksViewDataMapper,
provideModuleInputCallback: @escaping (StepQuizChildQuizInputProtocol?) -> Void
) {
self.provideModuleInputCallback = provideModuleInputCallback
self.viewData = viewDataMapper.mapToViewData(dataset: dataset, reply: reply)
}

func doProvideModuleInput() {
provideModuleInputCallback(self)
}

func doInputTextUpdate(_ inputText: String, for component: StepQuizFillBlankComponent) {
guard let index = viewData.components.firstIndex(
where: { $0.id == component.id }
) else {
return
}

viewData.components[index].inputText = inputText
outputCurrentReply()
}

func doSelectComponent(at indexPath: IndexPath) {
setIsFirstResponder(true, forComponentAt: indexPath)
}

func doDeselectComponent(at indexPath: IndexPath) {
setIsFirstResponder(false, forComponentAt: indexPath)
}

private func setIsFirstResponder(_ isFirstResponder: Bool, forComponentAt indexPath: IndexPath) {
guard viewData.components[indexPath.row].type == .input else {
return
}

viewData.components[indexPath.row].isFirstResponder = isFirstResponder
}
}

// MARK: - StepQuizFillBlanksViewModel: StepQuizChildQuizInputProtocol -

extension StepQuizFillBlanksViewModel: StepQuizChildQuizInputProtocol {
func createReply() -> Reply {
let blanks: [String] = viewData.components.compactMap { component in
switch component.type {
case .text, .lineBreak:
return nil
case .input:
return component.inputText ?? ""
}
}

return Reply.companion.fillBlanks(blanks: blanks)
}

private func outputCurrentReply() {
moduleOutput?.handleChildQuizSync(reply: createReply())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

struct StepQuizFillBlanksViewData: Hashable {
var components: [StepQuizFillBlankComponent]
}

struct StepQuizFillBlankComponent: Hashable, Identifiable {
var id: Int = 0
let type: ComponentType
// text
var attributedText: NSAttributedString?
// input
var inputText: String?
var isFirstResponder = false

enum ComponentType {
case text
case input
case lineBreak
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import Foundation
import Highlightr
import shared

final class StepQuizFillBlanksViewDataMapper {
private let fillBlanksItemMapper: FillBlanksItemMapper
private let highlightr: Highlightr
private let codeEditorThemeService: CodeEditorThemeServiceProtocol
private let cache: StepQuizFillBlanksViewDataMapperCacheProtocol

init(
fillBlanksItemMapper: FillBlanksItemMapper,
highlightr: Highlightr,
codeEditorThemeService: CodeEditorThemeServiceProtocol,
cache: StepQuizFillBlanksViewDataMapperCacheProtocol
) {
let theme = codeEditorThemeService.theme
highlightr.setTheme(to: theme.name)
highlightr.theme.setCodeFont(theme.font)

self.highlightr = highlightr
self.codeEditorThemeService = codeEditorThemeService
self.cache = cache
self.fillBlanksItemMapper = fillBlanksItemMapper
}

func mapToViewData(dataset: Dataset, reply: Reply?) -> StepQuizFillBlanksViewData {
guard let fillBlanksData = fillBlanksItemMapper.map(dataset: dataset, reply: reply) else {
return .init(components: [])
}

return mapFillBlanksDataToViewData(fillBlanksData)
}

private func mapFillBlanksDataToViewData(_ fillBlanksData: FillBlanksData) -> StepQuizFillBlanksViewData {
let language = fillBlanksData.language

var components = fillBlanksData.fillBlanks
.map { mapFillBlanksItem($0, language: language) }
.flatMap { $0 }
for index in components.indices {
components[index].id = index
}

return StepQuizFillBlanksViewData(components: components)
}

private func mapFillBlanksItem(
_ fillBlanksItem: FillBlanksItem,
language: String?
) -> [StepQuizFillBlankComponent] {
switch FillBlanksItemKs(fillBlanksItem) {
case .text(let data):
var result = [StepQuizFillBlankComponent]()

if data.startsWithNewLine {
result.append(StepQuizFillBlankComponent(type: .lineBreak))
}

let hash = data.text.hashValue ^ UITraitCollection.current.userInterfaceStyle.hashValue

if let cachedCode = cache.getHighlightedCode(for: hash) {
result.append(StepQuizFillBlankComponent(type: .text, attributedText: cachedCode))
} else {
let unescaped = HTMLString.unescape(string: data.text)

if let highlightedCode = highlight(code: unescaped, language: language) {
cache.setHighlightedCode(highlightedCode, for: hash)
result.append(StepQuizFillBlankComponent(type: .text, attributedText: highlightedCode))
} else {
let attributedText = NSAttributedString(
string: unescaped,
attributes: [.font: codeEditorThemeService.theme.font]
)
cache.setHighlightedCode(attributedText, for: hash)
result.append(StepQuizFillBlankComponent(type: .text, attributedText: attributedText))
}
}

return result
case .input(let data):
return [StepQuizFillBlankComponent(type: .input, inputText: data.inputText)]
}
}

private func highlight(code: String, language: String?) -> NSAttributedString? {
highlightr.highlight(code, as: language, fastRender: true)
}
}
Loading

0 comments on commit 368d166

Please sign in to comment.