Skip to content

Exercises: Add participation in quiz exercises #68

@nityanandaz

Description

@nityanandaz

Prototype

See #65

Multiple-choice question

Screen.Recording.2023-12-20.at.20.11.07.mov
Details

import SwiftUI

struct MultipleChoiceView: View {

    struct Exercise {

        struct Question {

            struct Choice {
                var title: String
                var hint: String?
                var explanation: String?
                var isOn: Bool

                var item: Hint?
            }

            var title: String

            var longQuestion: String?
            var hint: String?

            var choices: [Choice]

            var item: Hint?
        }

        var title: String
        var questions: [Question]
    }

    struct Hint: Identifiable {
        var message: String

        var id: String {
            message
        }
    }

    struct MultipleChoiceToggleStyle: ToggleStyle {
        func makeBody(configuration: Configuration) -> some View {
            HStack {
                configuration.label
                Button {
                    configuration.isOn.toggle()
                } label: {
                    if configuration.isOn {
                        Image(systemName: "checkmark.square.fill")
                    } else {
                        Image(systemName: "square")
                    }
                }
                .foregroundStyle(.foreground)
            }
            .padding()
            .background(.secondary.opacity(0.2), in: .rect(cornerRadius: 5))
        }
    }

    @Environment(\.isEnabled) var isEnabled

    @State var exercise = Exercise(
        title: "A Quiz Exercise",
        questions: [
            .init(
                title: "Multiple-choice question",
                longQuestion: "A very very very very very very very very very very very very very very very very very long question?",
                hint: "Something",
                choices: [
                    .init(
                        title: "Enter a correct answer option here",
                        hint: "This is correct",
                        explanation: "Add an explanation here (only visible in feedback after quiz has ended)",
                        isOn: false),
                    .init(title: "Maybe this is correct, too", isOn: false),
                    .init(title: "Enter a wrong answer option here", isOn: false)
                ]),
            .init(
                title: "What does every program say first?",
                hint: "Nothing",
                choices: [
                    .init(title: "Hello, world!", isOn: false)
                ])
        ])

    var body: some View {
        ScrollView {
            VStack {
                ForEach($exercise.questions, id: \.title, content: self.question)
            }
            .padding(.horizontal)
        }
        .navigationTitle(exercise.title)
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem {
                Button("Submit") {
                    //
                }
            }
        }
    }
}

private extension MultipleChoiceView {
    func question(_ question: Binding<Exercise.Question>) -> some View {
        VStack(alignment: .leading) {
            HStack {
                Text(question.wrappedValue.title)
                    .font(.title2)
                Spacer()
                question.wrappedValue.hint.map { hint in
                    Button {
                        question.wrappedValue.item = Hint(message: hint)
                    } label: {
                        Image(systemName: "questionmark.circle.fill")
                    }
                    .popover(item: question.item) { item in
                        Text(item.message)
                            .padding(.horizontal)
                            .presentationCompactAdaptation(.popover)
                    }
                }
                Text("1 P")
                    .font(.body.bold())
            }
            question.wrappedValue.longQuestion.map(Text.init)
            ForEach(question.choices, id: \.title, content: self.choice)
            Text("Please check all correct answer options")
                .font(.footnote)
        }
        .padding()
        .background(.secondary.opacity(0.1), in: .rect(cornerRadius: 5))
    }

    func choice(_ choice: Binding<Exercise.Question.Choice>) -> some View {
        VStack {
            Toggle(isOn: choice.isOn) {
                HStack {
                    Text(choice.wrappedValue.title)
                    Spacer()
                    if isEnabled {
                        choice.wrappedValue.hint.map { hint in
                            Button {
                                choice.wrappedValue.item = Hint(message: hint)
                            } label: {
                                Image(systemName: "questionmark.circle.fill")
                            }
                            .popover(item: choice.item) { item in
                                Text(item.message)
                                    .padding(.horizontal)
                                    .presentationCompactAdaptation(.popover)
                            }
                        }
                    }
                }
            }
            .toggleStyle(MultipleChoiceToggleStyle())
            if !isEnabled {
                if let hint = choice.wrappedValue.hint {
                    HStack {
                        Image(systemName: "questionmark.circle.fill")
                        Text(hint)
                        Spacer()
                    }
                }
                if let explanation = choice.wrappedValue.explanation {
                    HStack(alignment: .top) {
                        Image(systemName: "exclamationmark.circle.fill")
                        Text(explanation)
                        Spacer()
                    }
                }
            }
        }
    }
}

#Preview {
    NavigationStack {
        MultipleChoiceView()
            .disabled(false)
    }
}

#Preview {
    NavigationStack {
        MultipleChoiceView()
            .disabled(true)
    }
    .preferredColorScheme(.dark)
}

Short-answer question

Screen.Recording.2023-12-24.at.00.56.47.mov
Details

import SwiftUI

struct ShortAnswerView: View {

    static func makeNodes(_ string: String) -> [Exercise.Question.Node_] {
        let pattern = #/\[-spot \d+\]/#
        let split = string
            .split(separator: pattern)
            .map { substring in
                Exercise.Question.Node_.init(id: .init(), text: String(substring), isSpot: false)
            }

        let matches = string.matches(of: pattern)
        let match = matches[0].output
        print(match)

        assert(split.count == matches.count + 1)

        // https://stackoverflow.com/questions/34951824/how-can-i-interleave-two-arrays
        func mergeFunction<T>(_ one: [T], _ two: [T]) -> [T] {
            let commonLength = min(one.count, two.count)
            return zip(one, two).flatMap { [$0, $1] }
                   + one.suffix(from: commonLength)
                   + two.suffix(from: commonLength)
        }

        return mergeFunction(split,
                             matches.map { x in Exercise.Question.Node_.init(id: .init(), text: "", isSpot: true) })
    }

    struct Exercise {

        struct Question {

            struct Node_: Identifiable {
                let id: UUID
                var text: String
                var isSpot: Bool
            }

            var title: String

            var longQuestion: String?
            var hint: String?

            var nodes: [Node_]

            var item: Hint?
        }

        var title: String
        var questions: [Question]
    }

    struct Hint: Identifiable {
        var message: String

        var id: String {
            message
        }
    }

    @State var exercise = Exercise(
        title: "A Quiz Exercise",
        questions: [
            .init(
                title: "Short-answer question",
                longQuestion: nil,
                hint: "Something",
                nodes: makeNodes("Enter your long question if needed\n\nSelect a part of the text and click on Add Spot to automatically create an input field and the corresponding mapping\n\nYou can define a input field like this: This [-spot 1] an [-spot 2] field.\n\nTo define the solution for the input fields you need to create a mapping (multiple mapping also possible):")),
//            .init(
//                title: "What does every program say first?",
//                hint: "Nothing",
//                choices: [
//                    .init(title: "Hello, world!", isOn: false)
//                ])
        ])

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack {
                    ForEach($exercise.questions, id: \.title, content: self.question)
                }
                .padding(.horizontal)
            }
            .navigationTitle(exercise.title)
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem {
                    Button("Submit") {
                        //
                    }
                }
            }
        }
    }
}

private extension ShortAnswerView {
    func question(_ question: Binding<Exercise.Question>) -> some View {
        VStack(alignment: .leading) {
            HStack/*(alignment: .firstTextBaseline)*/ {
                Text(question.wrappedValue.title)
                    .font(.title2)
                Spacer()
                question.wrappedValue.hint.map { hint in
                    Button {
                        question.wrappedValue.item = Hint(message: hint)
                    } label: {
                        Image(systemName: "questionmark.circle.fill")
                    }
                    .popover(item: question.item) { item in
                        Text(item.message)
                            .padding(.horizontal)
                            .presentationCompactAdaptation(.popover)
                    }
                }
                Text("1 P")
                    .font(.body.bold())
            }
            VStack(alignment: .leading) {
                ForEach(question.nodes) { node in
                    if node.wrappedValue.isSpot {
                        TextField("", text: node.text)
                            .textFieldStyle(.roundedBorder)
                    } else {
                        Text(node.wrappedValue.text)
                    }
                }
            }
            .padding()
            .background(.background, in: .rect(cornerRadius: 5))
        }
        .padding()
        .background(.secondary.opacity(0.1), in: .rect(cornerRadius: 5))
    }
}

#Preview {
    ShortAnswerView()
}

Drag-and-drop question

Simulator.Screen.Recording.-.iPhone.15.Pro.-.2023-12-22.at.00.15.59.mp4
Details

struct DragAndDropView: View {

    struct Exercise {

        struct Question {

            struct Drag: Codable, Hashable, Identifiable, Transferable {
                static var transferRepresentation: some TransferRepresentation {
                    CodableRepresentation(contentType: .data)
                }

                let id: String
                var isText = false
            }

            struct Drop: Hashable, Identifiable {
                var location: CGRect
                var item: Drag?

                func hash(into hasher: inout Hasher) {
                    hasher.combine(location.width)
                    hasher.combine(location.height)
                    hasher.combine(location.minX)
                    hasher.combine(location.minY)
                }

                var id: Int {
                    hashValue
                }
            }

            var title: String

            var longQuestion: String?
            var hint: String?

            var drags: [Drag]
            var drops: [Drop]

            var item: Hint?
        }

        var title: String
        var questions: [Question]
    }

    struct Hint: Identifiable {
        var message: String

        var id: String {
            message
        }
    }

    @State var exercise = Exercise(
        title: "A Quiz Exercise",
        questions: [
            .init(
                title: "Drag-and-drop question",
                longQuestion: "Can you find the missing pieces?",
                hint: "Something",
                drags: [
                    .init(id: "1"),
                    .init(id: "2"),
                    .init(id: "Hello, world!", isText: true)
                ],
                drops: [
//                    let (anX, aY, aW, anH) = (78, 50, 21, 38)
                    .init(location: .init(x: 78, y: 50, width: 21, height: 38), item: nil),
                    .init(location: .init(x: 0, y: 0, width: 10, height: 20), item: nil),
                    .init(location: .init(x: 170, y: 160, width: 30, height: 40), item: nil)
                ])
        ])

    var body: some View {
        ScrollView {
            VStack {
                ForEach($exercise.questions, id: \.title, content: self.question)
            }
            .padding(.horizontal)
        }
        .navigationTitle(exercise.title)
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem {
                Button("Submit") {
                    //
                }
            }
        }
    }
}

private extension DragAndDropView {
    func question(_ question: Binding<Exercise.Question>) -> some View {
        VStack(alignment: .leading) {
            HStack {
                Text(question.wrappedValue.title)
                    .font(.title2)
                Spacer()
                question.wrappedValue.hint.map { hint in
                    Button {
                        question.wrappedValue.item = Hint(message: hint)
                    } label: {
                        Image(systemName: "questionmark.circle.fill")
                    }
                    .popover(item: question.item) { item in
                        Text(item.message)
                            .padding(.horizontal)
                            .presentationCompactAdaptation(.popover)
                    }
                }
                Text("1 P")
                    .font(.body.bold())
            }
            question.wrappedValue.longQuestion.map(Text.init)
            dragAndDrop(question)
            Text("Drag & Drop: Place the suitable items on the correct areas")
                .font(.footnote)
        }
        .padding()
        .background(.secondary.opacity(0.1), in: .rect(cornerRadius: 5))
    }

    func dragAndDrop(_ question: Binding<Exercise.Question>) -> some View {
        VStack {
            Image("background")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .overlay {
                    GeometryReader { proxy in
                        ZStack {
                            ForEach(question.drops) { drop in
                                self.drop(proxy: proxy, drop: drop)
                            }
                        }
                    }
                }
            HStack {
                Spacer()
                ForEach(question.wrappedValue.drags, content: self.drag)
                    .padding()
                Spacer()
            }
            .background(
                RoundedRectangle(cornerRadius: 5)
                    .foregroundStyle(Color.secondary.opacity(0.1)))
        }
    }

    @ViewBuilder
    func drag(drag: Exercise.Question.Drag) -> some View {
        if drag.isText {
            Text(drag.id)
                .padding()
                .background(.secondary.opacity(0.1), in: .rect(cornerRadius: 5))
                .draggable(drag)
        }
        else {
            Image("Untitled \(drag.id)")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(height: 50)
                .draggable(drag)
        }
    }

    func drop(proxy: GeometryProxy, drop: Binding<Exercise.Question.Drop>) -> some View {
        let width  = drop.wrappedValue.location.width  * proxy.size.width  / 200
        let height = drop.wrappedValue.location.height * proxy.size.height / 200
        let x      = drop.wrappedValue.location.minX   * proxy.size.width  / 200 + width / 2
        let y      = drop.wrappedValue.location.minY   * proxy.size.height / 200 + height / 2

        let _ = print(x, y, width, height)

        return Rectangle()
            .stroke(style: StrokeStyle(dash: [1]))
            .background {
                Rectangle()
                    .foregroundStyle(.background)
            }
            .overlay {
                if let item = drop.wrappedValue.item {
                    if item.isText {
                        Text(item.id)
                    }
                    else {
                        Image("Untitled \(item.id)")
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                    }
                }
            }
            .frame(width: width, height: height)
            .position(x: x, y: y)
            .dropDestination(for: Exercise.Question.Drag.self) { items, location in
                if let item = items.first {
                    drop.wrappedValue.item = item
                }
                return true
            }
    }
}
#Preview {
    DragAndDropView()
}

Android

a b c
Screenshot_20231210_014315 Screenshot_20231210_014332 Screenshot_20231210_014346
Screenshot_20231210_014410 Screenshot_20231210_014428
Screenshot_20231210_014441 Screenshot_20231210_014507
Screenshot_20231210_014551 Screenshot_20231210_014602 Screenshot_20231210_014607

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions