feat(ios): M2 测验模块全部 4 个 issue
M2-01 测验列表页 (QuizListView): - 展示知识库下所有测验 + 空状态/生成新测验按钮 - POST /quizzes 生成测验 M2-02 答题页面 (QuizTakerView): - 选择题 ABCD 选项 / 判断题 ✓✗ / 填空题输入 - 进度条 + 上一题/下一题/提交按钮 - POST /quizzes/:id/start + POST /quizzes/:id/submit M2-03 结果页 (QuizResultView): - 得分展示 + 每题对错详情 + 解释 - 重新测验按钮 路由: quizList/quizTake/quizResult QuizService: generate/list/detail/start/submit/results/history Quiz 模型: Quiz/QuizQuestion/QuizAttempt/QuizAnswer/QuizSubmitRequest 等 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c7b8360ff6
commit
bac51224e2
@ -397,6 +397,72 @@ struct BatchDeleteResponse: Codable {
|
|||||||
let count: Int?
|
let count: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Quiz
|
||||||
|
|
||||||
|
struct Quiz: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let knowledgeBaseId: String?
|
||||||
|
let title: String?
|
||||||
|
let description: String?
|
||||||
|
let questionCount: Int?
|
||||||
|
let sourceType: String?
|
||||||
|
let status: String?
|
||||||
|
let createdAt: String?
|
||||||
|
let questions: [QuizQuestion]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QuizQuestion: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let quizId: String?
|
||||||
|
let type: String?
|
||||||
|
let stem: String?
|
||||||
|
let options: [String]?
|
||||||
|
let answer: String?
|
||||||
|
let explanation: String?
|
||||||
|
let orderIndex: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QuizAttempt: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let quizId: String?
|
||||||
|
let totalQuestions: Int?
|
||||||
|
let correctCount: Int?
|
||||||
|
let score: Int?
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
let answers: [QuizAnswer]?
|
||||||
|
let quiz: QuizInfo?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QuizInfo: Codable { let title: String? }
|
||||||
|
|
||||||
|
struct QuizAnswer: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let attemptId: String?
|
||||||
|
let questionId: String?
|
||||||
|
let userAnswer: String?
|
||||||
|
let isCorrect: Bool?
|
||||||
|
let answeredAt: String?
|
||||||
|
let question: QuizQuestion?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QuizSubmitRequest: Codable {
|
||||||
|
let attemptId: String
|
||||||
|
let answers: [QuizAnswerItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QuizAnswerItem: Codable {
|
||||||
|
let questionId: String
|
||||||
|
let answer: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QuizSubmitResponse: Codable {
|
||||||
|
let score: Int?
|
||||||
|
let correctCount: Int?
|
||||||
|
let totalQuestions: Int?
|
||||||
|
let finishedAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - RAG Chat
|
// MARK: - RAG Chat
|
||||||
|
|
||||||
struct ChatSession: Codable, Identifiable {
|
struct ChatSession: Codable, Identifiable {
|
||||||
|
|||||||
@ -25,6 +25,11 @@ enum Route: Hashable {
|
|||||||
// Import
|
// Import
|
||||||
case importReview(sourceId: String)
|
case importReview(sourceId: String)
|
||||||
|
|
||||||
|
// Quiz
|
||||||
|
case quizList(knowledgeBaseId: String)
|
||||||
|
case quizTake(quizId: String)
|
||||||
|
case quizResult(quizId: String, attemptId: String)
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
case notificationList
|
case notificationList
|
||||||
case settings
|
case settings
|
||||||
@ -64,6 +69,9 @@ extension Route {
|
|||||||
case .feedbackForm: FeedbackFormView()
|
case .feedbackForm: FeedbackFormView()
|
||||||
case .editProfile: EditProfilePage()
|
case .editProfile: EditProfilePage()
|
||||||
case .importReview(let sourceId): ImportReviewPage(sourceId: sourceId)
|
case .importReview(let sourceId): ImportReviewPage(sourceId: sourceId)
|
||||||
|
case .quizList(let kbId): QuizListView(knowledgeBaseId: kbId)
|
||||||
|
case .quizTake(let id): QuizTakerView(quizId: id)
|
||||||
|
case .quizResult(let qid, let aid): QuizResultView(quizId: qid, attemptId: aid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -445,6 +445,33 @@ class KnowledgeSourceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Quiz
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class QuizService {
|
||||||
|
static let shared = QuizService()
|
||||||
|
private let client = APIClient.shared
|
||||||
|
|
||||||
|
func generate(knowledgeBaseId: String, questionCount: Int = 5) async throws -> Quiz {
|
||||||
|
return try await client.request("/quizzes", method: "POST", body: ["knowledgeBaseId": knowledgeBaseId, "questionCount": questionCount] as [String: Any])
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(knowledgeBaseId: String? = nil) async throws -> [Quiz] {
|
||||||
|
var items: [URLQueryItem]? = nil
|
||||||
|
if let kb = knowledgeBaseId { items = [URLQueryItem(name: "knowledgeBaseId", value: kb)] }
|
||||||
|
return try await client.request("/quizzes", queryItems: items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detail(id: String) async throws -> Quiz { return try await client.request("/quizzes/\(id)") }
|
||||||
|
func start(quizId: String) async throws -> QuizAttempt { return try await client.request("/quizzes/\(quizId)/start", method: "POST") }
|
||||||
|
func submit(quizId: String, attemptId: String, answers: [QuizAnswerItem]) async throws -> QuizSubmitResponse {
|
||||||
|
let body = QuizSubmitRequest(attemptId: attemptId, answers: answers)
|
||||||
|
return try await client.request("/quizzes/\(quizId)/submit", method: "POST", body: body)
|
||||||
|
}
|
||||||
|
func results(quizId: String, attemptId: String) async throws -> QuizAttempt { return try await client.request("/quizzes/\(quizId)/results?attemptId=\(attemptId)") }
|
||||||
|
func history() async throws -> [QuizAttempt] { return try await client.request("/quizzes/history/list") }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Notifications
|
// MARK: - Notifications
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
242
AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift
Normal file
242
AIStudyApp/AIStudyApp/Features/Quiz/QuizViews.swift
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Quiz List
|
||||||
|
|
||||||
|
struct QuizListView: View {
|
||||||
|
let knowledgeBaseId: String
|
||||||
|
@State private var quizzes: [Quiz] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var isGenerating = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.zxBg0.ignoresSafeArea()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if isLoading {
|
||||||
|
VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if quizzes.isEmpty {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "questionmark.circle").font(.system(size: 40)).foregroundColor(Color.zxF03)
|
||||||
|
Text("暂无测验").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF04)
|
||||||
|
Text("基于知识库内容自动生成测验题目").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
||||||
|
Button {
|
||||||
|
Task { await generateQuiz() }
|
||||||
|
} label: {
|
||||||
|
HStack { if isGenerating { ProgressView().tint(.white) }; Text("生成测验").font(.system(size: 14, weight: .bold)) }
|
||||||
|
.foregroundColor(.white).frame(height: 48).padding(.horizontal, 32)
|
||||||
|
.background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}.disabled(isGenerating)
|
||||||
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
Task { await generateQuiz() }
|
||||||
|
} label: {
|
||||||
|
HStack { Image(systemName: "plus"); Text("生成新测验") }.font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPrimary).frame(maxWidth: .infinity).frame(height: 44).background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}.disabled(isGenerating)
|
||||||
|
ForEach(quizzes) { q in
|
||||||
|
NavigationLink(value: Route.quizTake(quizId: q.id)) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack { Text(q.title ?? "测验").font(.system(size: 15, weight: .semibold)).foregroundColor(Color.zxF0); Spacer(); Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03) }
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Label("\(q.questionCount ?? 0) 题", systemImage: "list.bullet").font(.system(size: 11)).foregroundColor(Color.zxF04)
|
||||||
|
Label("选择题/判断/填空", systemImage: "square.grid.3x3").font(.system(size: 11)).foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}.padding(14).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
|
||||||
|
}.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("测验").navigationBarTitleDisplayMode(.inline).animatedTabBarHide().toolbarBackground(.hidden, for: .navigationBar)
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
do { quizzes = try await QuizService.shared.list(knowledgeBaseId: knowledgeBaseId) } catch {}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateQuiz() async {
|
||||||
|
isGenerating = true
|
||||||
|
do {
|
||||||
|
let _ = try await QuizService.shared.generate(knowledgeBaseId: knowledgeBaseId, questionCount: 5)
|
||||||
|
await load()
|
||||||
|
} catch { ZXToastManager.shared.error("生成失败") }
|
||||||
|
isGenerating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quiz Taker
|
||||||
|
|
||||||
|
struct QuizTakerView: View {
|
||||||
|
let quizId: String
|
||||||
|
@State private var quiz: Quiz?
|
||||||
|
@State private var attempt: QuizAttempt?
|
||||||
|
@State private var currentIndex = 0
|
||||||
|
@State private var answers: [String: String] = [:]
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var isSubmitting = false
|
||||||
|
@State private var showResult = false
|
||||||
|
@State private var resultAttemptId = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.zxBg0.ignoresSafeArea()
|
||||||
|
if isLoading {
|
||||||
|
VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载测验…").font(.system(size: 13)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if let q = quiz, let questions = q.questions, !questions.isEmpty {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Progress
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("第 \(currentIndex + 1) / \(questions.count) 题").font(.system(size: 13, weight: .medium)).foregroundColor(Color.zxF04)
|
||||||
|
Spacer()
|
||||||
|
Text("已答 \(answers.count) 题").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
||||||
|
}
|
||||||
|
GeometryReader { g in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 2).fill(Color.zxFill008).frame(height: 4)
|
||||||
|
RoundedRectangle(cornerRadius: 2).fill(ZXGradient.brandHorizontal).frame(width: g.size.width * CGFloat(currentIndex + 1) / CGFloat(questions.count), height: 4)
|
||||||
|
}
|
||||||
|
}.frame(height: 4)
|
||||||
|
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 16)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
let question = questions[currentIndex]
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text(question.stem ?? "").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0).lineSpacing(4)
|
||||||
|
|
||||||
|
if question.type == "choice", let options = question.options {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(Array(options.enumerated()), id: \.offset) { i, opt in
|
||||||
|
Button {
|
||||||
|
answers[question.id] = String(i)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(["A","B","C","D"][i]).font(.system(size: 13, weight: .bold)).foregroundColor(answers[question.id] == String(i) ? .white : Color.zxPurple).frame(width: 30, height: 30).background(answers[question.id] == String(i) ? Color.zxPurple : Color.zxPurpleBG(0.12)).clipShape(Circle())
|
||||||
|
Text(opt).font(.system(size: 14)).foregroundColor(Color.zxF0)
|
||||||
|
Spacer()
|
||||||
|
}.padding(12).background(answers[question.id] == String(i) ? Color.zxPurpleBG(0.08) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(answers[question.id] == String(i) ? Color.zxPurple.opacity(0.2) : Color.clear, lineWidth: 1))
|
||||||
|
}.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if question.type == "judge" {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(["true", "false"], id: \.self) { v in
|
||||||
|
Button {
|
||||||
|
answers[question.id] = v
|
||||||
|
} label: {
|
||||||
|
Text(v == "true" ? "✓ 正确" : "✗ 错误").font(.system(size: 14, weight: .medium)).foregroundColor(answers[question.id] == v ? .white : Color.zxF0).frame(maxWidth: .infinity).frame(height: 48).background(answers[question.id] == v ? (v == "true" ? Color.zxGreen : Color.zxCoral) : Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TextField("输入你的答案", text: Binding(get: { answers[question.id] ?? "" }, set: { answers[question.id] = $0 })).font(.system(size: 14)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 48).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
|
}
|
||||||
|
}.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation buttons
|
||||||
|
HStack {
|
||||||
|
Button { if currentIndex > 0 { currentIndex -= 1 } } label: {
|
||||||
|
HStack { Image(systemName: "chevron.left"); Text("上一题") }.font(.system(size: 14)).foregroundColor(Color.zxF05).padding(.horizontal, 20).padding(.vertical, 12).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}.disabled(currentIndex == 0)
|
||||||
|
Spacer()
|
||||||
|
if currentIndex < questions.count - 1 {
|
||||||
|
Button { currentIndex += 1 } label: {
|
||||||
|
HStack { Text("下一题"); Image(systemName: "chevron.right") }.font(.system(size: 14, weight: .medium)).foregroundColor(.white).padding(.horizontal, 20).padding(.vertical, 12).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
Task { await submit() }
|
||||||
|
} label: {
|
||||||
|
HStack { if isSubmitting { ProgressView().tint(.white) }; Text("提交").font(.system(size: 14, weight: .bold)) }.foregroundColor(.white).padding(.horizontal, 32).padding(.vertical, 12).background(Color.zxGreen).clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}.disabled(isSubmitting)
|
||||||
|
}
|
||||||
|
}.padding(.horizontal, 20).padding(.vertical, 16).background(.ultraThinMaterial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline).animatedTabBarHide().toolbarBackground(.hidden, for: .navigationBar)
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
do {
|
||||||
|
quiz = try await QuizService.shared.detail(id: quizId)
|
||||||
|
attempt = try await QuizService.shared.start(quizId: quizId)
|
||||||
|
} catch {}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() async {
|
||||||
|
isSubmitting = true
|
||||||
|
guard let att = attempt else { return }
|
||||||
|
let items = answers.map { QuizAnswerItem(questionId: $0.key, answer: $0.value) }
|
||||||
|
do {
|
||||||
|
let _ = try await QuizService.shared.submit(quizId: quizId, attemptId: att.id, answers: items)
|
||||||
|
resultAttemptId = att.id
|
||||||
|
showResult = true
|
||||||
|
} catch { ZXToastManager.shared.error("提交失败") }
|
||||||
|
isSubmitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quiz Result
|
||||||
|
|
||||||
|
struct QuizResultView: View {
|
||||||
|
let quizId: String; let attemptId: String
|
||||||
|
@State private var result: QuizAttempt?
|
||||||
|
@State private var isLoading = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.zxBg0.ignoresSafeArea()
|
||||||
|
if isLoading {
|
||||||
|
VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载结果…").font(.system(size: 13)).foregroundColor(Color.zxF04) }.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if let r = result {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("\(r.score ?? 0)分").font(.system(size: 48, weight: .heavy)).foregroundColor(r.score ?? 0 >= 60 ? Color.zxGreen : Color.zxCoral)
|
||||||
|
Text("答对 \(r.correctCount ?? 0)/\(r.totalQuestions ?? 0) 题").font(.system(size: 15)).foregroundColor(Color.zxF05)
|
||||||
|
NavigationLink(value: Route.quizTake(quizId: quizId)) {
|
||||||
|
Text("重新测验").font(.system(size: 14, weight: .bold)).foregroundColor(.white).frame(maxWidth: .infinity).frame(height: 48).background(ZXGradient.brand).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}.padding(.horizontal, 20)
|
||||||
|
}.padding(24).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20)).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("答题详情").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
|
||||||
|
ForEach(r.answers ?? []) { a in
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: a.isCorrect == true ? "checkmark.circle.fill" : "xmark.circle.fill").font(.system(size: 18)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(a.question?.stem ?? "").font(.system(size: 13)).foregroundColor(Color.zxF0).lineLimit(2)
|
||||||
|
if let exp = a.question?.explanation { Text(exp).font(.system(size: 11)).foregroundColor(Color.zxF04).lineLimit(2) }
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}.padding(12).background(a.isCorrect == true ? Color.zxGreen.opacity(0.05) : Color.zxCoral.opacity(0.05)).clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
|
||||||
|
}.scrollIndicators(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline).animatedTabBarHide().toolbarBackground(.hidden, for: .navigationBar)
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
do { result = try await QuizService.shared.results(quizId: quizId, attemptId: attemptId) } catch {}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user