diff --git a/CloudMaster.xcodeproj/project.pbxproj b/CloudMaster.xcodeproj/project.pbxproj index 5009492..56bdf6b 100644 --- a/CloudMaster.xcodeproj/project.pbxproj +++ b/CloudMaster.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 8D26A31C2C0EA9C100E9B015 /* QuestionNavbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D26A31B2C0EA9C100E9B015 /* QuestionNavbar.swift */; }; + 8D26A31E2C0EE3F400E9B015 /* QuestionImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D26A31D2C0EE3F400E9B015 /* QuestionImages.swift */; }; + 8D26A3202C0EE4A000E9B015 /* QuestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D26A31F2C0EE4A000E9B015 /* QuestionView.swift */; }; 8D8D8A862C05A23600ACC61C /* CloudMasterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8A852C05A23600ACC61C /* CloudMasterTests.swift */; }; 8D8D8A902C05A23600ACC61C /* CloudMasterUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8A8F2C05A23600ACC61C /* CloudMasterUITests.swift */; }; 8D8D8A922C05A23600ACC61C /* CloudMasterUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8A912C05A23600ACC61C /* CloudMasterUITestsLaunchTests.swift */; }; @@ -63,6 +65,8 @@ /* Begin PBXFileReference section */ 8D26A31B2C0EA9C100E9B015 /* QuestionNavbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionNavbar.swift; sourceTree = ""; }; + 8D26A31D2C0EE3F400E9B015 /* QuestionImages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionImages.swift; sourceTree = ""; }; + 8D26A31F2C0EE4A000E9B015 /* QuestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionView.swift; sourceTree = ""; }; 8D8D8A712C05A23400ACC61C /* CloudMaster Swift.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "CloudMaster Swift.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 8D8D8A812C05A23600ACC61C /* CloudMasterTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CloudMasterTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8D8D8A852C05A23600ACC61C /* CloudMasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMasterTests.swift; sourceTree = ""; }; @@ -132,6 +136,8 @@ children = ( 8D8D8A9F2C05A27800ACC61C /* ConfirmPopup.swift */, 8D26A31B2C0EA9C100E9B015 /* QuestionNavbar.swift */, + 8D26A31D2C0EE3F400E9B015 /* QuestionImages.swift */, + 8D26A31F2C0EE4A000E9B015 /* QuestionView.swift */, ); path = Components; sourceTree = ""; @@ -586,9 +592,11 @@ 8D8D8ACF2C05A27800ACC61C /* DownloadOverlayView.swift in Sources */, 8D8D8AD32C05A27800ACC61C /* UserExamData.swift in Sources */, 8D8D8AD82C05A27800ACC61C /* PreviousExamsView.swift in Sources */, + 8D26A31E2C0EE3F400E9B015 /* QuestionImages.swift in Sources */, 8D8D8AE42C05A27800ACC61C /* QuestionLoader.swift in Sources */, 8D8D8ACE2C05A27800ACC61C /* ConfirmPopup.swift in Sources */, 8D8D8AE32C05A27800ACC61C /* FavoriteStorage.swift in Sources */, + 8D26A3202C0EE4A000E9B015 /* QuestionView.swift in Sources */, 8D8D8AD62C05A27800ACC61C /* ExamSummaryView.swift in Sources */, 8D8D8AD92C05A27800ACC61C /* HomeView.swift in Sources */, 8D8D8AD42C05A27800ACC61C /* ExamModesView.swift in Sources */, @@ -774,7 +782,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.ditectrev.cloudmasterswift; PRODUCT_NAME = "CloudMaster Swift"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -809,7 +817,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = com.ditectrev.cloudmasterswift; PRODUCT_NAME = "CloudMaster Swift"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/CloudMaster/Features/Exam/Views/ExamView.swift b/CloudMaster/Features/Exam/Views/ExamView.swift index 65b0541..4e3830d 100644 --- a/CloudMaster/Features/Exam/Views/ExamView.swift +++ b/CloudMaster/Features/Exam/Views/ExamView.swift @@ -44,10 +44,12 @@ struct ExamView: View { } .padding(.horizontal) - ExamQuestion( + QuestionView( + mode: .exam, question: questions[currentQuestionIndex], selectedChoices: selectedChoices[questions[currentQuestionIndex].id] ?? [], isMultipleResponse: questions[currentQuestionIndex].multipleResponse, + isResultShown: false, // Exam mode does not show result immediately onChoiceSelected: { choiceId in if questions[currentQuestionIndex].multipleResponse { if selectedChoices[questions[currentQuestionIndex].id]?.contains(choiceId) == true { @@ -60,6 +62,7 @@ struct ExamView: View { } } ) + Button(action: { if currentQuestionIndex < questions.count - 1 { currentQuestionIndex += 1 @@ -86,7 +89,7 @@ struct ExamView: View { } .padding() } else { - Text("No que") + Text("No Questions available! Please download course") } } .navigationDestination(isPresented: $navigateToSummary) { @@ -164,96 +167,3 @@ struct ExamView: View { return String(format: "%02d:%02d", minutes, seconds) } } - -struct ExamQuestion: View { - let question: Question - let selectedChoices: Set? - let isMultipleResponse: Bool - let onChoiceSelected: (UUID) -> Void - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text(question.question) - .font(.system(size: adjustedFontSize(for: question.question), weight: .bold)) - .minimumScaleFactor(0.5) - .lineLimit(nil) // Allow text to wrap as needed - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal) - .multilineTextAlignment(.leading) // Justify the text - .lineSpacing(2) - - if let imagePath = question.imagePath, - let image = loadImage(from: imagePath) { - Image(uiImage: image) - .resizable() - .cornerRadius(2) - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) - .padding(.horizontal) - } - - if isMultipleResponse { - VStack { - Text("Multiple response - Pick \(question.responseCount)") - .font(.subheadline) - .multilineTextAlignment(.center) - .opacity(0.7) - .padding(.vertical, 5) - .frame(minWidth: 0, maxWidth: .infinity) - } - .background(Color.gray.opacity(0.2)) - .cornerRadius(10) - .padding(.horizontal) - } - - ForEach(question.choices) { choice in - ExamChoice(choice: choice, isSelected: selectedChoices?.contains(choice.id) == true, onChoiceSelected: onChoiceSelected) - } - } - .padding() - } - } - - private func loadImage(from imagePath: String) -> UIImage? { - let fileManager = FileManager.default - let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! - let imageURL = documentsURL.appendingPathComponent(imagePath) - return UIImage(contentsOfFile: imageURL.path) - } - - private func adjustedFontSize(for text: String) -> CGFloat { - let maxWidth = UIScreen.main.bounds.width - 32 - let baseFontSize: CGFloat = 24 - let minFontSize: CGFloat = 14 - - // Scale the font size based on the text length - let lengthFactor = CGFloat(text.count) / 100.0 - let scaledFontSize = max(baseFontSize - lengthFactor, minFontSize) - - return scaledFontSize - } -} - -struct ExamChoice: View { - let choice: Choice - let isSelected: Bool - let onChoiceSelected: (UUID) -> Void - - var body: some View { - Button(action: { - onChoiceSelected(choice.id) - }) { - Text(choice.text) - .padding() - .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) - .multilineTextAlignment(.center) - } - .background(isSelected ? Color.gray.opacity(0.3) : Color.clear) - .cornerRadius(10) - .padding(.horizontal) - .foregroundColor(.white) - - Divider() - } -} diff --git a/CloudMaster/Features/Shared/Components/QuestionImages.swift b/CloudMaster/Features/Shared/Components/QuestionImages.swift new file mode 100644 index 0000000..4f0d8e8 --- /dev/null +++ b/CloudMaster/Features/Shared/Components/QuestionImages.swift @@ -0,0 +1,104 @@ +import SwiftUI + +struct QuestionImages: View { + let images: [Question.ImageInfo] + @Binding var currentImageIndex: Int + @Binding var isFullscreenImageShown: Bool + @Binding var selectedImageIndex: Int + + var body: some View { + if !images.isEmpty { + TabView(selection: $currentImageIndex) { + ForEach(images.indices, id: \.self) { index in + let imageInfo = images[index] + if let image = loadImage(from: imageInfo.path) { + Image(uiImage: image) + .resizable() + .cornerRadius(2) + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity) + .padding(.horizontal) + .tag(index) + .onTapGesture { + selectedImageIndex = index + isFullscreenImageShown = true + } + } else if let url = imageInfo.url, let urlImage = loadImage(from: url) { + Image(uiImage: urlImage) + .resizable() + .cornerRadius(2) + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity) + .padding(.horizontal) + .tag(index) + .onTapGesture { + selectedImageIndex = index + isFullscreenImageShown = true + } + } + } + } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .always)) + .frame(height: 300) + } + } + + private func loadImage(from imagePath: String) -> UIImage? { + let fileManager = FileManager.default + let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let imageURL = documentsURL.appendingPathComponent(imagePath) + return UIImage(contentsOfFile: imageURL.path) + } +} + + +struct FullscreenImageView: View { + let images: [Question.ImageInfo] + @Binding var selectedImageIndex: Int + @Binding var isShown: Bool + + @State private var scale: CGFloat = 1.0 + @State private var lastScale: CGFloat = 1.0 + + var body: some View { + ZStack { + Color.black.opacity(0.8) + .edgesIgnoringSafeArea(.all) + + if let imageInfo = images[safe: selectedImageIndex], + let uiImage = loadImage(from: imageInfo.path) ?? loadImage(from: imageInfo.url) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .scaleEffect(scale) + .gesture( + MagnificationGesture() + .onChanged { value in + self.scale = self.lastScale * value + } + .onEnded { _ in + self.lastScale = self.scale + } + ) + .onTapGesture { + isShown = false + } + } + } + } + + private func loadImage(from imagePath: String?) -> UIImage? { + guard let imagePath = imagePath else { return nil } + let fileManager = FileManager.default + let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let imageURL = documentsURL.appendingPathComponent(imagePath) + return UIImage(contentsOfFile: imageURL.path) + } +} + +// extension to safely access array elements to avoid out-of-bounds +extension Array { + subscript(safe index: Int) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/CloudMaster/Features/Shared/Components/QuestionView.swift b/CloudMaster/Features/Shared/Components/QuestionView.swift new file mode 100644 index 0000000..29e2a12 --- /dev/null +++ b/CloudMaster/Features/Shared/Components/QuestionView.swift @@ -0,0 +1,170 @@ +// +// QuestionView.swift +// CloudMaster +// +// Created by Benedikt Wagner on 04.06.24. +// + +import Foundation +import SwiftUI + +struct QuestionView: View { + enum Mode { + case training + case exam + } + + let mode: Mode + let question: Question + let selectedChoices: Set? + let isMultipleResponse: Bool + let isResultShown: Bool? + let onChoiceSelected: (UUID) -> Void + + @State private var currentImageIndex = 0 + @State private var isFullscreenImageShown = false + @State private var selectedImageIndex = 0 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(question.question) + .font(.system(size: adjustedFontSize(for: question.question), weight: .bold)) + .minimumScaleFactor(0.5) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal) + .multilineTextAlignment(.leading) + .lineSpacing(2) + + QuestionImages(images: question.images, + currentImageIndex: $currentImageIndex, + isFullscreenImageShown: $isFullscreenImageShown, + selectedImageIndex: $selectedImageIndex) + .onAppear { + currentImageIndex = 0 // Reset image index when question changes + } + + if isMultipleResponse { + VStack { + Text("Multiple response - Pick \(question.responseCount)") + .font(.subheadline) + .multilineTextAlignment(.center) + .opacity(0.7) + .padding(.vertical, 5) + .frame(minWidth: 0, maxWidth: .infinity) + } + .background(Color.gray.opacity(0.2)) + .cornerRadius(10) + .padding(.horizontal) + } + + ForEach(question.choices) { choice in + if mode == .training { + TrainingChoice( + choice: choice, + isSelected: selectedChoices?.contains(choice.id) == true, + isResultShown: isResultShown ?? false, + onChoiceSelected: onChoiceSelected + ) + } else { + ExamChoice( + choice: choice, + isSelected: selectedChoices?.contains(choice.id) == true, + onChoiceSelected: onChoiceSelected + ) + } + } + } + .padding() + } + .overlay( + Group { + if isFullscreenImageShown { + FullscreenImageView(images: question.images, selectedImageIndex: $selectedImageIndex, isShown: $isFullscreenImageShown) + } + } + ) + } + + private func adjustedFontSize(for text: String) -> CGFloat { + let maxWidth = UIScreen.main.bounds.width - 32 + let baseFontSize: CGFloat = 20 + let minFontSize: CGFloat = 14 + + let lengthFactor = CGFloat(text.count) / 100.0 + let scaledFontSize = max(baseFontSize - lengthFactor, minFontSize) + + return scaledFontSize + } +} + + +struct TrainingChoice: View { + let choice: Choice + let isSelected: Bool + let isResultShown: Bool + let onChoiceSelected: (UUID) -> Void + + var body: some View { + Button(action: { + onChoiceSelected(choice.id) + }) { + Text(choice.text) + .padding() + .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + } + .background(getChoiceBackgroundColor()) + .foregroundColor(getChoiceTextColor()) + .cornerRadius(10) + .padding(.horizontal) + .disabled(isResultShown) + + Divider() + } + + private func getChoiceBackgroundColor() -> Color { + if isResultShown { + if choice.correct { + return Color.correct + } else if isSelected { + return Color.wrong + } + } else if isSelected { + return Color.gray.opacity(0.3) + } + return Color.clear + } + + private func getChoiceTextColor() -> Color { + if isResultShown && choice.correct { + return .white + } else { + return .primary + } + } +} + +struct ExamChoice: View { + let choice: Choice + let isSelected: Bool + let onChoiceSelected: (UUID) -> Void + + var body: some View { + Button(action: { + onChoiceSelected(choice.id) + }) { + Text(choice.text) + .padding() + .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + } + .background(isSelected ? Color.gray.opacity(0.3) : Color.clear) + .cornerRadius(10) + .padding(.horizontal) + .foregroundColor(.white) + + Divider() + } +} diff --git a/CloudMaster/Features/Training/Views/TrainingView.swift b/CloudMaster/Features/Training/Views/TrainingView.swift index fa120aa..3113821 100644 --- a/CloudMaster/Features/Training/Views/TrainingView.swift +++ b/CloudMaster/Features/Training/Views/TrainingView.swift @@ -28,7 +28,8 @@ struct TrainingView: View { let question = questions[currentQuestionIndex] - TrainingQuestion( + QuestionView( + mode: .training, question: question, selectedChoices: selectedChoices, isMultipleResponse: question.multipleResponse, @@ -113,24 +114,20 @@ struct TrainingView: View { } func updateUserTrainingData(for question: Question) { - // Update the time spent if let startTime = startTime { userTrainingData.timeSpent += Date().timeIntervalSince(startTime) } - // Update correct and wrong answers let correctChoices = Set(question.choices.filter { $0.correct }.map { $0.id }) let userCorrectChoices = selectedChoices.intersection(correctChoices) userTrainingData.correctAnswers += userCorrectChoices.count userTrainingData.wrongAnswers += selectedChoices.subtracting(correctChoices).count - // Update question stats userTrainingData.updateStats(for: question.id, correctChoices: correctChoices, selectedChoices: selectedChoices) } func loadUserTrainingData(for course: Course) { - // Load the user training data for the specific course if let data = UserDefaults.standard.data(forKey: course.shortName) { if let decodedData = try? JSONDecoder().decode(UserTrainingData.self, from: data) { userTrainingData = decodedData @@ -139,131 +136,8 @@ struct TrainingView: View { } func saveUserTrainingData() { - // Save the user training data for the specific course if let data = try? JSONEncoder().encode(userTrainingData) { UserDefaults.standard.set(data, forKey: course.shortName) } } } - -struct TrainingQuestion: View { - let question: Question - let selectedChoices: Set - let isMultipleResponse: Bool - let isResultShown: Bool - let onChoiceSelected: (UUID) -> Void - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text(question.question) - .font(.system(size: adjustedFontSize(for: question.question), weight: .bold)) - .minimumScaleFactor(0.5) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal) - .multilineTextAlignment(.leading) // Justify the text - .lineSpacing(2) - - if let imagePath = question.imagePath, - let image = loadImage(from: imagePath) { - Image(uiImage: image) - .resizable() - .cornerRadius(2) - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) - .padding(.horizontal) - } - if isMultipleResponse { - VStack { - Text("Multiple response - Pick \(question.responseCount)") - .font(.subheadline) - .multilineTextAlignment(.center) - .opacity(0.7) - .padding(.vertical, 5) - .frame(minWidth: 0, maxWidth: .infinity) - } - .background(Color.gray.opacity(0.2)) - .cornerRadius(10) - .padding(.horizontal) - } - - ForEach(question.choices) { choice in - TrainingChoice( - choice: choice, - isSelected: selectedChoices.contains(choice.id), - isResultShown: isResultShown, - onChoiceSelected: onChoiceSelected - ) - } - } - .padding() - } - } - - private func loadImage(from imagePath: String) -> UIImage? { - let fileManager = FileManager.default - let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! - let imageURL = documentsURL.appendingPathComponent(imagePath) - return UIImage(contentsOfFile: imageURL.path) - } - - - private func adjustedFontSize(for text: String) -> CGFloat { - let maxWidth = UIScreen.main.bounds.width - 32 - let baseFontSize: CGFloat = 24 - let minFontSize: CGFloat = 14 - - // Scale the font size based on the text length - let lengthFactor = CGFloat(text.count) / 100.0 - let scaledFontSize = max(baseFontSize - lengthFactor, minFontSize) - - return scaledFontSize - } -} - -struct TrainingChoice: View { - let choice: Choice - let isSelected: Bool - let isResultShown: Bool - let onChoiceSelected: (UUID) -> Void - - var body: some View { - Button(action: { - onChoiceSelected(choice.id) - }) { - Text(choice.text) - .padding() - .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) - .multilineTextAlignment(.center) - } - .background(getChoiceBackgroundColor()) - .foregroundColor(getChoiceTextColor()) - .cornerRadius(10) - .padding(.horizontal) - .disabled(isResultShown) - - Divider() - } - - private func getChoiceBackgroundColor() -> Color { - if isResultShown { - if choice.correct { - return Color.correct - } else if isSelected { - return Color.wrong - } - } else if isSelected { - return Color.gray.opacity(0.3) - } - return Color.clear - } - - private func getChoiceTextColor() -> Color { - if isResultShown && choice.correct { - return .white - } else { - return .primary - } - } -} diff --git a/CloudMaster/Utilities/DownloadUtility.swift b/CloudMaster/Utilities/DownloadUtility.swift index 73fad1d..1f50d79 100644 --- a/CloudMaster/Utilities/DownloadUtility.swift +++ b/CloudMaster/Utilities/DownloadUtility.swift @@ -9,20 +9,22 @@ class DownloadUtility { completion(.failure(NSError(domain: "Invalid URL", code: 1, userInfo: nil))) return } - + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let destinationURL = documentsURL.appendingPathComponent("\(course.shortName).md") - + let task = URLSession.shared.downloadTask(with: url) { (tempURL, response, error) in downloadQueue.async { downloadTasks[course.shortName] = nil } + if let error = error { DispatchQueue.main.async { completion(.failure(error)) } return } + guard let tempURL = tempURL else { let error = NSError(domain: "com.example.error", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to download file"]) DispatchQueue.main.async { @@ -30,31 +32,32 @@ class DownloadUtility { } return } + do { try FileManager.default.removeItemIfExists(at: destinationURL) try FileManager.default.moveItem(at: tempURL, to: destinationURL) - + let markdown = try String(contentsOf: destinationURL, encoding: .utf8) var questions = try parseMarkdown(markdown: markdown, course: course) - - let totalTasks = questions.count + 1 // +1 for downloading questions + + let totalTasks = questions.count + 1 var progress = Progress(totalUnitCount: Int64(totalTasks)) progress.completedUnitCount = 1 progressHandler(progress, "Questions for \(course.shortName)") - - questions = try downloadImages(for: questions, course: course) { completedImages in + + questions = try downloadImages(for: questions, course: course) { completedImages, totalImages in progress.completedUnitCount = Int64(completedImages + 1) - progressHandler(progress, "Assets for \(course.shortName)") + progressHandler(progress, "\(completedImages)/\(totalImages) assets for \(course.shortName)") } - + progress.completedUnitCount = Int64(totalTasks) progressHandler(progress, "Completed downloading \(course.shortName)") - + let jsonData = try JSONSerialization.data(withJSONObject: questions, options: .prettyPrinted) let jsonFileURL = documentsURL.appendingPathComponent("\(course.shortName).json") try FileManager.default.removeItemIfExists(at: jsonFileURL) try jsonData.write(to: jsonFileURL) - + DispatchQueue.main.async { print("Downloaded course: \(course.shortName)") completion(.success(())) @@ -69,10 +72,10 @@ class DownloadUtility { downloadQueue.async { downloadTasks[course.shortName] = task } - + task.resume() } - + static func cancelDownload(for course: Course) { downloadQueue.async { if let task = downloadTasks[course.shortName] { @@ -82,18 +85,18 @@ class DownloadUtility { } } - static func parseMarkdown(markdown: String, course: Course) throws -> [[String: Any]] { + private static func parseMarkdown(markdown: String, course: Course) throws -> [[String: Any]] { let lines = markdown.components(separatedBy: .newlines) var questions: [[String: Any]] = [] var currentQuestion: [String: Any] = [:] var choices: [[String: Any]] = [] var correctCount = 0 - var currentImagePath: String? - + var currentImagePaths: [String] = [] + let questionPattern = try NSRegularExpression(pattern: "### (.+)") let choicePattern = try NSRegularExpression(pattern: "- \\[([ x])\\] (.+)") let imagePattern = try NSRegularExpression(pattern: "!\\[.*\\]\\((images/.+?)\\)") - + for line in lines { if let questionMatch = questionPattern.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { if !currentQuestion.isEmpty { @@ -102,12 +105,12 @@ class DownloadUtility { currentQuestion["multiple_response"] = true currentQuestion["response_count"] = correctCount } - currentQuestion["imagePath"] = currentImagePath + currentQuestion["images"] = currentImagePaths.map { ["path": $0, "url": nil, "downloaded": false] } questions.append(currentQuestion) currentQuestion = [:] choices = [] correctCount = 0 - currentImagePath = nil + currentImagePaths = [] } let question = String(line[Range(questionMatch.range(at: 1), in: line)!]) currentQuestion["question"] = question @@ -120,59 +123,70 @@ class DownloadUtility { choices.append(["id": UUID().uuidString, "text": choice, "correct": isCorrect]) } else if let imageMatch = imagePattern.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { let imagePath = String(line[Range(imageMatch.range(at: 1), in: line)!]) - currentImagePath = "images/\(course.shortName)/\(imagePath)" + if !imagePath.contains("discord") && !imagePath.contains("promotional") { + currentImagePaths.append("images/\(course.shortName)/\(imagePath)") + } } } - + if !currentQuestion.isEmpty { currentQuestion["choices"] = choices if correctCount > 1 { currentQuestion["multiple_response"] = true currentQuestion["response_count"] = correctCount } - currentQuestion["imagePath"] = currentImagePath + currentQuestion["images"] = currentImagePaths.map { ["path": $0, "url": nil, "downloaded": false] } questions.append(currentQuestion) } - + if questions.isEmpty { throw NSError(domain: "com.example.error", code: 2, userInfo: [NSLocalizedDescriptionKey: "Course has no questions, please contact developer"]) } - + return questions } - - static func downloadImages(for questions: [[String: Any]], course: Course, progressHandler: @escaping (Int) -> Void) throws -> [[String: Any]] { + + private static func downloadImages(for questions: [[String: Any]], course: Course, progressHandler: @escaping (Int, Int) -> Void) throws -> [[String: Any]] { let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let imagesDirectoryURL = documentsURL.appendingPathComponent("images/\(course.shortName)") - + try FileManager.default.createDirectory(at: imagesDirectoryURL, withIntermediateDirectories: true, attributes: nil) - + var updatedQuestions = questions - + + let totalImages = questions.reduce(0) { count, question in + count + ((question["images"] as? [[String: Any]])?.count ?? 0) + } + + var downloadedImages = 0 + for (index, question) in questions.enumerated() { - if let imagePath = question["imagePath"] as? String { - if imagePath.contains("discord") { - continue - } - let imageUrlString = "\(course.repositoryURL)/blob/main/\(imagePath.replacingOccurrences(of: "images/\(course.shortName)/", with: ""))".replacingOccurrences(of: "github.com", with: "raw.githubusercontent.com").replacingOccurrences(of: "/blob/", with: "/") - if let imageUrl = URL(string: imageUrlString) { - let imageData = try Data(contentsOf: imageUrl) - let imageFileName = imagePath.replacingOccurrences(of: "images/\(course.shortName)/", with: "") - let imageFileURL = imagesDirectoryURL.appendingPathComponent(imageFileName) - - let imageFileDirectory = imageFileURL.deletingLastPathComponent() - try FileManager.default.createDirectory(at: imageFileDirectory, withIntermediateDirectories: true, attributes: nil) - - try FileManager.default.removeItemIfExists(at: imageFileURL) - try imageData.write(to: imageFileURL) - - updatedQuestions[index]["imagePath"] = "images/\(course.shortName)/\(imageFileName)" - - progressHandler(index + 1) + if let imagePaths = question["images"] as? [[String: Any]] { + var updatedImagePaths: [[String: Any]] = [] + for imagePath in imagePaths { + if let path = imagePath["path"] as? String { + let imageUrlString = "\(course.repositoryURL)/blob/main/\(path.replacingOccurrences(of: "images/\(course.shortName)/", with: ""))".replacingOccurrences(of: "github.com", with: "raw.githubusercontent.com").replacingOccurrences(of: "/blob/", with: "/") + if let imageUrl = URL(string: imageUrlString) { + let imageData = try Data(contentsOf: imageUrl) + let imageFileName = path.replacingOccurrences(of: "images/\(course.shortName)/", with: "") + let imageFileURL = imagesDirectoryURL.appendingPathComponent(imageFileName) + + let imageFileDirectory = imageFileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: imageFileDirectory, withIntermediateDirectories: true, attributes: nil) + + try FileManager.default.removeItemIfExists(at: imageFileURL) + try imageData.write(to: imageFileURL) + + updatedImagePaths.append(["path": "images/\(course.shortName)/\(imageFileName)", "url": imageUrl.absoluteString, "downloaded": true]) + downloadedImages += 1 + progressHandler(downloadedImages, totalImages) + } + } } + updatedQuestions[index]["images"] = updatedImagePaths } } - + return updatedQuestions } } diff --git a/CloudMaster/Utilities/QuestionLoader.swift b/CloudMaster/Utilities/QuestionLoader.swift index 27ef325..482921d 100644 --- a/CloudMaster/Utilities/QuestionLoader.swift +++ b/CloudMaster/Utilities/QuestionLoader.swift @@ -6,22 +6,28 @@ struct Question: Identifiable, Codable { let choices: [Choice] var multipleResponse: Bool var responseCount: Int - let imagePath: String? + let images: [ImageInfo] enum CodingKeys: String, CodingKey { - case question, choices, multipleResponse = "multiple_response", responseCount = "response_count", imagePath = "imagePath" + case question, choices, multipleResponse = "multiple_response", responseCount = "response_count", images } - + + struct ImageInfo: Codable { + let path: String + let url: String? + } + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) question = try container.decode(String.self, forKey: .question) choices = try container.decode([Choice].self, forKey: .choices) multipleResponse = try container.decodeIfPresent(Bool.self, forKey: .multipleResponse) ?? false responseCount = try container.decodeIfPresent(Int.self, forKey: .responseCount) ?? 0 - imagePath = try container.decodeIfPresent(String.self, forKey: .imagePath) + images = try container.decodeIfPresent([ImageInfo].self, forKey: .images) ?? [] } } + struct Choice: Identifiable, Codable { var id = UUID() let text: String