diff --git a/CloudMaster.xcodeproj/project.pbxproj b/CloudMaster.xcodeproj/project.pbxproj index 569b609..1efb3a2 100644 --- a/CloudMaster.xcodeproj/project.pbxproj +++ b/CloudMaster.xcodeproj/project.pbxproj @@ -11,19 +11,17 @@ 8D8D8A902C05A23600ACC61C /* CloudMasterUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8A8F2C05A23600ACC61C /* CloudMasterUITests.swift */; }; 8D8D8A922C05A23600ACC61C /* CloudMasterUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8A912C05A23600ACC61C /* CloudMasterUITestsLaunchTests.swift */; }; 8D8D8ACE2C05A27800ACC61C /* ConfirmPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8A9F2C05A27800ACC61C /* ConfirmPopup.swift */; }; - 8D8D8ACF2C05A27800ACC61C /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AA02C05A27800ACC61C /* DownloadView.swift */; }; + 8D8D8ACF2C05A27800ACC61C /* DownloadOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AA02C05A27800ACC61C /* DownloadOverlayView.swift */; }; 8D8D8AD02C05A27800ACC61C /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AA12C05A27800ACC61C /* NotificationSettingsView.swift */; }; 8D8D8AD12C05A27800ACC61C /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AA42C05A27800ACC61C /* CourseView.swift */; }; 8D8D8AD22C05A27800ACC61C /* CoursesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AA72C05A27800ACC61C /* CoursesView.swift */; }; 8D8D8AD32C05A27800ACC61C /* UserExamData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AAA2C05A27800ACC61C /* UserExamData.swift */; }; 8D8D8AD42C05A27800ACC61C /* ExamModesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AAD2C05A27800ACC61C /* ExamModesView.swift */; }; - 8D8D8AD52C05A27800ACC61C /* ExamQuestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AAE2C05A27800ACC61C /* ExamQuestion.swift */; }; 8D8D8AD62C05A27800ACC61C /* ExamSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AAF2C05A27800ACC61C /* ExamSummaryView.swift */; }; 8D8D8AD72C05A27800ACC61C /* ExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AB02C05A27800ACC61C /* ExamView.swift */; }; 8D8D8AD82C05A27800ACC61C /* PreviousExamsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AB12C05A27800ACC61C /* PreviousExamsView.swift */; }; 8D8D8AD92C05A27800ACC61C /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AB42C05A27800ACC61C /* HomeView.swift */; }; 8D8D8ADA2C05A27800ACC61C /* IntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AB72C05A27800ACC61C /* IntroView.swift */; }; - 8D8D8ADB2C05A27800ACC61C /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AB82C05A27800ACC61C /* LoadingView.swift */; }; 8D8D8ADC2C05A27800ACC61C /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8ABB2C05A27800ACC61C /* SettingsView.swift */; }; 8D8D8ADD2C05A27800ACC61C /* UserTrainingData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8ABE2C05A27800ACC61C /* UserTrainingData.swift */; }; 8D8D8ADE2C05A27800ACC61C /* TrainingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D8D8AC02C05A27800ACC61C /* TrainingView.swift */; }; @@ -42,6 +40,7 @@ 8D8D8AF32C05A31D00ACC61C /* Icon.afpub in Resources */ = {isa = PBXBuildFile; fileRef = 8D8D8AED2C05A31C00ACC61C /* Icon.afpub */; }; 8D8D8AF42C05A31D00ACC61C /* Icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 8D8D8AEE2C05A31C00ACC61C /* Icon.png */; }; 8D8D8AF82C05A32B00ACC61C /* swift.yml in Resources */ = {isa = PBXBuildFile; fileRef = 8D8D8AF52C05A32B00ACC61C /* swift.yml */; }; + 8DABB7742C0D7D0300B40E25 /* DownloadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DABB7732C0D7D0300B40E25 /* DownloadViewModel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -69,19 +68,17 @@ 8D8D8A8F2C05A23600ACC61C /* CloudMasterUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMasterUITests.swift; sourceTree = ""; }; 8D8D8A912C05A23600ACC61C /* CloudMasterUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMasterUITestsLaunchTests.swift; sourceTree = ""; }; 8D8D8A9F2C05A27800ACC61C /* ConfirmPopup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfirmPopup.swift; sourceTree = ""; }; - 8D8D8AA02C05A27800ACC61C /* DownloadView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; + 8D8D8AA02C05A27800ACC61C /* DownloadOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadOverlayView.swift; sourceTree = ""; }; 8D8D8AA12C05A27800ACC61C /* NotificationSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; 8D8D8AA42C05A27800ACC61C /* CourseView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = ""; }; 8D8D8AA72C05A27800ACC61C /* CoursesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoursesView.swift; sourceTree = ""; }; 8D8D8AAA2C05A27800ACC61C /* UserExamData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserExamData.swift; sourceTree = ""; }; 8D8D8AAD2C05A27800ACC61C /* ExamModesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExamModesView.swift; sourceTree = ""; }; - 8D8D8AAE2C05A27800ACC61C /* ExamQuestion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExamQuestion.swift; sourceTree = ""; }; 8D8D8AAF2C05A27800ACC61C /* ExamSummaryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExamSummaryView.swift; sourceTree = ""; }; 8D8D8AB02C05A27800ACC61C /* ExamView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExamView.swift; sourceTree = ""; }; 8D8D8AB12C05A27800ACC61C /* PreviousExamsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreviousExamsView.swift; sourceTree = ""; }; 8D8D8AB42C05A27800ACC61C /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 8D8D8AB72C05A27800ACC61C /* IntroView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IntroView.swift; sourceTree = ""; }; - 8D8D8AB82C05A27800ACC61C /* LoadingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; 8D8D8ABB2C05A27800ACC61C /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 8D8D8ABE2C05A27800ACC61C /* UserTrainingData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserTrainingData.swift; sourceTree = ""; }; 8D8D8AC02C05A27800ACC61C /* TrainingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrainingView.swift; sourceTree = ""; }; @@ -100,6 +97,7 @@ 8D8D8AED2C05A31C00ACC61C /* Icon.afpub */ = {isa = PBXFileReference; lastKnownFileType = file; path = Icon.afpub; sourceTree = ""; }; 8D8D8AEE2C05A31C00ACC61C /* Icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = Icon.png; sourceTree = ""; }; 8D8D8AF52C05A32B00ACC61C /* swift.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swift.yml; sourceTree = ""; }; + 8DABB7732C0D7D0300B40E25 /* DownloadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadViewModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -184,8 +182,9 @@ 8D8D8AA22C05A27800ACC61C /* Common */ = { isa = PBXGroup; children = ( + 8DABB7722C0D7CF900B40E25 /* ViewModels */, 8D8D8A9F2C05A27800ACC61C /* ConfirmPopup.swift */, - 8D8D8AA02C05A27800ACC61C /* DownloadView.swift */, + 8D8D8AA02C05A27800ACC61C /* DownloadOverlayView.swift */, 8D8D8AA12C05A27800ACC61C /* NotificationSettingsView.swift */, ); path = Common; @@ -250,7 +249,6 @@ isa = PBXGroup; children = ( 8D8D8AAD2C05A27800ACC61C /* ExamModesView.swift */, - 8D8D8AAE2C05A27800ACC61C /* ExamQuestion.swift */, 8D8D8AAF2C05A27800ACC61C /* ExamSummaryView.swift */, 8D8D8AB02C05A27800ACC61C /* ExamView.swift */, 8D8D8AB12C05A27800ACC61C /* PreviousExamsView.swift */, @@ -288,7 +286,6 @@ isa = PBXGroup; children = ( 8D8D8AB72C05A27800ACC61C /* IntroView.swift */, - 8D8D8AB82C05A27800ACC61C /* LoadingView.swift */, ); path = Views; sourceTree = ""; @@ -411,6 +408,14 @@ path = .github; sourceTree = ""; }; + 8DABB7722C0D7CF900B40E25 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 8DABB7732C0D7D0300B40E25 /* DownloadViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -551,7 +556,6 @@ buildActionMask = 2147483647; files = ( 8D8D8ADC2C05A27800ACC61C /* SettingsView.swift in Sources */, - 8D8D8AD52C05A27800ACC61C /* ExamQuestion.swift in Sources */, 8D8D8ADE2C05A27800ACC61C /* TrainingView.swift in Sources */, 8D8D8AD02C05A27800ACC61C /* NotificationSettingsView.swift in Sources */, 8D8D8AE22C05A27800ACC61C /* DownloadUtility.swift in Sources */, @@ -560,8 +564,7 @@ 8D8D8ADA2C05A27800ACC61C /* IntroView.swift in Sources */, 8D8D8AE02C05A27800ACC61C /* CloudMaster.swift in Sources */, 8D8D8ADD2C05A27800ACC61C /* UserTrainingData.swift in Sources */, - 8D8D8ADB2C05A27800ACC61C /* LoadingView.swift in Sources */, - 8D8D8ACF2C05A27800ACC61C /* DownloadView.swift in Sources */, + 8D8D8ACF2C05A27800ACC61C /* DownloadOverlayView.swift in Sources */, 8D8D8AD32C05A27800ACC61C /* UserExamData.swift in Sources */, 8D8D8AD82C05A27800ACC61C /* PreviousExamsView.swift in Sources */, 8D8D8AE42C05A27800ACC61C /* QuestionLoader.swift in Sources */, @@ -572,6 +575,7 @@ 8D8D8AD42C05A27800ACC61C /* ExamModesView.swift in Sources */, 8D8D8AE12C05A27800ACC61C /* Courses.swift in Sources */, 8D8D8AD12C05A27800ACC61C /* CourseView.swift in Sources */, + 8DABB7742C0D7D0300B40E25 /* DownloadViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -733,10 +737,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"CloudMaster/Preview Content\""; - DEVELOPMENT_TEAM = 9D3QHQ7CMS; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "CloudMaster Swift"; @@ -749,9 +754,10 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.ditectrev.cloudmasterswift; PRODUCT_NAME = "CloudMaster Swift"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -766,10 +772,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"CloudMaster/Preview Content\""; - DEVELOPMENT_TEAM = 9D3QHQ7CMS; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "CloudMaster Swift"; @@ -782,9 +789,10 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.ditectrev.cloudmasterswift; PRODUCT_NAME = "CloudMaster Swift"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; diff --git a/CloudMaster.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/CloudMaster.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/CloudMaster.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/CloudMaster.xcodeproj/project.xcworkspace/xcuserdata/benedikt.xcuserdatad/WorkspaceSettings.xcsettings b/CloudMaster.xcodeproj/project.xcworkspace/xcuserdata/benedikt.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..bbfef02 --- /dev/null +++ b/CloudMaster.xcodeproj/project.xcworkspace/xcuserdata/benedikt.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,14 @@ + + + + + BuildLocationStyle + UseAppPreferences + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/CloudMaster.xcodeproj/xcuserdata/benedikt.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/CloudMaster.xcodeproj/xcuserdata/benedikt.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..82d1026 --- /dev/null +++ b/CloudMaster.xcodeproj/xcuserdata/benedikt.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/CloudMaster/Constants/Courses.swift b/CloudMaster/Constants/Courses.swift index eea757e..aca48c2 100644 --- a/CloudMaster/Constants/Courses.swift +++ b/CloudMaster/Constants/Courses.swift @@ -12,14 +12,16 @@ enum CodingKeys: String, CodingKey { } struct Course: Codable, Hashable, Identifiable { + let id = UUID() // Add this line let fullName: String - let company: CourseCompany + let shortName: String let description: String + let company: CourseCompany + var repositoryURL: String var questionURL: String - let shortName: String let url: String let exam: Exam - let id = UUID() // Add this line + var lastUpdate: Date? // Add this for Hashable conformance func hash(into hasher: inout Hasher) { @@ -27,18 +29,20 @@ struct Course: Codable, Hashable, Identifiable { hasher.combine(company) hasher.combine(description) hasher.combine(questionURL) + hasher.combine(repositoryURL) hasher.combine(shortName) hasher.combine(url) } // Add this for Hashable conformance static func == (lhs: Course, rhs: Course) -> Bool { - return lhs.fullName == rhs.fullName && - lhs.company == rhs.company && - lhs.description == rhs.description && - lhs.questionURL == rhs.questionURL && - lhs.shortName == rhs.shortName && - lhs.url == rhs.url + return lhs.fullName == rhs.fullName && + lhs.company == rhs.company && + lhs.description == rhs.description && + lhs.questionURL == rhs.questionURL && + lhs.repositoryURL == rhs.repositoryURL && + lhs.shortName == rhs.shortName && + lhs.url == rhs.url } @@ -53,131 +57,152 @@ struct Course: Codable, Hashable, Identifiable { let questionCount: Int } } + + extension Course { static let allCourses = [ Course( fullName: "AWS Certified Advanced Networking Specialty", - company: .aws, + shortName: "ANS-C01", description: "Validates expertise in designing and maintaining AWS network architecture, including hybrid IT, routing, and security.", + company: .aws, + repositoryURL: "https://github.com/Ditectrev/Amazon-Web-Services-Certified-AWS-Certified-Advanced-Networking-Specialty-ANS-C01-Practice-Test-Exam", questionURL: "https://raw.githubusercontent.com/Ditectrev/Amazon-Web-Services-Certified-AWS-Certified-Advanced-Networking-Specialty-ANS-C01-Practice-Test-Exam/main/README.md", - shortName: "ANS-C01", url: "https://aws.amazon.com/certification/certified-advanced-networking-specialty/", exam: Exam( quick: ExamDetail(time: 57, questionCount: 22), intermediate: ExamDetail(time: 119, questionCount: 46), real: ExamDetail(time: 170, questionCount: 65) - ) + ), + lastUpdate: nil ), Course( fullName: "AWS Certified Solutions Architect Associate", - company: .aws, + shortName: "SAA-C03", description: "Covers designing and deploying scalable, highly available, and fault-tolerant systems on AWS.", + company: .aws, + repositoryURL: "https://github.com/Ditectrev/AWS-Certified-Solutions-Architect-Associate-SAA-C03-Practice-Tests-Exams-Questions-Answers", questionURL: "https://raw.githubusercontent.com/Ditectrev/AWS-Certified-Solutions-Architect-Associate-SAA-C03-Practice-Tests-Exams-Questions-Answers/main/README.md", - shortName: "SAA-C03", url: "https://aws.amazon.com/certification/certified-solutions-architect-associate/", exam: Exam( quick: ExamDetail(time: 40, questionCount: 20), intermediate: ExamDetail(time: 84, questionCount: 49), real: ExamDetail(time: 120, questionCount: 65) - ) + ), + lastUpdate: nil ), Course( fullName: "AWS Certified Cloud Practitioner", - company: .aws, + shortName: "CLF-C02", description: "Provides a foundational understanding of AWS cloud concepts, services, security, architecture, pricing, and support.", + company: .aws, + repositoryURL: "https://github.com/Ditectrev/Amazon-Web-Services-AWS-Certified-Cloud-Practitioner-CLF-C02-Practice-Tests-Exams-Questions-Answers", questionURL: "https://raw.githubusercontent.com/Ditectrev/Amazon-Web-Services-AWS-Certified-Cloud-Practitioner-CLF-C02-Practice-Tests-Exams-Questions-Answers/main/README.md", - shortName: "CLF-C02", url: "https://aws.amazon.com/certification/certified-cloud-practitioner/", exam: Exam( quick: ExamDetail(time: 30, questionCount: 20), intermediate: ExamDetail(time: 42, questionCount: 42), real: ExamDetail(time: 90, questionCount: 65) - ) + ), + lastUpdate: nil ), Course( fullName: "Azure Fundamentals", - company: .azure, + shortName: "AZ-900", description: "Covers general cloud concepts and core Azure services, pricing, and support.", + company: .azure, + repositoryURL: "https://github.com/Ditectrev/Microsoft-Azure-AZ-900-Microsoft-Azure-Fundamentals-Practice-Tests-Exams-Questions-Answers", questionURL: "https://raw.githubusercontent.com/Ditectrev/Microsoft-Azure-AZ-900-Microsoft-Azure-Fundamentals-Practice-Tests-Exams-Questions-Answers/main/README.md", - shortName: "AZ-900", url: "https://learn.microsoft.com/en-us/certifications/azure-fundamentals/", exam: Exam( quick: ExamDetail(time: 20, questionCount: 20), intermediate: ExamDetail(time: 35, questionCount: 42), real: ExamDetail(time: 60, questionCount: 60) - ) + ), + lastUpdate: nil ), Course( fullName: "Azure Designing and Implementing Microsoft DevOps Solutions", - company: .azure, + shortName: "AZ-400", description: "Validates skills in DevOps practices, continuous integration, delivery, and infrastructure as code.", + company: .azure, + repositoryURL: "https://github.com/Ditectrev/Microsoft-Azure-AZ-400-Designing-and-Implementing-Microsoft-DevOps-Solutions-Practice-Tests-Exams-QA", questionURL: "https://raw.githubusercontent.com/Ditectrev/Microsoft-Azure-AZ-400-Designing-and-Implementing-Microsoft-DevOps-Solutions-Practice-Tests-Exams-QA/main/README.md", - shortName: "AZ-400", url: "https://learn.microsoft.com/en-us/certifications/devops-engineer/", exam: Exam( quick: ExamDetail(time: 65, questionCount: 20), intermediate: ExamDetail(time: 91, questionCount: 42), real: ExamDetail(time: 120, questionCount: 60) - ) + ), + lastUpdate: nil ), Course( fullName: "Azure Developing Solutions for Microsoft Azure", - company: .azure, + shortName: "AZ-204", description: "Covers designing, building, testing, and maintaining cloud applications on Azure.", + company: .azure, + repositoryURL: "https://github.com/Ditectrev/Microsoft-Azure-AZ-204-Developing-Solutions-for-Microsoft-Azure-Practice-Tests-Exams-Question-Answer", questionURL: "https://raw.githubusercontent.com/Ditectrev/Microsoft-Azure-AZ-204-Developing-Solutions-for-Microsoft-Azure-Practice-Tests-Exams-Question-Answer/main/README.md", - shortName: "AZ-204", url: "https://learn.microsoft.com/en-us/certifications/azure-developer/", exam: Exam( quick: ExamDetail(time: 50, questionCount: 40), intermediate: ExamDetail(time: 126, questionCount: 56), real: ExamDetail(time: 180, questionCount: 65) - ) + ), + lastUpdate: nil ), Course( fullName: "Azure Infrastructure Solutions", - company: .azure, + shortName: "AZ-305", description: "Focuses on designing cloud and hybrid solutions on Azure.", + company: .azure, + repositoryURL: "https://github.com/Ditectrev/Microsoft-Azure-AZ-305-Designing-Microsoft-Azure-Infrastructure-Solutions-Practice-Tests-Exams-QA", questionURL: "https://raw.githubusercontent.com/Ditectrev/Microsoft-Azure-AZ-305-Designing-Microsoft-Azure-Infrastructure-Solutions-Practice-Tests-Exams-QA/main/README.md", - shortName: "AZ-305", url: "https://learn.microsoft.com/en-us/certifications/azure-solutions-architect/", exam: Exam( quick: ExamDetail(time: 55, questionCount: 20), intermediate: ExamDetail(time: 98, questionCount: 42), real: ExamDetail(time: 120, questionCount: 60) - ) + ), + lastUpdate: nil ), Course( fullName: "Azure Windows Server Hybrid Administrator", - company: .azure, + shortName: "AZ-800", description: "Covers managing core Windows Server workloads using Azure services.", + company: .azure, + repositoryURL: "https://github.com/Ditectrev/Microsoft-Azure-AZ-800-Windows-Server-Hybrid-Administrator-Practice-Tests-Exams-Questions-Answers", questionURL: "https://raw.githubusercontent.com/Ditectrev/Microsoft-Azure-AZ-800-Windows-Server-Hybrid-Administrator-Practice-Tests-Exams-Questions-Answers/main/README.md", - shortName: "AZ-800", url: "https://learn.microsoft.com/en-us/certifications/windows-server-hybrid-administrator-associate/", exam: Exam( quick: ExamDetail(time: 60, questionCount: 20), intermediate: ExamDetail(time: 105, questionCount: 42), real: ExamDetail(time: 150, questionCount: 60) - ) + ), + lastUpdate: nil ), Course( fullName: "Azure Administrator", - company: .azure, + shortName: "AZ-104", description: "Validates managing cloud services covering storage, networking, and compute on Azure.", + company: .azure, + repositoryURL: "https://github.com/Ditectrev/Microsoft-Azure-AZ-104-Microsoft-Azure-Administrator-Practice-Tests-Exams-Questions-Answers", questionURL: "https://raw.githubusercontent.com/Ditectrev/Microsoft-Azure-AZ-104-Microsoft-Azure-Administrator-Practice-Tests-Exams-Questions-Answers/main/README.md", - shortName: "AZ-104", url: "https://learn.microsoft.com/en-us/certifications/azure-administrator/", exam: Exam( quick: ExamDetail(time: 60, questionCount: 20), intermediate: ExamDetail(time: 105, questionCount: 42), real: ExamDetail(time: 150, questionCount: 60) - ) + ), + lastUpdate: nil ), Course( fullName: "Azure Security Engineer", - company: .azure, + shortName: "AZ-500", description: "Covers implementing security controls, managing identity and access, and protecting data on Azure.", + company: .azure, + repositoryURL: "https://github.com/Ditectrev/Microsoft-Azure-AZ-500-Azure-Security-Engineer-Practice-Tests-Exams-Questions-Answers", questionURL: "https://raw.githubusercontent.com/Ditectrev/Microsoft-Azure-AZ-500-Azure-Security-Engineer-Practice-Tests-Exams-Questions-Answers/main/README.md", - shortName: "AZ-500", url: "https://learn.microsoft.com/en-us/certifications/azure-security-engineer/", exam: Exam( quick: ExamDetail(time: 60, questionCount: 20), @@ -187,16 +212,18 @@ extension Course { ), Course( fullName: "Associate Cloud Engineer", - company: .gcp, + shortName: "ACE", description: "Validates deploying, monitoring, and managing solutions on Google Cloud Platform.", + company: .gcp, + repositoryURL: "https://github.com/Ditectrev/Google-Cloud-Platform-GCP-Associate-Cloud-Engineer-Practice-Tests-Exams-Questions-Answers", questionURL: "https://raw.githubusercontent.com/Ditectrev/Google-Cloud-Platform-GCP-Associate-Cloud-Engineer-Practice-Tests-Exams-Questions-Answers/main/README.md", - shortName: "ACE", url: "https://cloud.google.com/certification/cloud-engineer", exam: Exam( quick: ExamDetail(time: 40, questionCount: 20), intermediate: ExamDetail(time: 84, questionCount: 42), real: ExamDetail(time: 120, questionCount: 60) - ) + ), + lastUpdate: nil ) ] } diff --git a/CloudMaster/Features/Common/DownloadView.swift b/CloudMaster/Features/Common/DownloadOverlayView.swift similarity index 58% rename from CloudMaster/Features/Common/DownloadView.swift rename to CloudMaster/Features/Common/DownloadOverlayView.swift index dda16a5..ccaf122 100644 --- a/CloudMaster/Features/Common/DownloadView.swift +++ b/CloudMaster/Features/Common/DownloadOverlayView.swift @@ -1,16 +1,8 @@ -// -// DownloadView.swift -// Cloud-Master -// -// Created by Benedikt Wagner on 24.05.24. -// - -import Foundation import SwiftUI struct DownloadOverlayView: View { @Binding var isShowing: Bool - @Binding var progress: Double + @StateObject var viewModel: DownloadViewModel var body: some View { if isShowing { @@ -18,9 +10,27 @@ struct DownloadOverlayView: View { Color.black.opacity(0.4) .edgesIgnoringSafeArea(.all) - CircularProgressView(progress: $progress) - .frame(width: 250, height: 250) - .shadow(radius: 10) + VStack { + CircularProgressView(progress: $viewModel.overallProgress) + .frame(width: 250, height: 250) + .shadow(radius: 10) + + HStack { + if viewModel.totalCourses > 1 { + Text("\(viewModel.completedDownloads) / \(Int(viewModel.totalCourses)) courses") + .font(.headline) + .foregroundColor(.white) + .padding(.top, 10) + } + } + + Text(viewModel.statusMessage) + .font(.subheadline) + .bold() + .foregroundColor(.white) + .padding(.top, 5) + + } } } } @@ -39,7 +49,7 @@ struct CircularProgressView: View { Circle() .trim(from: 0.0, to: CGFloat(min(self.progress, 1.0))) .stroke(style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)) - .foregroundColor(Color.customPrimary) + .foregroundColor(Color.customAccent) .rotationEffect(Angle(degrees: 270.0)) .animation(.linear, value: progress) @@ -59,5 +69,3 @@ struct CircularProgressView: View { .padding(40) } } - - diff --git a/CloudMaster/Features/Common/ViewModels/DownloadViewModel.swift b/CloudMaster/Features/Common/ViewModels/DownloadViewModel.swift new file mode 100644 index 0000000..4a927f1 --- /dev/null +++ b/CloudMaster/Features/Common/ViewModels/DownloadViewModel.swift @@ -0,0 +1,65 @@ +import Foundation +import Combine + +class DownloadViewModel: ObservableObject { + @Published var isDownloading: Bool = false + @Published var overallProgress: Double = 0.0 + @Published var statusMessage: String = "" + @Published var showAlert: Bool = false + @Published var downloadCompleted: Bool = false + @Published var completedDownloads: Int = 0 + var alertMessage: String = "" + var cancellables = Set() + + private var downloadProgress: [Course: Progress] = [:] + var totalCourses: Double = 0 + + func downloadCourses(_ favorites: Set) { + totalCourses = Double(favorites.count) + completedDownloads = 0 + isDownloading = true + downloadCompleted = false + + for course in favorites { + downloadCourse(course) + } + } + + func downloadCourse(_ course: Course) { + isDownloading = true + DownloadUtility.downloadAndConvertCourse(course: course, progressHandler: { [weak self] progress, status in + DispatchQueue.main.async { + self?.downloadProgress[course] = progress + self?.statusMessage = status + let totalProgress = self?.downloadProgress.values.map({ $0.fractionCompleted / 2 }).reduce(0, +) ?? 0 + self?.overallProgress = totalProgress / self!.totalCourses + } + }) { [weak self] result in + DispatchQueue.main.async { + self?.isDownloading = false + switch result { + case .success: + self?.completedDownloads += 1 + self?.overallProgress = Double(self!.completedDownloads) / self!.totalCourses + if self?.completedDownloads == Int(self!.totalCourses) { + self?.isDownloading = false + self?.downloadCompleted = true + } + case .failure(let error): + self?.alertMessage = "Failed to download \(course.shortName): \(error.localizedDescription)" + self?.showAlert = true + self?.isDownloading = false + } + } + } + } + + func cancelDownloads() { + for (course, _) in downloadProgress { + DownloadUtility.cancelDownload(for: course) + } + downloadProgress.removeAll() + statusMessage = "Download cancelled" + isDownloading = false + } +} diff --git a/CloudMaster/Features/Course/Views/CourseView.swift b/CloudMaster/Features/Course/Views/CourseView.swift index de2476b..3845cde 100644 --- a/CloudMaster/Features/Course/Views/CourseView.swift +++ b/CloudMaster/Features/Course/Views/CourseView.swift @@ -6,9 +6,16 @@ struct CourseView: View { @State private var userTrainingData = UserTrainingData() @State private var showingNotificationSettings = false @State private var notificationsEnabled = false - + @StateObject private var viewModel = DownloadViewModel() + @StateObject private var questionLoader: QuestionLoader + let course: Course + init(course: Course) { + self.course = course + _questionLoader = StateObject(wrappedValue: QuestionLoader(filename: course.shortName + ".json", intelligentLearning: false)) + } + var body: some View { VStack { VStack { @@ -31,10 +38,10 @@ struct CourseView: View { .font(.subheadline) } .padding(.top, 20) - + Spacer() VStack(spacing: 20) { - NavigationLink(destination: TrainingView(course: course, intelligentLearning: false)) { + NavigationLink(destination: TrainingView(course: course, questionLoader: questionLoader)) { VStack { Text("Training") .font(.title) @@ -47,8 +54,8 @@ struct CourseView: View { .background(LinearGradient(gradient: Gradient(colors: [Color.customAccent, Color.training]), startPoint: .leading, endPoint: .trailing)) .cornerRadius(10) } - - NavigationLink(destination: TrainingView(course: course, intelligentLearning: true)) { + + NavigationLink(destination: TrainingView(course: course, questionLoader: questionLoader)) { VStack { Text("Intelligent Training") .font(.title) @@ -61,7 +68,7 @@ struct CourseView: View { .background(LinearGradient(gradient: Gradient(colors: [Color.training, Color.exam]), startPoint: .leading, endPoint: .trailing)) .cornerRadius(10) } - + NavigationLink(destination: ExamModeView(course: course)) { VStack { Text("Exam") @@ -77,12 +84,12 @@ struct CourseView: View { } } Spacer() - + HStack(spacing: 20) { Link("Certification", destination: URL(string: course.url)!) .padding() .font(.subheadline) - + Link("Sources", destination: URL(string: course.url)!) .padding() .font(.subheadline) @@ -91,6 +98,9 @@ struct CourseView: View { .onAppear { loadUserTrainingData(for: course) checkNotificationSettings() + if questionLoader.questions.isEmpty { + downloadCourse() + } } } .navigationBarTitle(course.shortName, displayMode: .inline) @@ -102,8 +112,17 @@ struct CourseView: View { checkNotificationSettings() } } + .overlay( + DownloadOverlayView( + isShowing: $viewModel.isDownloading, + viewModel: viewModel + ) + ) + .alert(isPresented: $viewModel.showAlert) { + Alert(title: Text("Download Error"), message: Text(viewModel.alertMessage ?? ""), dismissButton: .default(Text("OK"))) + } } - + private var notificationButton: some View { Button(action: { if notificationsEnabled { @@ -116,7 +135,7 @@ struct CourseView: View { .foregroundColor(notificationsEnabled ? Color.correct : .gray) } } - + func loadUserTrainingData(for course: Course) { if let data = UserDefaults.standard.data(forKey: course.shortName) { if let decodedData = try? JSONDecoder().decode(UserTrainingData.self, from: data) { @@ -124,21 +143,42 @@ struct CourseView: View { } } } - + func formatTimeSpent(_ time: TimeInterval) -> String { let hours = Int(time) / 3600 let minutes = (Int(time) % 3600) / 60 return "\(hours)h \(minutes)m" } - + func checkNotificationSettings() { let frequency = UserDefaults.standard.integer(forKey: "\(course.shortName)_notificationFrequency") notificationsEnabled = frequency > 0 } - + func disableNotifications(for course: Course) { UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [course.shortName]) UserDefaults.standard.removeObject(forKey: "\(course.shortName)_notificationFrequency") notificationsEnabled = false } + + func downloadCourse() { + viewModel.downloadCourse(course) + viewModel.$isDownloading.sink { isDownloading in + if !isDownloading { + DispatchQueue.main.async { + questionLoader.reloadQuestions(from: course.shortName + ".json") + } + } + } + .store(in: &viewModel.cancellables) + + viewModel.$showAlert.sink { showAlert in + if showAlert { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { // Adjust the delay as needed + // Handle dismissal if needed + } + } + } + .store(in: &viewModel.cancellables) + } } diff --git a/CloudMaster/Features/Courses/Views/CoursesView.swift b/CloudMaster/Features/Courses/Views/CoursesView.swift index e897863..9892e0e 100644 --- a/CloudMaster/Features/Courses/Views/CoursesView.swift +++ b/CloudMaster/Features/Courses/Views/CoursesView.swift @@ -3,75 +3,45 @@ import SwiftUI struct CoursesView: View { @Binding var favorites: Set @State private var searchText = "" - @State private var downloadProgress: [Course: Progress] = [:] - @State private var isDownloading = false - @State private var overallProgress = 0.0 + @StateObject private var viewModel = DownloadViewModel() @State private var alertMessage: String? - @State private var showAlert = false var body: some View { - VStack { - SearchBar(text: $searchText) - .padding() + VStack { + SearchBar(text: $searchText) + .padding() - List(Course.allCourses.filter({ searchText.isEmpty ? true : $0.fullName.lowercased().contains(searchText.lowercased()) })) { course in - CourseRow(course: course, isBookmarked: favorites.contains(course)) { - if favorites.contains(course) { - favorites.remove(course) - } else { - favorites.insert(course) - } - FavoritesStorage.shared.saveFavorites(favorites) + List(Course.allCourses.filter({ searchText.isEmpty ? true : $0.fullName.lowercased().contains(searchText.lowercased()) })) { course in + CourseRow(course: course, isBookmarked: favorites.contains(course)) { + if favorites.contains(course) { + favorites.remove(course) + } else { + favorites.insert(course) } + FavoritesStorage.shared.saveFavorites(favorites) } - .listStyle(PlainListStyle()) - } - .navigationBarTitle("All Courses", displayMode: .inline) - .navigationBarItems(trailing: updateButton) - .overlay(DownloadOverlayView(isShowing: $isDownloading, progress: $overallProgress)) - .alert(isPresented: $showAlert) { - Alert(title: Text("Download Error"), message: Text(alertMessage ?? ""), dismissButton: .default(Text("OK"))) } + .listStyle(PlainListStyle()) + } + .navigationBarTitle("All Courses", displayMode: .inline) + .navigationBarItems(trailing: updateButton) + .overlay( + DownloadOverlayView( + isShowing: $viewModel.isDownloading, + viewModel: viewModel + ) + ) + .alert(isPresented: $viewModel.showAlert) { + Alert(title: Text("Download Error"), message: Text(viewModel.alertMessage ?? ""), dismissButton: .default(Text("OK"))) + } } -} -extension CoursesView { private var updateButton: some View { Button(action: { - updateCourses() + viewModel.downloadCourses(favorites) }) { Image(systemName: "arrow.down.circle") } } - - private func updateCourses() { - isDownloading = true - let total = Double(favorites.count) - var completedDownloads = 0.0 - - for course in favorites { - DownloadUtility.downloadAndConvertCourse(course: course, progressHandler: { progress in - DispatchQueue.main.async { - downloadProgress[course] = progress - let totalProgress = downloadProgress.values.map({ $0.fractionCompleted }).reduce(0, +) - overallProgress = totalProgress / total - } - }) { result in - DispatchQueue.main.async { - switch result { - case .success: - completedDownloads += 1 - overallProgress = completedDownloads / total - if completedDownloads == total { - isDownloading = false - } - case .failure(let error): - alertMessage = "Failed to download \(course.shortName): \(error.localizedDescription)" - showAlert = true - isDownloading = false - } - } - } - } - } } + diff --git a/CloudMaster/Features/Exam/Views/ExamQuestion.swift b/CloudMaster/Features/Exam/Views/ExamQuestion.swift deleted file mode 100644 index 50ff8a0..0000000 --- a/CloudMaster/Features/Exam/Views/ExamQuestion.swift +++ /dev/null @@ -1,2 +0,0 @@ -import SwiftUI - diff --git a/CloudMaster/Features/Exam/Views/ExamView.swift b/CloudMaster/Features/Exam/Views/ExamView.swift index d57d10c..0c17c8b 100644 --- a/CloudMaster/Features/Exam/Views/ExamView.swift +++ b/CloudMaster/Features/Exam/Views/ExamView.swift @@ -25,82 +25,83 @@ struct ExamView: View { } var body: some View { - VStack { - if !questionLoader.questions.isEmpty { - let questions = Array(questionLoader.questions.prefix(questionCount)) - if currentQuestionIndex < questions.count { + VStack { + if !questionLoader.questions.isEmpty { + let questions = Array(questionLoader.questions.prefix(questionCount)) + if currentQuestionIndex < questions.count { + HStack { + Spacer() HStack { Spacer() - HStack { - Spacer() - Text("\(currentQuestionIndex + 1) of \(questionCount)") - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - } + Text("\(currentQuestionIndex + 1) of \(questionCount)") + .font(.subheadline) + .foregroundColor(.secondary) Spacer() } - .padding(.horizontal) + Spacer() + } + .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) - } else { - selectedChoices[questions[currentQuestionIndex].id, default: []].insert(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] = [choiceId] + selectedChoices[questions[currentQuestionIndex].id, default: []].insert(choiceId) } - } - ) - - Button(action: { - if currentQuestionIndex < questions.count - 1 { - currentQuestionIndex += 1 } else { - storeExamData(questions: questions) - showSummary = true + selectedChoices[questions[currentQuestionIndex].id] = [choiceId] } - }) { - Text(currentQuestionIndex < questions.count - 1 ? "Next Question" : "Show Exam Result") - .padding() - .background(Color.customSecondary) - .foregroundColor(.white) - .cornerRadius(10) } - .padding() + ) + + 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() + .frame(maxWidth: .infinity) + .background(Color.customSecondary) + .foregroundColor(.white) + .cornerRadius(10) + } + .padding(.horizontal, 20) - Spacer() + Spacer() - HStack { - Image(systemName: "timer") - Text(timeFormatted(timeRemaining)) - .font(.headline) - } - .padding() - } else { - Text("Loading questions...") + HStack { + Image(systemName: "timer") + Text(timeFormatted(timeRemaining)) + .font(.headline) } + .padding() } else { Text("Loading questions...") } + } else { + Text("Loading questions...") } - .onAppear(perform: startTimer) - .onDisappear { - if !showSummary { - endExamIfNeeded() - } + } + .onAppear(perform: startTimer) + .onDisappear { + if !showSummary { + endExamIfNeeded() } - .sheet(isPresented: $showSummary) { - if let examData = lastExamData { - ExamSummaryView(exam: examData) - } + } + .sheet(isPresented: $showSummary) { + if let examData = lastExamData { + ExamSummaryView(exam: examData) } + } } func startTimer() { @@ -179,17 +180,29 @@ struct ExamQuestion: View { .lineLimit(nil) // Allow text to wrap as needed .fixedSize(horizontal: false, vertical: true) .padding(.horizontal) + .frame(alignment: .leading) + .multilineTextAlignment(.center) + + 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) // Center the text - .opacity(0.7) // Set opacity to 70% + .multilineTextAlignment(.center) + .opacity(0.7) .padding(.vertical, 5) - .frame(minWidth: 0, maxWidth: .infinity) // Use full width + .frame(minWidth: 0, maxWidth: .infinity) } - .background(Color.gray.opacity(0.2)) // Light gray background to highlight the section + .background(Color.gray.opacity(0.2)) .cornerRadius(10) .padding(.horizontal) } @@ -202,6 +215,13 @@ struct ExamQuestion: View { } } + 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 { _ = UIScreen.main.bounds.width - 32 // Adjust for desired padding let fontSize = max(min(text.count / 80, 24), 14) // Simplified dynamic font sizing @@ -215,15 +235,19 @@ struct ExamChoice: View { let onChoiceSelected: (UUID) -> Void var body: some View { - Text(choice.text) - .padding() - .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) - .background(isSelected ? Color.blue.opacity(0.3) : Color.clear) - .cornerRadius(10) - .onTapGesture { - onChoiceSelected(choice.id) - } - .multilineTextAlignment(.center) + 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/Intro/Views/IntroView.swift b/CloudMaster/Features/Intro/Views/IntroView.swift index 1fef575..b110b2c 100644 --- a/CloudMaster/Features/Intro/Views/IntroView.swift +++ b/CloudMaster/Features/Intro/Views/IntroView.swift @@ -3,8 +3,9 @@ import SwiftUI struct IntroView: View { @State private var currentPage = 0 @State private var searchText = "" - @State private var showLoadingView = false - + @State private var showDownloadOverlayView = false + @StateObject private var viewModel = DownloadViewModel() + @Binding var favorites: Set @Binding var isAppConfigured: Bool @@ -18,14 +19,25 @@ struct IntroView: View { } } } else { - SecondPage(searchText: $searchText, favorites: $favorites, showLoadingView: $showLoadingView, isAppConfigured: $isAppConfigured) + SecondPage(searchText: $searchText, favorites: $favorites, showDownloadOverlayView: $showDownloadOverlayView, isAppConfigured: $isAppConfigured) } } .navigationBarHidden(true) } .navigationViewStyle(StackNavigationViewStyle()) - .fullScreenCover(isPresented: $showLoadingView) { - LoadingView(favorites: $favorites, isSetupComplete: $isAppConfigured) + .fullScreenCover(isPresented: $showDownloadOverlayView) { + DownloadOverlayView( + isShowing: $showDownloadOverlayView, + viewModel: viewModel + ) + .onAppear { + viewModel.downloadCourses(favorites) + } + } + .onChange(of: viewModel.downloadCompleted) { completed in + if completed { + isAppConfigured = true + } } } } @@ -33,13 +45,11 @@ struct IntroView: View { struct FirstPage: View { let onNext: () -> Void @Environment(\.colorScheme) var colorScheme - - var body: some View { let isIpad = UIDevice.current.userInterfaceIdiom == .pad let imageSize: CGFloat = isIpad ? 300 : 200 - + VStack { Text("Welcome to CloudMaster") .font(.largeTitle) @@ -48,19 +58,19 @@ struct FirstPage: View { .transition(.opacity) .frame(alignment: .leading) .multilineTextAlignment(.center) - + Text("Improve your knowledge and get ready for exams") .font(.subheadline) .bold() .padding(.bottom, 20) - + Spacer() - + Image(colorScheme == .dark ? "CM_white" : "CM_black") .resizable() .scaledToFit() .frame(width: imageSize, height: imageSize) - + Spacer() Button(action: onNext) { @@ -75,11 +85,10 @@ struct FirstPage: View { } } - struct SecondPage: View { @Binding var searchText: String @Binding var favorites: Set - @Binding var showLoadingView: Bool + @Binding var showDownloadOverlayView: Bool @Binding var isAppConfigured: Bool var body: some View { @@ -92,7 +101,6 @@ struct SecondPage: View { Text("Select your courses to study") .font(.subheadline) .bold() - SearchBar(text: $searchText) .padding() @@ -111,7 +119,7 @@ struct SecondPage: View { Spacer() Button("Finish Setup") { - showLoadingView = true + showDownloadOverlayView = true } .foregroundColor(.white) .padding() diff --git a/CloudMaster/Features/Intro/Views/LoadingView.swift b/CloudMaster/Features/Intro/Views/LoadingView.swift deleted file mode 100644 index ba951a3..0000000 --- a/CloudMaster/Features/Intro/Views/LoadingView.swift +++ /dev/null @@ -1,41 +0,0 @@ -import SwiftUI - -struct LoadingView: View { - @Binding var favorites: Set - @Binding var isSetupComplete: Bool - @State private var overallProgress = 0.0 - @State private var completedDownloads = 0 - - var body: some View { - VStack { - Text("Downloading Courses...") - .font(.headline) - .padding() - ProgressView(value: overallProgress, total: 1.0) - .progressViewStyle(LinearProgressViewStyle()) - .padding() - Spacer() - } - .onAppear(perform: downloadCourses) - } - - private func downloadCourses() { - let total = Double(favorites.count) - for course in favorites { - DownloadUtility.downloadAndConvertCourse(course: course, progressHandler: { progress in - progress.completedUnitCount = 1 - DispatchQueue.main.async { - self.overallProgress = (Double(completedDownloads) + progress.fractionCompleted) / total - } - }) {_ in - DispatchQueue.main.async { - completedDownloads += 1 - self.overallProgress = Double(completedDownloads) / total - if completedDownloads == favorites.count { - self.isSetupComplete = true // Transition to HomeView - } - } - } - } - } -} diff --git a/CloudMaster/Features/Training/Views/TrainingView.swift b/CloudMaster/Features/Training/Views/TrainingView.swift index f70512d..8b2720f 100644 --- a/CloudMaster/Features/Training/Views/TrainingView.swift +++ b/CloudMaster/Features/Training/Views/TrainingView.swift @@ -6,19 +6,14 @@ struct TrainingView: View { @State private var showResult = false @State private var userTrainingData = UserTrainingStore.shared.trainingData @State private var startTime: Date? - @State private var isDownloading = false - @State private var overallProgress = 0.0 - @State private var alertMessage: String? - @State private var showAlert = false @Environment(\.presentationMode) var presentationMode - let course: Course @ObservedObject var questionLoader: QuestionLoader - init(course: Course, intelligentLearning: Bool) { + init(course: Course, questionLoader: QuestionLoader) { self.course = course - self._questionLoader = ObservedObject(wrappedValue: QuestionLoader(filename: course.shortName + ".json", intelligentLearning: intelligentLearning)) + self._questionLoader = ObservedObject(wrappedValue: questionLoader) loadUserTrainingData(for: course) } @@ -52,17 +47,36 @@ struct TrainingView: View { HStack(spacing: 20) { if !showResult { - Button(action: { - showResult = true - updateUserTrainingData(for: question) - }) { - Text("Show Result") - .padding(10) - .background(Color.customPrimary) - .foregroundColor(.white) - .cornerRadius(10) - } - } else { + 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() @@ -71,6 +85,7 @@ struct TrainingView: View { }) { Text("Next Question") .padding(10) + .frame(maxWidth: .infinity) .background(Color.customSecondary) .foregroundColor(.white) .cornerRadius(10) @@ -79,10 +94,7 @@ struct TrainingView: View { } .padding(.top) } else { - Text("Loading") - .onAppear { - downloadCourse() - } + Text("No Questions available! Please download course") } Spacer() @@ -94,10 +106,6 @@ struct TrainingView: View { .onDisappear { saveUserTrainingData() } - .overlay(DownloadOverlayView(isShowing: $isDownloading, progress: $overallProgress)) - .alert(isPresented: $showAlert) { - Alert(title: Text("Download Error"), message: Text(alertMessage ?? ""), dismissButton: .default(Text("OK"))) - } } func handleChoiceSelection(_ choiceID: UUID, _ question: Question) { @@ -144,29 +152,6 @@ struct TrainingView: View { UserDefaults.standard.set(data, forKey: course.shortName) } } - - func downloadCourse() { - isDownloading = true - DownloadUtility.downloadAndConvertCourse(course: course, progressHandler: { progress in - DispatchQueue.main.async { - overallProgress = progress.fractionCompleted - } - }) { result in - DispatchQueue.main.async { - isDownloading = false - switch result { - case .success: - questionLoader.reloadQuestions(from: course.shortName + ".json") - case .failure(let error): - alertMessage = "Failed to download \(course.shortName): \(error.localizedDescription)" - showAlert = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { // Adjust the delay as needed - presentationMode.wrappedValue.dismiss() - } - } - } - } - } } struct TrainingQuestion: View { @@ -186,6 +171,15 @@ struct TrainingQuestion: View { .fixedSize(horizontal: false, vertical: true) .padding(.horizontal) + 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)") @@ -212,7 +206,15 @@ struct TrainingQuestion: View { .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 { _ = UIScreen.main.bounds.width - 32 let fontSize = max(min(text.count / 80, 24), 14) @@ -227,16 +229,19 @@ struct TrainingChoice: View { let onChoiceSelected: (UUID) -> Void var body: some View { - Text(choice.text) - .padding() - .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) - .background(getChoiceBackgroundColor()) - .foregroundColor(getChoiceTextColor()) - .cornerRadius(10) - .onTapGesture { - onChoiceSelected(choice.id) - } - .multilineTextAlignment(.center) + 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() } diff --git a/CloudMaster/Utilities/DownloadUtility.swift b/CloudMaster/Utilities/DownloadUtility.swift index 429154f..73fad1d 100644 --- a/CloudMaster/Utilities/DownloadUtility.swift +++ b/CloudMaster/Utilities/DownloadUtility.swift @@ -1,7 +1,10 @@ import Foundation class DownloadUtility { - static func downloadAndConvertCourse(course: Course, progressHandler: @escaping (Progress) -> Void, completion: @escaping (Result) -> Void) { + private static var downloadTasks: [String: URLSessionDownloadTask] = [:] + private static let downloadQueue = DispatchQueue(label: "com.example.downloadQueue") + + static func downloadAndConvertCourse(course: Course, progressHandler: @escaping (Progress, String) -> Void, completion: @escaping (Result) -> Void) { guard let url = URL(string: course.questionURL) else { completion(.failure(NSError(domain: "Invalid URL", code: 1, userInfo: nil))) return @@ -11,6 +14,9 @@ class DownloadUtility { 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)) @@ -25,12 +31,32 @@ class DownloadUtility { return } do { - if FileManager.default.fileExists(atPath: destinationURL.path) { - try FileManager.default.removeItem(at: destinationURL) - } + try FileManager.default.removeItemIfExists(at: destinationURL) try FileManager.default.moveItem(at: tempURL, to: destinationURL) - try convertMarkdownToJSON(fileURL: destinationURL, shortName: course.shortName) + + 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 + var progress = Progress(totalUnitCount: Int64(totalTasks)) + progress.completedUnitCount = 1 + progressHandler(progress, "Questions for \(course.shortName)") + + questions = try downloadImages(for: questions, course: course) { completedImages in + progress.completedUnitCount = Int64(completedImages + 1) + progressHandler(progress, "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(())) } } catch { @@ -39,80 +65,72 @@ class DownloadUtility { } } } + + downloadQueue.async { + downloadTasks[course.shortName] = task + } - progressHandler(task.progress) task.resume() } - static func convertMarkdownToJSON(fileURL: URL, shortName: String) throws { - do { - let fileContents = try String(contentsOf: fileURL, encoding: .utf8) - let jsonData = try parseMarkdown(markdown: fileContents) - let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - let jsonFileURL = documentsURL.appendingPathComponent("\(shortName).json") - if FileManager.default.fileExists(atPath: jsonFileURL.path) { - try FileManager.default.removeItem(at: jsonFileURL) + static func cancelDownload(for course: Course) { + downloadQueue.async { + if let task = downloadTasks[course.shortName] { + task.cancel() + downloadTasks[course.shortName] = nil } - try jsonData.write(to: jsonFileURL) - } catch { - print("Error converting file: \(error)") - throw error } } - - static func parseMarkdown(markdown: String) throws -> Data { + + 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? - let questionPattern = try? NSRegularExpression(pattern: "### (.+)") - let choicePattern = try? NSRegularExpression(pattern: "- \\[([ x])\\] (.+)") + 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 && !choices.isEmpty { + if let questionMatch = questionPattern.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { + if !currentQuestion.isEmpty { currentQuestion["choices"] = choices if correctCount > 1 { currentQuestion["multiple_response"] = true currentQuestion["response_count"] = correctCount } + currentQuestion["imagePath"] = currentImagePath questions.append(currentQuestion) currentQuestion = [:] choices = [] correctCount = 0 + currentImagePath = nil } let question = String(line[Range(questionMatch.range(at: 1), in: line)!]) currentQuestion["question"] = question - } else if let choiceMatch = choicePattern?.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { + } else if let choiceMatch = choicePattern.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { let isCorrect = line[Range(choiceMatch.range(at: 1), in: line)!] == "x" if isCorrect { correctCount += 1 } let choice = String(line[Range(choiceMatch.range(at: 2), in: line)!]) choices.append(["id": UUID().uuidString, "text": choice, "correct": isCorrect]) - } else if line.trimmingCharacters(in: .whitespacesAndNewlines) == "**[⬆ Back to Top](#table-of-contents)**" { - if !currentQuestion.isEmpty && !choices.isEmpty { - currentQuestion["choices"] = choices - if correctCount > 1 { - currentQuestion["multiple_response"] = true - currentQuestion["response_count"] = correctCount - } - questions.append(currentQuestion) - currentQuestion = [:] - choices = [] - correctCount = 0 - } + } 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 !currentQuestion.isEmpty && !choices.isEmpty { + if !currentQuestion.isEmpty { currentQuestion["choices"] = choices if correctCount > 1 { currentQuestion["multiple_response"] = true currentQuestion["response_count"] = correctCount } + currentQuestion["imagePath"] = currentImagePath questions.append(currentQuestion) } @@ -120,7 +138,49 @@ class DownloadUtility { throw NSError(domain: "com.example.error", code: 2, userInfo: [NSLocalizedDescriptionKey: "Course has no questions, please contact developer"]) } - let jsonData = try JSONSerialization.data(withJSONObject: questions, options: .prettyPrinted) - return jsonData + return questions + } + + static func downloadImages(for questions: [[String: Any]], course: Course, progressHandler: @escaping (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 + + 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) + } + } + } + + return updatedQuestions + } +} + +extension FileManager { + func removeItemIfExists(at url: URL) throws { + if fileExists(atPath: url.path) { + try removeItem(at: url) + } } } diff --git a/CloudMaster/Utilities/QuestionLoader.swift b/CloudMaster/Utilities/QuestionLoader.swift index ee1a9fa..27ef325 100644 --- a/CloudMaster/Utilities/QuestionLoader.swift +++ b/CloudMaster/Utilities/QuestionLoader.swift @@ -4,12 +4,12 @@ struct Question: Identifiable, Codable { let id = UUID() let question: String let choices: [Choice] - var multipleResponse: Bool var responseCount: Int + let imagePath: String? enum CodingKeys: String, CodingKey { - case question, choices, multipleResponse = "multiple_response", responseCount = "response_count" + case question, choices, multipleResponse = "multiple_response", responseCount = "response_count", imagePath = "imagePath" } init(from decoder: Decoder) throws { @@ -18,6 +18,7 @@ struct Question: Identifiable, Codable { 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) } } @@ -95,4 +96,3 @@ class QuestionLoader: ObservableObject { loadQuestions(from: filename) } } -