diff --git a/CloudMaster.xcodeproj/project.pbxproj b/CloudMaster.xcodeproj/project.pbxproj index 56bdf6b..f248fa2 100644 --- a/CloudMaster.xcodeproj/project.pbxproj +++ b/CloudMaster.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 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 */; }; + 8D26A3222C101C5000E9B015 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D26A3212C101C5000E9B015 /* BookmarksView.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 */; }; @@ -67,6 +68,7 @@ 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 = ""; }; + 8D26A3212C101C5000E9B015 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.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 = ""; }; @@ -225,6 +227,7 @@ isa = PBXGroup; children = ( 8D8D8AA42C05A27800ACC61C /* CourseView.swift */, + 8D26A3212C101C5000E9B015 /* BookmarksView.swift */, ); path = Views; sourceTree = ""; @@ -603,6 +606,7 @@ 8D8D8AE12C05A27800ACC61C /* Courses.swift in Sources */, 8D26A31C2C0EA9C100E9B015 /* QuestionNavbar.swift in Sources */, 8D8D8AD12C05A27800ACC61C /* CourseView.swift in Sources */, + 8D26A3222C101C5000E9B015 /* BookmarksView.swift in Sources */, 8DABB7742C0D7D0300B40E25 /* DownloadViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/CloudMaster/Features/Course/Views/BookmarksView.swift b/CloudMaster/Features/Course/Views/BookmarksView.swift new file mode 100644 index 0000000..215770d --- /dev/null +++ b/CloudMaster/Features/Course/Views/BookmarksView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct BookmarksView: View { + @State private var bookmarks: [Bookmark] = [] + + var body: some View { + NavigationView { + + if bookmarks.isEmpty { + VStack { + Image(systemName: "bookmark") + .resizable() + .frame(width: 80, height: 100) + .foregroundColor(.gray) + .padding(.bottom, 20) + Text("No questions saved") + .font(.title) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(bookmarks) { bookmark in + NavigationLink(destination: QuestionDetailView(question: bookmark.question, bookmarks: $bookmarks)) { + VStack(alignment: .leading) { + Text(bookmark.question.question.prefix(40) + "...") + .font(.headline) + .lineLimit(2) + .padding(.vertical,5) + } + } + } + } + } + } + .navigationTitle("Bookmarks") + .onAppear { + bookmarks = FavoritesStorage.shared.loadBookmarks() + } + } +} + +struct QuestionDetailView: View { + let question: Question + @Binding var bookmarks: [Bookmark] + @State private var isBookmarked: Bool = false + @Environment(\.presentationMode) var presentationMode + + var body: some View { + QuestionView( + mode: .bookmarked, + question: question, + selectedChoices: nil, + isMultipleResponse: question.multipleResponse, + isResultShown: true, + onChoiceSelected: { _ in } + ) + .navigationTitle("Question") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(trailing: bookmarkButton) + .onAppear { + isBookmarked = FavoritesStorage.shared.isBookmarked(question) + } + } + + private var bookmarkButton: some View { + Button(action: { + toggleBookmark() + }) { + Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") + + } + } + + private func toggleBookmark() { + if isBookmarked { + FavoritesStorage.shared.removeBookmarkByQuestionText(question.question) + bookmarks = FavoritesStorage.shared.loadBookmarks() + presentationMode.wrappedValue.dismiss() + } else { + let newBookmark = Bookmark(id: UUID(), question: question, answer: question.choices) + FavoritesStorage.shared.addBookmark(newBookmark) + bookmarks = FavoritesStorage.shared.loadBookmarks() + } + isBookmarked.toggle() + } +} diff --git a/CloudMaster/Features/Course/Views/CourseView.swift b/CloudMaster/Features/Course/Views/CourseView.swift index 1e01b6c..99bdaeb 100644 --- a/CloudMaster/Features/Course/Views/CourseView.swift +++ b/CloudMaster/Features/Course/Views/CourseView.swift @@ -1,14 +1,17 @@ import SwiftUI - struct CourseView: View { @State private var isLoading = false @State private var downloadProgress: [Course: Progress] = [:] @State private var userTrainingData = UserTrainingData() @State private var showingNotificationSettings = false @State private var notificationsEnabled = false + @State private var showingInfoPopup = false + @StateObject private var viewModel = DownloadViewModel() @StateObject private var questionLoader: QuestionLoader + @Environment(\.colorScheme) var colorScheme + let course: Course init(course: Course) { @@ -41,7 +44,7 @@ struct CourseView: View { Spacer() VStack(spacing: 20) { - NavigationLink(destination: TrainingView(course: course, questionLoader: questionLoader)) { + NavigationLink(destination: TrainingView(course: course)) { VStack { Text("Training") .font(.title) @@ -55,7 +58,7 @@ struct CourseView: View { .cornerRadius(10) } - NavigationLink(destination: TrainingView(course: course, questionLoader: questionLoader)) { + NavigationLink(destination: TrainingView(course: course)) { VStack { Text("Intelligent Training") .font(.title) @@ -85,14 +88,16 @@ struct CourseView: View { } Spacer() - HStack(spacing: 20) { - Link("Certification", destination: URL(string: course.url)!) - .padding() - .font(.subheadline) - - Link("Sources", destination: URL(string: course.repositoryURL)!) - .padding() - .font(.subheadline) + NavigationLink(destination: BookmarksView()) { + HStack { + Image(systemName: "bookmark") + .font(.title3) + .foregroundColor(colorScheme == .dark ? .white : .black) + Text("Bookmarks") + .font(.title3) + .foregroundColor(colorScheme == .dark ? .white : .black) + } + .cornerRadius(10) } } .onAppear { @@ -104,7 +109,10 @@ struct CourseView: View { } } .navigationBarTitle(course.shortName, displayMode: .inline) - .navigationBarItems(trailing: notificationButton) + .navigationBarItems(trailing: HStack { + notificationButton + infoButton + }) .navigationBarBackButtonHidden(false) .sheet(isPresented: $showingNotificationSettings) { NotificationSettingsView(isPresented: $showingNotificationSettings, notificationsEnabled: $notificationsEnabled, course: course) @@ -112,6 +120,9 @@ struct CourseView: View { checkNotificationSettings() } } + .sheet(isPresented: $showingInfoPopup) { + CourseInformationPopup(course: course) + } .overlay( DownloadOverlayView( isShowing: $viewModel.isDownloading, @@ -135,6 +146,14 @@ struct CourseView: View { } } + private var infoButton: some View { + Button(action: { + showingInfoPopup = true + }) { + Image(systemName: "info.circle") + } + } + func loadUserTrainingData(for course: Course) { if let data = UserDefaults.standard.data(forKey: course.shortName) { if let decodedData = try? JSONDecoder().decode(UserTrainingData.self, from: data) { @@ -163,7 +182,7 @@ struct CourseView: View { func downloadCourse() { viewModel.downloadCourse(course) viewModel.$isDownloading.sink { isDownloading in - if !isDownloading { + if (!isDownloading) { DispatchQueue.main.async { questionLoader.reloadQuestions(from: course.shortName + ".json") } @@ -181,3 +200,39 @@ struct CourseView: View { .store(in: &viewModel.cancellables) } } + +struct CourseInformationPopup: View { + let course: Course + + var body: some View { + VStack(spacing: 20) { + Text("Course information") + .font(.title2) + .multilineTextAlignment(.center) + + VStack(spacing: 10) { + HStack { + Spacer() + Image(systemName: "book.pages.fill") + Text("Certification") + Spacer() + } + Link(course.url, destination: URL(string: course.url)!) + } + + VStack(spacing: 10) { + HStack { + Spacer() + Image(systemName: "link") + Text("Sources") + Spacer() + } + Link(course.repositoryURL, destination: URL(string: course.repositoryURL)!) + } + + Spacer() + } + .padding() + } + +} diff --git a/CloudMaster/Features/Exam/Views/ExamModesView.swift b/CloudMaster/Features/Exam/Views/ExamModesView.swift index 55f4787..6322676 100644 --- a/CloudMaster/Features/Exam/Views/ExamModesView.swift +++ b/CloudMaster/Features/Exam/Views/ExamModesView.swift @@ -4,6 +4,8 @@ struct ExamModeView: View { let course: Course @ObservedObject var examDataStore = UserExamDataStore.shared + @Environment(\.colorScheme) var colorScheme + var body: some View { VStack { @@ -68,12 +70,13 @@ struct ExamModeView: View { NavigationLink(destination: PreviousExamsView(exams: filteredExams)) { HStack { Image(systemName: "clock.arrow.circlepath") + .font(.title3) + .foregroundColor(colorScheme == .dark ? .white : .black) Text("Exam History") + .font(.title3) + .foregroundColor(filteredExams.isEmpty ? .gray : (colorScheme == .dark ? .white : .black)) } .padding() - .background(filteredExams.isEmpty ? Color.customAccent : Color.customPrimary) - .foregroundColor(.white) - .cornerRadius(10) } .disabled(filteredExams.isEmpty) } diff --git a/CloudMaster/Features/Shared/Components/QuestionNavbar.swift b/CloudMaster/Features/Shared/Components/QuestionNavbar.swift index 017393a..2b6ec66 100644 --- a/CloudMaster/Features/Shared/Components/QuestionNavbar.swift +++ b/CloudMaster/Features/Shared/Components/QuestionNavbar.swift @@ -1,10 +1,12 @@ import SwiftUI - struct QuestionNavbar: View { @Environment(\.presentationMode) var presentationMode let currentQuestionIndex: Int let totalQuestions: Int + let question: Question + @Binding var isBookmarked: Bool + var body: some View { HStack { Button(action: { @@ -22,6 +24,21 @@ struct QuestionNavbar: View { .foregroundColor(.secondary) Spacer() + + Button(action: { + if isBookmarked { + FavoritesStorage.shared.removeBookmarkByQuestionText(question.question) + isBookmarked = false + } else { + let newBookmark = Bookmark(id: UUID(), question: question, answer: question.choices) + FavoritesStorage.shared.addBookmark(newBookmark) + isBookmarked = true + } + }) { + Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") + .font(.title3) + .foregroundColor(.blue) + } } .padding() } diff --git a/CloudMaster/Features/Shared/Components/QuestionView.swift b/CloudMaster/Features/Shared/Components/QuestionView.swift index 29e2a12..128f257 100644 --- a/CloudMaster/Features/Shared/Components/QuestionView.swift +++ b/CloudMaster/Features/Shared/Components/QuestionView.swift @@ -1,10 +1,3 @@ -// -// QuestionView.swift -// CloudMaster -// -// Created by Benedikt Wagner on 04.06.24. -// - import Foundation import SwiftUI @@ -12,6 +5,7 @@ struct QuestionView: View { enum Mode { case training case exam + case bookmarked } let mode: Mode @@ -67,12 +61,17 @@ struct QuestionView: View { isResultShown: isResultShown ?? false, onChoiceSelected: onChoiceSelected ) - } else { + } else if mode == .exam { ExamChoice( choice: choice, isSelected: selectedChoices?.contains(choice.id) == true, onChoiceSelected: onChoiceSelected ) + } else if mode == .bookmarked { + BookmarkedChoice( + choice: choice, + isSelected: selectedChoices?.contains(choice.id) == true + ) } } } @@ -163,7 +162,24 @@ struct ExamChoice: View { .background(isSelected ? Color.gray.opacity(0.3) : Color.clear) .cornerRadius(10) .padding(.horizontal) - .foregroundColor(.white) + + Divider() + } +} + +struct BookmarkedChoice: View { + let choice: Choice + let isSelected: Bool + + var body: some View { + Text(choice.text) + .padding() + .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + .background(choice.correct ? Color.correct : (isSelected ? Color.wrong : Color.gray.opacity(0.3))) + .foregroundColor(.white) + .cornerRadius(10) + .padding(.horizontal) Divider() } diff --git a/CloudMaster/Features/Training/Views/TrainingView.swift b/CloudMaster/Features/Training/Views/TrainingView.swift index 3113821..5640a44 100644 --- a/CloudMaster/Features/Training/Views/TrainingView.swift +++ b/CloudMaster/Features/Training/Views/TrainingView.swift @@ -6,28 +6,31 @@ struct TrainingView: View { @State private var showResult = false @State private var userTrainingData = UserTrainingStore.shared.trainingData @State private var startTime: Date? + @State private var isBookmarked: Bool = false @Environment(\.presentationMode) var presentationMode let course: Course - @ObservedObject var questionLoader: QuestionLoader + @StateObject private var questionLoader: QuestionLoader - init(course: Course, questionLoader: QuestionLoader) { + init(course: Course) { self.course = course - self._questionLoader = ObservedObject(wrappedValue: questionLoader) - loadUserTrainingData(for: course) + _questionLoader = StateObject(wrappedValue: QuestionLoader(filename: course.shortName + ".json", intelligentLearning: false)) } var body: some View { ZStack { VStack { - QuestionNavbar(currentQuestionIndex: currentQuestionIndex, totalQuestions: questionLoader.questions.count) - if !questionLoader.questions.isEmpty { let questions = Array(questionLoader.questions) let totalQuestions = questions.count - let question = questions[currentQuestionIndex] - + + QuestionNavbar( + currentQuestionIndex: currentQuestionIndex, + totalQuestions: questionLoader.questions.count, + question: question, + isBookmarked: $isBookmarked + ) QuestionView( mode: .training, question: question, @@ -38,15 +41,16 @@ struct TrainingView: View { handleChoiceSelection(choiceID, question) } ) - + .navigationBarItems(trailing: bookmarkButton) + .onAppear { + updateBookmarkState() + } + HStack(spacing: 20) { if !showResult { if currentQuestionIndex > 0 { Button(action: { - currentQuestionIndex = max(currentQuestionIndex - 1, 0) - selectedChoices.removeAll() - showResult = false - startTime = Date() + showPreviousQuestion() }) { Text("Previous") .padding(10) @@ -56,7 +60,7 @@ struct TrainingView: View { .cornerRadius(10) } } - + Button(action: { showResult = true updateUserTrainingData(for: question) @@ -70,10 +74,7 @@ struct TrainingView: View { } } else { Button(action: { - currentQuestionIndex = (currentQuestionIndex + 1) % totalQuestions - selectedChoices.removeAll() - showResult = false - startTime = Date() + showNextQuestion() }) { Text("Next Question") .padding(10) @@ -88,12 +89,13 @@ struct TrainingView: View { } else { Text("No Questions available! Please download course") } - + Spacer() } .navigationBarHidden(true) .onAppear { startTime = Date() + updateBookmarkState() // Ensure bookmark state is updated when the view appears } .onDisappear { saveUserTrainingData() @@ -101,6 +103,51 @@ struct TrainingView: View { } } + private var bookmarkButton: some View { + Button(action: { + toggleBookmark() + }) { + Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") + .foregroundColor(isBookmarked ? .red : .blue) + } + } + + private func showNextQuestion() { + if currentQuestionIndex < questionLoader.questions.count - 1 { + currentQuestionIndex += 1 + selectedChoices.removeAll() + showResult = false + startTime = Date() + updateBookmarkState() // Update the bookmark state for the next question + } + } + + private func showPreviousQuestion() { + if currentQuestionIndex > 0 { + currentQuestionIndex -= 1 + selectedChoices.removeAll() + showResult = false + startTime = Date() + updateBookmarkState() // Update the bookmark state for the previous question + } + } + + private func updateBookmarkState() { + let currentQuestion = questionLoader.questions[currentQuestionIndex] + isBookmarked = FavoritesStorage.shared.isBookmarked(currentQuestion) + } + + private func toggleBookmark() { + let currentQuestion = questionLoader.questions[currentQuestionIndex] + if isBookmarked { + FavoritesStorage.shared.removeBookmarkByQuestionText(currentQuestion.question) + } else { + let newBookmark = Bookmark(id: UUID(), question: currentQuestion, answer: currentQuestion.choices) + FavoritesStorage.shared.addBookmark(newBookmark) + } + isBookmarked.toggle() + } + func handleChoiceSelection(_ choiceID: UUID, _ question: Question) { if question.multipleResponse { if selectedChoices.contains(choiceID) { diff --git a/CloudMaster/Utilities/FavoriteStorage.swift b/CloudMaster/Utilities/FavoriteStorage.swift index 70c2acb..97d30f7 100644 --- a/CloudMaster/Utilities/FavoriteStorage.swift +++ b/CloudMaster/Utilities/FavoriteStorage.swift @@ -1,12 +1,24 @@ import Foundation +struct Bookmark: Identifiable, Codable { + let id: UUID + let question: Question + let answer: [Choice] +} + class FavoritesStorage { static let shared = FavoritesStorage() private let storageKey = "favorites" + private let bookmarksKey = "bookmarks" + private init() {} + /** + Course favorites Section + */ + func saveFavorites(_ favorites: Set) { if let encoded = try? JSONEncoder().encode(Array(favorites)) { UserDefaults.standard.set(encoded, forKey: storageKey) @@ -22,4 +34,57 @@ class FavoritesStorage { } return [] } + + /** + Bookmark Section + */ + + func saveBookmarks(_ bookmarks: [Bookmark]) { + if let encoded = try? JSONEncoder().encode(bookmarks) { + UserDefaults.standard.set(encoded, forKey: bookmarksKey) + } else { + UserDefaults.standard.removeObject(forKey: bookmarksKey) + } + } + + func loadBookmarks() -> [Bookmark] { + if let bookmarksData = UserDefaults.standard.data(forKey: bookmarksKey), + let decodedBookmarks = try? JSONDecoder().decode([Bookmark].self, from: bookmarksData) { + return decodedBookmarks + } + return [] + } + + func addBookmark(_ bookmark: Bookmark) { + var bookmarks = loadBookmarks() + guard !bookmarks.contains(where: { $0.question.question == bookmark.question.question }) else { + return + } + bookmarks.append(bookmark) + saveBookmarks(bookmarks) + } + + func removeBookmark(_ bookmark: Bookmark) { + var bookmarks = loadBookmarks() + if let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) { + bookmarks.remove(at: index) + saveBookmarks(bookmarks) + } + } + + + /** + This function using currently the question text for comparison, which is not perfect. As the question id is changed, this is currently the way to go. + */ + func removeBookmarkByQuestionText(_ questionText: String) { + var bookmarks = loadBookmarks() + if let index = bookmarks.firstIndex(where: { $0.question.question == questionText }) { + bookmarks.remove(at: index) + saveBookmarks(bookmarks) + } + } + + func isBookmarked(_ question: Question) -> Bool { + return loadBookmarks().contains { $0.question.question == question.question } + } }