From 2ed293a516870e030b445387023ec17b942f7405 Mon Sep 17 00:00:00 2001 From: Benedikt Wagner Date: Mon, 3 Jun 2024 18:31:00 +0700 Subject: [PATCH 01/12] improve exam summary --- .../Features/Course/Views/CourseView.swift | 2 +- .../Features/Exam/Views/ExamSummaryView.swift | 24 ++-- .../Features/Exam/Views/ExamView.swift | 116 +++++++++--------- .../Exam/Views/PreviousExamsView.swift | 2 +- 4 files changed, 78 insertions(+), 66 deletions(-) diff --git a/CloudMaster/Features/Course/Views/CourseView.swift b/CloudMaster/Features/Course/Views/CourseView.swift index 3845cde..5daa10e 100644 --- a/CloudMaster/Features/Course/Views/CourseView.swift +++ b/CloudMaster/Features/Course/Views/CourseView.swift @@ -90,7 +90,7 @@ struct CourseView: View { .padding() .font(.subheadline) - Link("Sources", destination: URL(string: course.url)!) + Link("Sources", destination: URL(string: course.repositoryURL)!) .padding() .font(.subheadline) } diff --git a/CloudMaster/Features/Exam/Views/ExamSummaryView.swift b/CloudMaster/Features/Exam/Views/ExamSummaryView.swift index f3fa114..406f512 100644 --- a/CloudMaster/Features/Exam/Views/ExamSummaryView.swift +++ b/CloudMaster/Features/Exam/Views/ExamSummaryView.swift @@ -1,13 +1,20 @@ import Foundation import SwiftUI +import Foundation +import SwiftUI + struct ExamSummaryView: View { @State private var expandedQuestionIDs: Set = [] @State private var showDeleteConfirmation = false @ObservedObject var examDataStore = UserExamDataStore.shared + let exam: UserExamData + // Helper variable to hide backbutton after Exam + let afterExam: Bool + var body: some View { VStack { Text(exam.isPassed ? "Passed" : "Failed") @@ -79,14 +86,17 @@ struct ExamSummaryView: View { } .padding() .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(afterExam) // Hide back button based on the flag .toolbar { - ToolbarItem(placement: .principal) { - HStack { - Spacer() - Button(action: { - showDeleteConfirmation = true - }) { - Image(systemName: "trash") + if (!afterExam){ + ToolbarItem(placement: .principal) { + HStack { + Spacer() + Button(action: { + showDeleteConfirmation = true + }) { + Image(systemName: "trash") + } } } } diff --git a/CloudMaster/Features/Exam/Views/ExamView.swift b/CloudMaster/Features/Exam/Views/ExamView.swift index b686900..15e2f4b 100644 --- a/CloudMaster/Features/Exam/Views/ExamView.swift +++ b/CloudMaster/Features/Exam/Views/ExamView.swift @@ -7,9 +7,10 @@ struct ExamView: View { @State private var currentQuestionIndex = 0 @State private var selectedChoices: [UUID: Set] = [:] @State private var timeRemaining: Int - @State private var showSummary = false + @State private var examFinished = false @State private var startTime: Date = Date() @State private var lastExamData: UserExamData? = nil + @State private var navigateToSummary = false let questionCount: Int let timeLimit: Int @@ -25,82 +26,82 @@ struct ExamView: View { } var body: some View { - VStack { - if !questionLoader.questions.isEmpty { - let questions = Array(questionLoader.questions.prefix(questionCount)) - if currentQuestionIndex < questions.count { - HStack { - Spacer() + NavigationStack { + VStack { + if !questionLoader.questions.isEmpty { + let questions = Array(questionLoader.questions.prefix(questionCount)) + if currentQuestionIndex < questions.count { HStack { Spacer() - Text("\(currentQuestionIndex + 1) of \(questionCount)") - .font(.subheadline) - .foregroundColor(.secondary) + HStack { + Spacer() + Text("\(currentQuestionIndex + 1) of \(questionCount)") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + } Spacer() } - Spacer() - } - .padding(.horizontal) + .padding(.horizontal) - ExamQuestion( - question: questions[currentQuestionIndex], - selectedChoices: selectedChoices[questions[currentQuestionIndex].id] ?? [], - isMultipleResponse: questions[currentQuestionIndex].multipleResponse, - onChoiceSelected: { choiceId in - if questions[currentQuestionIndex].multipleResponse { - if selectedChoices[questions[currentQuestionIndex].id]?.contains(choiceId) == true { - selectedChoices[questions[currentQuestionIndex].id]?.remove(choiceId) + ExamQuestion( + question: questions[currentQuestionIndex], + selectedChoices: selectedChoices[questions[currentQuestionIndex].id] ?? [], + isMultipleResponse: questions[currentQuestionIndex].multipleResponse, + onChoiceSelected: { choiceId in + if questions[currentQuestionIndex].multipleResponse { + if selectedChoices[questions[currentQuestionIndex].id]?.contains(choiceId) == true { + selectedChoices[questions[currentQuestionIndex].id]?.remove(choiceId) + } else { + selectedChoices[questions[currentQuestionIndex].id, default: []].insert(choiceId) + } } else { - selectedChoices[questions[currentQuestionIndex].id, default: []].insert(choiceId) + selectedChoices[questions[currentQuestionIndex].id] = [choiceId] } + } + ) + + Button(action: { + if currentQuestionIndex < questions.count - 1 { + currentQuestionIndex += 1 } else { - selectedChoices[questions[currentQuestionIndex].id] = [choiceId] + storeExamData(questions: questions) + navigateToSummary = true } + }) { + Text(currentQuestionIndex < questions.count - 1 ? "Next Question" : "Show Exam Result") + .padding() + .background(Color.customSecondary) + .foregroundColor(.white) + .cornerRadius(10) } - ) - - Button(action: { - if currentQuestionIndex < questions.count - 1 { - currentQuestionIndex += 1 - } else { - storeExamData(questions: questions) - showSummary = true - } - }) { - Text(currentQuestionIndex < questions.count - 1 ? "Next Question" : "Show Exam Result") - .padding() - .background(Color.customSecondary) - .foregroundColor(.white) - .cornerRadius(10) - } - .padding() + .padding() - Spacer() + Spacer() - HStack { - Image(systemName: "timer") - Text(timeFormatted(timeRemaining)) - .font(.headline) + HStack { + Image(systemName: "timer") + Text(timeFormatted(timeRemaining)) + .font(.headline) + } + .padding() } - .padding() } else { - Text("Loading questions...") + Text("No que") + } + } + .navigationDestination(isPresented: $navigateToSummary) { + if let examData = lastExamData { + ExamSummaryView(exam: examData, afterExam: true) } - } else { - Text("Loading questions...") } } .onAppear(perform: startTimer) .onDisappear { - if !showSummary { + if !examFinished { endExamIfNeeded() } } - .sheet(isPresented: $showSummary) { - if let examData = lastExamData { - ExamSummaryView(exam: examData) - } - } } func startTimer() { @@ -111,7 +112,7 @@ struct ExamView: View { } else { timer.invalidate() storeExamData(questions: Array(questionLoader.questions.prefix(questionCount))) - showSummary = true + navigateToSummary = true } } } @@ -147,13 +148,14 @@ struct ExamView: View { UserExamDataStore.shared.saveExamData(examData) lastExamData = examData + examFinished = true } private func endExamIfNeeded() { if timeRemaining > 0 { timeRemaining = 0 // End the exam immediately storeExamData(questions: Array(questionLoader.questions.prefix(questionCount))) - showSummary = true + navigateToSummary = true } } diff --git a/CloudMaster/Features/Exam/Views/PreviousExamsView.swift b/CloudMaster/Features/Exam/Views/PreviousExamsView.swift index 66ae386..576bad2 100644 --- a/CloudMaster/Features/Exam/Views/PreviousExamsView.swift +++ b/CloudMaster/Features/Exam/Views/PreviousExamsView.swift @@ -6,7 +6,7 @@ struct PreviousExamsView: View { var body: some View { List(exams) { exam in - NavigationLink(destination: ExamSummaryView(exam: exam)) { + NavigationLink(destination: ExamSummaryView(exam: exam, afterExam: false)) { HStack { VStack(alignment: .leading) { Text(exam.mode) From be413cf17418b6e83136f9a676f94fffba3b0e7b Mon Sep 17 00:00:00 2001 From: Benedikt Wagner Date: Tue, 4 Jun 2024 08:40:12 +0700 Subject: [PATCH 02/12] add github icon to HomeView --- .../githubIcon.symbolset/Contents.json | 12 ++ .../githubIcon.symbolset/githubIcon.svg | 167 ++++++++++++++++++ .../Features/Home/Views/HomeView.swift | 16 +- 3 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 CloudMaster/Assets.xcassets/githubIcon.symbolset/Contents.json create mode 100644 CloudMaster/Assets.xcassets/githubIcon.symbolset/githubIcon.svg diff --git a/CloudMaster/Assets.xcassets/githubIcon.symbolset/Contents.json b/CloudMaster/Assets.xcassets/githubIcon.symbolset/Contents.json new file mode 100644 index 0000000..c6996d6 --- /dev/null +++ b/CloudMaster/Assets.xcassets/githubIcon.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "githubIcon.svg", + "idiom" : "universal" + } + ] +} diff --git a/CloudMaster/Assets.xcassets/githubIcon.symbolset/githubIcon.svg b/CloudMaster/Assets.xcassets/githubIcon.symbolset/githubIcon.svg new file mode 100644 index 0000000..9964bc1 --- /dev/null +++ b/CloudMaster/Assets.xcassets/githubIcon.symbolset/githubIcon.svg @@ -0,0 +1,167 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.4.0 + Requires Xcode 14 or greater + Generated from github.fill + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CloudMaster/Features/Home/Views/HomeView.swift b/CloudMaster/Features/Home/Views/HomeView.swift index d455c92..585e509 100644 --- a/CloudMaster/Features/Home/Views/HomeView.swift +++ b/CloudMaster/Features/Home/Views/HomeView.swift @@ -3,10 +3,11 @@ import SwiftUI struct HomeView: View { @Binding var favorites: Set + @Environment(\.colorScheme) var colorScheme + private var appVersion: String { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown" - let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "Unknown" - return "Version \(version) (\(build))" + return "\(version)" } @@ -50,6 +51,16 @@ struct HomeView: View { .background(Color.customPrimary) .cornerRadius(10) } + .padding(.top, 10) + + Link(destination: URL(string: "https://github.com/Ditectrev/CloudMaster")!) { + Image("githubIcon") + .resizable() + .scaledToFit() + .foregroundColor(colorScheme == .dark ? .gray : .black) + .frame(width: 24, height: 24) + } + .padding(.bottom, 5) Text(appVersion) .font(.footnote) @@ -75,7 +86,6 @@ extension HomeView { Image(systemName: "gearshape") } } - } From d0f2d3a710f60ef84559e5215f1bcfe055c535c3 Mon Sep 17 00:00:00 2001 From: Benedikt Wagner Date: Tue, 4 Jun 2024 08:40:48 +0700 Subject: [PATCH 03/12] style question font size more accurate --- .../Features/Exam/Views/ExamView.swift | 16 +- .../Training/Views/TrainingView.swift | 189 ++++++++++-------- 2 files changed, 117 insertions(+), 88 deletions(-) diff --git a/CloudMaster/Features/Exam/Views/ExamView.swift b/CloudMaster/Features/Exam/Views/ExamView.swift index c1b3e9b..65b0541 100644 --- a/CloudMaster/Features/Exam/Views/ExamView.swift +++ b/CloudMaster/Features/Exam/Views/ExamView.swift @@ -180,8 +180,8 @@ struct ExamQuestion: View { .lineLimit(nil) // Allow text to wrap as needed .fixedSize(horizontal: false, vertical: true) .padding(.horizontal) - .frame(alignment: .leading) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) // Justify the text + .lineSpacing(2) if let imagePath = question.imagePath, let image = loadImage(from: imagePath) { @@ -223,9 +223,15 @@ struct ExamQuestion: View { } private func adjustedFontSize(for text: String) -> CGFloat { - _ = UIScreen.main.bounds.width - 32 // Adjust for desired padding - let fontSize = max(min(text.count / 80, 24), 14) // Simplified dynamic font sizing - return CGFloat(fontSize) + 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 } } diff --git a/CloudMaster/Features/Training/Views/TrainingView.swift b/CloudMaster/Features/Training/Views/TrainingView.swift index 8b2720f..eb9bd03 100644 --- a/CloudMaster/Features/Training/Views/TrainingView.swift +++ b/CloudMaster/Features/Training/Views/TrainingView.swift @@ -18,93 +18,108 @@ struct TrainingView: View { } var body: some View { - VStack { - if !questionLoader.questions.isEmpty { - let questions = Array(questionLoader.questions) - let totalQuestions = questions.count - - // Progress Header + ZStack { + VStack { + Spacer(minLength: 50) // Add spacing to push content below the custom navigation bar + if !questionLoader.questions.isEmpty { + let questions = Array(questionLoader.questions) + let totalQuestions = questions.count + + let question = questions[currentQuestionIndex] + + TrainingQuestion( + question: question, + selectedChoices: selectedChoices, + isMultipleResponse: question.multipleResponse, + isResultShown: showResult, + onChoiceSelected: { choiceID in + handleChoiceSelection(choiceID, question) + } + ) + + HStack(spacing: 20) { + if !showResult { + if currentQuestionIndex > 0 { + Button(action: { + currentQuestionIndex = max(currentQuestionIndex - 1, 0) + selectedChoices.removeAll() + showResult = false + startTime = Date() + }) { + Text("Previous") + .padding(10) + .frame(maxWidth: .infinity) + .background(Color.customSecondary) + .foregroundColor(.white) + .cornerRadius(10) + } + } + + Button(action: { + showResult = true + updateUserTrainingData(for: question) + }) { + Text("Show Result") + .padding(10) + .frame(maxWidth: .infinity) + .background(Color.customPrimary) + .foregroundColor(.white) + .cornerRadius(10) + } + } else { + Button(action: { + currentQuestionIndex = (currentQuestionIndex + 1) % totalQuestions + selectedChoices.removeAll() + showResult = false + startTime = Date() + }) { + Text("Next Question") + .padding(10) + .frame(maxWidth: .infinity) + .background(Color.customSecondary) + .foregroundColor(.white) + .cornerRadius(10) + } + } + } + .padding(.top) + } else { + Text("No Questions available! Please download course") + } + + Spacer() + } + .navigationBarHidden(true) + .onAppear { + startTime = Date() + } + .onDisappear { + saveUserTrainingData() + } + + VStack { HStack { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + HStack { + Image(systemName: "chevron.left") + .font(.title2) + .foregroundColor(.blue) + } + } + Spacer() - Text("\(currentQuestionIndex + 1) of \(totalQuestions)") + + Text("\(currentQuestionIndex + 1) of \(questionLoader.questions.count)") .font(.subheadline) .foregroundColor(.secondary) + Spacer() } - .padding(.top) - - let question = questions[currentQuestionIndex] - - TrainingQuestion( - question: question, - selectedChoices: selectedChoices, - isMultipleResponse: question.multipleResponse, - isResultShown: showResult, - onChoiceSelected: { choiceID in - handleChoiceSelection(choiceID, question) - } - ) - - HStack(spacing: 20) { - if !showResult { - if currentQuestionIndex > 0 { - Button(action: { - currentQuestionIndex = max(currentQuestionIndex - 1, 0) - selectedChoices.removeAll() - showResult = false - startTime = Date() - }) { - Text("Previous") - .padding(10) - .frame(maxWidth: .infinity) - .background(Color.customSecondary) - .foregroundColor(.white) - .cornerRadius(10) - } - } else { - Spacer() - } - - Button(action: { - showResult = true - updateUserTrainingData(for: question) - }) { - Text("Show Result") - .padding(10) - .frame(maxWidth: .infinity) - .background(Color.customPrimary) - .foregroundColor(.white) - .cornerRadius(10) - } - } else { - Button(action: { - currentQuestionIndex = (currentQuestionIndex + 1) % totalQuestions - selectedChoices.removeAll() - showResult = false - startTime = Date() - }) { - Text("Next Question") - .padding(10) - .frame(maxWidth: .infinity) - .background(Color.customSecondary) - .foregroundColor(.white) - .cornerRadius(10) - } - } - } - .padding(.top) - } else { - Text("No Questions available! Please download course") + .padding() + Spacer() } - - Spacer() - } - .navigationBarBackButtonHidden(false) - .onAppear { - startTime = Date() - } - .onDisappear { - saveUserTrainingData() } } @@ -170,6 +185,8 @@ struct TrainingQuestion: View { .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) { @@ -216,9 +233,15 @@ struct TrainingQuestion: View { private func adjustedFontSize(for text: String) -> CGFloat { - _ = UIScreen.main.bounds.width - 32 - let fontSize = max(min(text.count / 80, 24), 14) - return CGFloat(fontSize) + 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 } } From cecb25a33a83b1cd156a355781ad6fce513d71d6 Mon Sep 17 00:00:00 2001 From: Benedikt Wagner Date: Tue, 4 Jun 2024 08:41:21 +0700 Subject: [PATCH 04/12] unify button design --- CloudMaster/Features/Common/DownloadOverlayView.swift | 4 ++-- CloudMaster/Features/Course/Views/CourseView.swift | 1 - CloudMaster/Features/Courses/Views/CoursesView.swift | 3 ++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CloudMaster/Features/Common/DownloadOverlayView.swift b/CloudMaster/Features/Common/DownloadOverlayView.swift index ccaf122..17fa65e 100644 --- a/CloudMaster/Features/Common/DownloadOverlayView.swift +++ b/CloudMaster/Features/Common/DownloadOverlayView.swift @@ -7,7 +7,7 @@ struct DownloadOverlayView: View { var body: some View { if isShowing { ZStack { - Color.black.opacity(0.4) + Color.black.opacity(0.8) .edgesIgnoringSafeArea(.all) VStack { @@ -61,7 +61,7 @@ struct CircularProgressView: View { withAnimation(.spring()) { Image(systemName: "checkmark.circle.fill") .resizable() - .foregroundColor(Color.green) + .foregroundColor(Color.correct) .frame(width: 50, height: 50) } } diff --git a/CloudMaster/Features/Course/Views/CourseView.swift b/CloudMaster/Features/Course/Views/CourseView.swift index 5daa10e..1e01b6c 100644 --- a/CloudMaster/Features/Course/Views/CourseView.swift +++ b/CloudMaster/Features/Course/Views/CourseView.swift @@ -132,7 +132,6 @@ struct CourseView: View { } }) { Image(systemName: notificationsEnabled ? "bell.fill" : "bell") - .foregroundColor(notificationsEnabled ? Color.correct : .gray) } } diff --git a/CloudMaster/Features/Courses/Views/CoursesView.swift b/CloudMaster/Features/Courses/Views/CoursesView.swift index 9892e0e..64b5a61 100644 --- a/CloudMaster/Features/Courses/Views/CoursesView.swift +++ b/CloudMaster/Features/Courses/Views/CoursesView.swift @@ -41,7 +41,8 @@ struct CoursesView: View { viewModel.downloadCourses(favorites) }) { Image(systemName: "arrow.down.circle") + .foregroundColor(viewModel.isDownloading ? .gray : .accentColor) // Change color based on state (optional) } + .disabled(viewModel.isDownloading) } } - From 2853c1ebad47abdde965c745ca2c4062789adde4 Mon Sep 17 00:00:00 2001 From: Benedikt Wagner Date: Tue, 4 Jun 2024 08:41:27 +0700 Subject: [PATCH 05/12] improve intro --- CloudMaster/CloudMaster.swift | 3 -- .../Features/Intro/Views/IntroView.swift | 41 ++++++++++++++----- .../Settings/Views/SettingsView.swift | 15 ------- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/CloudMaster/CloudMaster.swift b/CloudMaster/CloudMaster.swift index d25ef46..987b969 100644 --- a/CloudMaster/CloudMaster.swift +++ b/CloudMaster/CloudMaster.swift @@ -16,9 +16,6 @@ struct CloudMaster: App { HomeView(favorites: $favorites) } else { IntroView(favorites: $favorites, isAppConfigured: $isFirstStart) - .onAppear { - UserDefaults.standard.set(true, forKey: "isFirstStart") - } } } } diff --git a/CloudMaster/Features/Intro/Views/IntroView.swift b/CloudMaster/Features/Intro/Views/IntroView.swift index b110b2c..6554367 100644 --- a/CloudMaster/Features/Intro/Views/IntroView.swift +++ b/CloudMaster/Features/Intro/Views/IntroView.swift @@ -50,19 +50,26 @@ struct FirstPage: View { let isIpad = UIDevice.current.userInterfaceIdiom == .pad let imageSize: CGFloat = isIpad ? 300 : 200 - VStack { - Text("Welcome to CloudMaster") - .font(.largeTitle) - .bold() - .padding() + VStack() { + Text("WELCOME TO") + .font(.system(size: 24, weight: .light, design: .default)) + .transition(.opacity) + .frame(alignment: .leading) + .multilineTextAlignment(.center) + + Text("Cloudmaster") + .font(.system(size: 36, weight: .bold, design: .default)) .transition(.opacity) .frame(alignment: .leading) .multilineTextAlignment(.center) Text("Improve your knowledge and get ready for exams") - .font(.subheadline) + .font(.system(size: 18, weight: .light, design: .default)) .bold() .padding(.bottom, 20) + .frame(alignment: .leading) + .multilineTextAlignment(.center) + .padding() Spacer() @@ -93,14 +100,25 @@ struct SecondPage: View { var body: some View { VStack { - Text("Welcome to CloudMaster") - .font(.largeTitle) - .bold() - .padding() + Text("WELCOME TO") + .font(.system(size: 24, weight: .light, design: .default)) + .transition(.opacity) + .frame(alignment: .leading) + .multilineTextAlignment(.center) + Text("Cloudmaster") + .font(.system(size: 36, weight: .bold, design: .default)) + .transition(.opacity) + .frame(alignment: .leading) + .multilineTextAlignment(.center) + Text("Select your courses to study") - .font(.subheadline) + .font(.system(size: 18, weight: .light, design: .default)) .bold() + .padding(.bottom, 20) + .frame(alignment: .leading) + .multilineTextAlignment(.center) + .padding() SearchBar(text: $searchText) .padding() @@ -119,6 +137,7 @@ struct SecondPage: View { Spacer() Button("Finish Setup") { + UserDefaults.standard.set(true, forKey: "isFirstStart") showDownloadOverlayView = true } .foregroundColor(.white) diff --git a/CloudMaster/Features/Settings/Views/SettingsView.swift b/CloudMaster/Features/Settings/Views/SettingsView.swift index a925a16..381f89f 100644 --- a/CloudMaster/Features/Settings/Views/SettingsView.swift +++ b/CloudMaster/Features/Settings/Views/SettingsView.swift @@ -32,21 +32,6 @@ struct SettingsView: View { alertAction?() }) - Spacer() - - HStack { - Spacer() - Link(destination: URL(string: "https://github.com/your-repository-url")!) { - HStack { - Image(systemName: "link.circle") - Text("View on GitHub") - } - .padding() - } - .foregroundColor(.blue) - Spacer() - } - .padding() } } From 6a2d3a67ccdc2fbe3769825c550811289d0a0cdc Mon Sep 17 00:00:00 2001 From: Benedikt Wagner Date: Tue, 4 Jun 2024 09:14:54 +0700 Subject: [PATCH 06/12] create shared Navbar component to be used in exam later --- CloudMaster.xcodeproj/project.pbxproj | 22 ++++++++++++++- .../Components}/ConfirmPopup.swift | 0 .../Shared/Components/QuestionNavbar.swift | 28 +++++++++++++++++++ .../Training/Views/TrainingView.swift | 27 ++---------------- 4 files changed, 51 insertions(+), 26 deletions(-) rename CloudMaster/Features/{Common => Shared/Components}/ConfirmPopup.swift (100%) create mode 100644 CloudMaster/Features/Shared/Components/QuestionNavbar.swift diff --git a/CloudMaster.xcodeproj/project.pbxproj b/CloudMaster.xcodeproj/project.pbxproj index 1efb3a2..5009492 100644 --- a/CloudMaster.xcodeproj/project.pbxproj +++ b/CloudMaster.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 8D26A31C2C0EA9C100E9B015 /* QuestionNavbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D26A31B2C0EA9C100E9B015 /* QuestionNavbar.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 */; }; @@ -61,6 +62,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 8D26A31B2C0EA9C100E9B015 /* QuestionNavbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionNavbar.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 = ""; }; @@ -125,6 +127,23 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 8D26A3192C0EA9A400E9B015 /* Components */ = { + isa = PBXGroup; + children = ( + 8D8D8A9F2C05A27800ACC61C /* ConfirmPopup.swift */, + 8D26A31B2C0EA9C100E9B015 /* QuestionNavbar.swift */, + ); + path = Components; + sourceTree = ""; + }; + 8D26A31A2C0EA9B300E9B015 /* Shared */ = { + isa = PBXGroup; + children = ( + 8D26A3192C0EA9A400E9B015 /* Components */, + ); + path = Shared; + sourceTree = ""; + }; 8D8D8A682C05A23400ACC61C = { isa = PBXGroup; children = ( @@ -183,7 +202,6 @@ isa = PBXGroup; children = ( 8DABB7722C0D7CF900B40E25 /* ViewModels */, - 8D8D8A9F2C05A27800ACC61C /* ConfirmPopup.swift */, 8D8D8AA02C05A27800ACC61C /* DownloadOverlayView.swift */, 8D8D8AA12C05A27800ACC61C /* NotificationSettingsView.swift */, ); @@ -342,6 +360,7 @@ 8D8D8AC32C05A27800ACC61C /* Features */ = { isa = PBXGroup; children = ( + 8D26A31A2C0EA9B300E9B015 /* Shared */, 8D8D8AA22C05A27800ACC61C /* Common */, 8D8D8AA62C05A27800ACC61C /* Course */, 8D8D8AA92C05A27800ACC61C /* Courses */, @@ -574,6 +593,7 @@ 8D8D8AD92C05A27800ACC61C /* HomeView.swift in Sources */, 8D8D8AD42C05A27800ACC61C /* ExamModesView.swift in Sources */, 8D8D8AE12C05A27800ACC61C /* Courses.swift in Sources */, + 8D26A31C2C0EA9C100E9B015 /* QuestionNavbar.swift in Sources */, 8D8D8AD12C05A27800ACC61C /* CourseView.swift in Sources */, 8DABB7742C0D7D0300B40E25 /* DownloadViewModel.swift in Sources */, ); diff --git a/CloudMaster/Features/Common/ConfirmPopup.swift b/CloudMaster/Features/Shared/Components/ConfirmPopup.swift similarity index 100% rename from CloudMaster/Features/Common/ConfirmPopup.swift rename to CloudMaster/Features/Shared/Components/ConfirmPopup.swift diff --git a/CloudMaster/Features/Shared/Components/QuestionNavbar.swift b/CloudMaster/Features/Shared/Components/QuestionNavbar.swift new file mode 100644 index 0000000..017393a --- /dev/null +++ b/CloudMaster/Features/Shared/Components/QuestionNavbar.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct QuestionNavbar: View { + @Environment(\.presentationMode) var presentationMode + let currentQuestionIndex: Int + let totalQuestions: Int + + var body: some View { + HStack { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Image(systemName: "chevron.left") + .font(.title3) + .foregroundColor(.blue) + } + + Spacer() + + Text("\(currentQuestionIndex + 1) of \(totalQuestions)") + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + } + .padding() + } +} diff --git a/CloudMaster/Features/Training/Views/TrainingView.swift b/CloudMaster/Features/Training/Views/TrainingView.swift index eb9bd03..fa120aa 100644 --- a/CloudMaster/Features/Training/Views/TrainingView.swift +++ b/CloudMaster/Features/Training/Views/TrainingView.swift @@ -20,7 +20,8 @@ struct TrainingView: View { var body: some View { ZStack { VStack { - Spacer(minLength: 50) // Add spacing to push content below the custom navigation bar + QuestionNavbar(currentQuestionIndex: currentQuestionIndex, totalQuestions: questionLoader.questions.count) + if !questionLoader.questions.isEmpty { let questions = Array(questionLoader.questions) let totalQuestions = questions.count @@ -96,30 +97,6 @@ struct TrainingView: View { .onDisappear { saveUserTrainingData() } - - VStack { - HStack { - Button(action: { - presentationMode.wrappedValue.dismiss() - }) { - HStack { - Image(systemName: "chevron.left") - .font(.title2) - .foregroundColor(.blue) - } - } - - Spacer() - - Text("\(currentQuestionIndex + 1) of \(questionLoader.questions.count)") - .font(.subheadline) - .foregroundColor(.secondary) - - Spacer() - } - .padding() - Spacer() - } } } From b2c3219c155cfa16947d77594c9c975295a20bd7 Mon Sep 17 00:00:00 2001 From: Benedikt Wagner Date: Tue, 4 Jun 2024 09:17:10 +0700 Subject: [PATCH 07/12] navigate back after deleting exam --- CloudMaster/Features/Exam/Views/ExamSummaryView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CloudMaster/Features/Exam/Views/ExamSummaryView.swift b/CloudMaster/Features/Exam/Views/ExamSummaryView.swift index 406f512..d1eff3b 100644 --- a/CloudMaster/Features/Exam/Views/ExamSummaryView.swift +++ b/CloudMaster/Features/Exam/Views/ExamSummaryView.swift @@ -1,9 +1,6 @@ import Foundation import SwiftUI -import Foundation -import SwiftUI - struct ExamSummaryView: View { @State private var expandedQuestionIDs: Set = [] @State private var showDeleteConfirmation = false @@ -15,6 +12,8 @@ struct ExamSummaryView: View { // Helper variable to hide backbutton after Exam let afterExam: Bool + @Environment(\.presentationMode) var presentationMode + var body: some View { VStack { Text(exam.isPassed ? "Passed" : "Failed") @@ -103,6 +102,7 @@ struct ExamSummaryView: View { } .confirmPopup(isPresented: $showDeleteConfirmation, title: "Delete Exam", message: "Are you sure you want to delete this exam?", confirmAction: { examDataStore.deleteExam(withId: exam.id) + presentationMode.wrappedValue.dismiss() // Dismiss the view after deletion }) } From 929afe7f2bd4e7ce6b05662d49bfd73e60496cad Mon Sep 17 00:00:00 2001 From: Benedikt Wagner Date: Tue, 4 Jun 2024 09:23:36 +0700 Subject: [PATCH 08/12] add download button to readme --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index d52e89f..be1b6bc 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ CloudMaster Swift is a powerful iOS application designed to help you prepare for various cloud certification exams like AWS, GCP, and Azure. This app is a testament to the capabilities of modern AI language models like ChatGPT and Claude.AI, as it was developed solely with their assistance, without any prior Swift experience. + + Download on the App Store + + + ## Personal Goal My goal with this project was to create an mobile application from scratch in a for me unknown languange and unknown IDE. Relying solely on the guidance provided by large language models like ChatGPT and Claude.AI i was able to create this Swift application within Xcode. This endeavor aimed to showcase the potential of these AI models in assisting developers, even those without prior experience in a particular language or framework. From 39c6250eeea9bd1b9beef0fbfa0f236f20964145 Mon Sep 17 00:00:00 2001 From: Benedikt Wagner Date: Tue, 4 Jun 2024 13:11:04 +0700 Subject: [PATCH 09/12] download multiple images for questions --- CloudMaster/Utilities/DownloadUtility.swift | 112 +++++++++++--------- CloudMaster/Utilities/QuestionLoader.swift | 14 ++- 2 files changed, 73 insertions(+), 53 deletions(-) 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 From 65dbdecf8b06a8d3e1f84b0aade41f84eb44e76d Mon Sep 17 00:00:00 2001 From: Benedikt Wagner Date: Tue, 4 Jun 2024 13:11:15 +0700 Subject: [PATCH 10/12] display multiple images in TableView --- CloudMaster.xcodeproj/project.pbxproj | 8 + .../Features/Exam/Views/ExamView.swift | 100 +---------- .../Shared/Components/QuestionImages.swift | 104 +++++++++++ .../Shared/Components/QuestionView.swift | 170 ++++++++++++++++++ .../Training/Views/TrainingView.swift | 130 +------------- 5 files changed, 289 insertions(+), 223 deletions(-) create mode 100644 CloudMaster/Features/Shared/Components/QuestionImages.swift create mode 100644 CloudMaster/Features/Shared/Components/QuestionView.swift diff --git a/CloudMaster.xcodeproj/project.pbxproj b/CloudMaster.xcodeproj/project.pbxproj index 5009492..c0d83f3 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 */, 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 - } - } -} From 2c8316f43d1c39295ab15eb0377101206810a82a Mon Sep 17 00:00:00 2001 From: Benedikt Wagner Date: Tue, 4 Jun 2024 13:14:10 +0700 Subject: [PATCH 11/12] bump version --- CloudMaster.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CloudMaster.xcodeproj/project.pbxproj b/CloudMaster.xcodeproj/project.pbxproj index 5009492..47a2de5 100644 --- a/CloudMaster.xcodeproj/project.pbxproj +++ b/CloudMaster.xcodeproj/project.pbxproj @@ -774,7 +774,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 +809,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 = ""; From 564658ed752ee8229ec096728a971ebe7f5bf85f Mon Sep 17 00:00:00 2001 From: Benedikt Wagner Date: Tue, 4 Jun 2024 13:14:46 +0700 Subject: [PATCH 12/12] bump version to 1.0.3 --- CloudMaster.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CloudMaster.xcodeproj/project.pbxproj b/CloudMaster.xcodeproj/project.pbxproj index c0d83f3..56bdf6b 100644 --- a/CloudMaster.xcodeproj/project.pbxproj +++ b/CloudMaster.xcodeproj/project.pbxproj @@ -782,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 = ""; @@ -817,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 = "";