diff --git a/.gitignore b/.gitignore index 36e2b2501..582c86cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore ## User settings -xcuserdata/* +xcuserdata/ +*.xcuserdata/* /OpenEdX.xcodeproj/xcuserdata/ /OpenEdX.xcworkspace/xcuserdata/ /OpenEdX.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -25,6 +26,15 @@ DerivedData/ *.perspectivev3 !default.perspectivev3 +*.xcodeproj/* +**/xcuserdata/ +**/*.xcuserdata/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings + ## Obj-C/Swift specific *.hmap @@ -100,4 +110,4 @@ iOSInjectionProject/ xcode-frameworks vendor/ -.bundle/ \ No newline at end of file +.bundle/ diff --git a/OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Core/Core.xcodeproj.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 72% rename from OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to Core/Core.xcodeproj.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist index 0c67376eb..18d981003 100644 --- a/OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ b/Core/Core.xcodeproj.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -1,5 +1,8 @@ - + + IDEDidComputeMac32BitWarning + + diff --git a/Core/Core.xcodeproj.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Core/Core.xcodeproj.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..7dde19a07 --- /dev/null +++ b/Core/Core.xcodeproj.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "youtubeplayerkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SvenTiigi/YouTubePlayerKit", + "state" : { + "revision" : "1fe4c8b07a61d50c2fd276e1d9c8087583c7638a", + "version" : "1.5.3" + } + } + ], + "version" : 2 +} diff --git a/Core/Core.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Core/Core.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Core/Core.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..7dde19a07 --- /dev/null +++ b/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "youtubeplayerkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SvenTiigi/YouTubePlayerKit", + "state" : { + "revision" : "1fe4c8b07a61d50c2fd276e1d9c8087583c7638a", + "version" : "1.5.3" + } + } + ], + "version" : 2 +} diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index 059b54cc2..362a3dc33 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -69,6 +69,7 @@ public enum DateStringStyle { case monthYear case lastPost case iso8601 + case shortWeekdayMonthDayYear } public extension Date { @@ -102,6 +103,8 @@ public extension Date { dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmDdYyyy case .iso8601: dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + case .shortWeekdayMonthDayYear: + dateFormatter.dateFormat = "EEE, MMM d, yyyy" } let date = dateFormatter.string(from: self) @@ -130,6 +133,8 @@ public extension Date { } case .iso8601: return date + case .shortWeekdayMonthDayYear: + return date } } } diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index 0197b0494..41a0fcb7d 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -54,6 +54,20 @@ public enum CoreLocalization { /// Section “ public static let section = CoreLocalization.tr("Localizable", "COURSEWARE.SECTION", fallback: "Section “") } + public enum CourseDates { + /// Completed + public static let completed = CoreLocalization.tr("Localizable", "COURSE_DATES.COMPLETED", fallback: "Completed") + /// Due next + public static let dueNext = CoreLocalization.tr("Localizable", "COURSE_DATES.DUE_NEXT", fallback: "Due next") + /// Past due + public static let pastDue = CoreLocalization.tr("Localizable", "COURSE_DATES.PAST_DUE", fallback: "Past due") + /// Today + public static let today = CoreLocalization.tr("Localizable", "COURSE_DATES.TODAY", fallback: "Today") + /// Unreleased + public static let unreleased = CoreLocalization.tr("Localizable", "COURSE_DATES.UNRELEASED", fallback: "Unreleased") + /// Verified Only + public static let verifiedOnly = CoreLocalization.tr("Localizable", "COURSE_DATES.VERIFIED_ONLY", fallback: "Verified Only") + } public enum Date { /// Ended public static let ended = CoreLocalization.tr("Localizable", "DATE.ENDED", fallback: "Ended") diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index f16f781dc..6c055ab25 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -68,3 +68,10 @@ "WEBVIEW.ALERT.OK" = "Ok"; "WEBVIEW.ALERT.CANCEL" = "Cancel"; + +"COURSE_DATES.TODAY" = "Today"; +"COURSE_DATES.COMPLETED" = "Completed"; +"COURSE_DATES.PAST_DUE" = "Past due"; +"COURSE_DATES.DUE_NEXT" = "Due next"; +"COURSE_DATES.UNRELEASED" = "Unreleased"; +"COURSE_DATES.VERIFIED_ONLY" = "Verified Only"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index e06937311..40ff490a3 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -68,3 +68,10 @@ "WEBVIEW.ALERT.OK" = "Так"; "WEBVIEW.ALERT.CANCEL" = "Скасувати"; + +"COURSE_DATES.TODAY" = "Today"; +"COURSE_DATES.COMPLETED" = "Completed"; +"COURSE_DATES.PAST_DUE" = "Past due"; +"COURSE_DATES.DUE_NEXT" = "Due next"; +"COURSE_DATES.UNRELEASED" = "Unreleased"; +"COURSE_DATES.VERIFIED_ONLY" = "Verified Only"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index c80d63032..16f57b0fb 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -64,6 +64,11 @@ 0766DFD0299AB29000EBEF6A /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0766DFCF299AB29000EBEF6A /* PlayerViewController.swift */; }; 197FB8EA8F92F00A8F383D82 /* Pods_App_Course.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E795BD160CDA7D9C120DE6 /* Pods_App_Course.framework */; }; B8F50317B6B830A0E520C954 /* Pods_App_Course_CourseTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50E59D2B81E12610964282C5 /* Pods_App_Course_CourseTests.framework */; }; + DB205BFB2AE81B1200136EC2 /* CourseDateViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */; }; + DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */; }; + DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */; }; + DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */; }; + DB7D6EB22ADFE9510036BB13 /* Data_CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -150,6 +155,11 @@ A47C63D9EB0D866F303D4588 /* Pods-App-Course.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Course/Pods-App-Course.releasestage.xcconfig"; sourceTree = ""; }; ADC2A1B8183A674705F5F7E2 /* Pods-App-Course.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course.debug.xcconfig"; path = "Target Support Files/Pods-App-Course/Pods-App-Course.debug.xcconfig"; sourceTree = ""; }; B196A14555D0E006995A5683 /* Pods-App-CourseDetails.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-CourseDetails.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-CourseDetails/Pods-App-CourseDetails.releaseprod.xcconfig"; sourceTree = ""; }; + DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDateViewModelTests.swift; sourceTree = ""; }; + DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseDatesView.swift; sourceTree = ""; }; + DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDatesViewModel.swift; sourceTree = ""; }; + DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDates.swift; sourceTree = ""; }; + DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDates.swift; sourceTree = ""; }; DBE05972CB5115D4535C6B8A /* Pods-App-Course-CourseTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course-CourseTests.debug.xcconfig"; path = "Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests.debug.xcconfig"; sourceTree = ""; }; E5E795BD160CDA7D9C120DE6 /* Pods_App_Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E6BDAE887ED8A46860B3F6D3 /* Pods-App-Course-CourseTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course-CourseTests.release.xcconfig"; path = "Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests.release.xcconfig"; sourceTree = ""; }; @@ -291,6 +301,7 @@ 027020FB28E7362100F54332 /* Data_CourseOutlineResponse.swift */, 022C64DB29ACFDEE000F532B /* Data_HandoutsResponse.swift */, 022C64DF29ADEA9B000F532B /* Data_UpdatesResponse.swift */, + DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */, ); path = Model; sourceTree = ""; @@ -319,6 +330,7 @@ 02EAE2CA28E1F0A700529644 /* Presentation */ = { isa = PBXGroup; children = ( + DB7D6EAA2ADFCAA00036BB13 /* Dates */, 070019A828F6F33600D5FC78 /* Container */, 070019A628F6F2CB00D5FC78 /* Details */, 070019A728F6F2D600D5FC78 /* Outline */, @@ -337,6 +349,7 @@ 02B6B3C228E1DCD100232911 /* CourseDetails.swift */, 0276D75C29DDA3F80004CDF8 /* ResumeBlock.swift */, 022C64E129ADEB83000F532B /* CourseUpdate.swift */, + DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */, ); path = Model; sourceTree = ""; @@ -428,6 +441,7 @@ children = ( 0262148E29AE17C4008BD75A /* HandoutsViewModelTests.swift */, 0295B1D8297E6DF8003B0C65 /* CourseUnitViewModelTests.swift */, + DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */, ); path = Unit; sourceTree = ""; @@ -462,6 +476,15 @@ path = ../Pods; sourceTree = ""; }; + DB7D6EAA2ADFCAA00036BB13 /* Dates */ = { + isa = PBXGroup; + children = ( + DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */, + DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */, + ); + path = Dates; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -664,6 +687,7 @@ 02F78AEB29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift in Sources */, 023812E7297AC8EB0087098F /* CourseDetailsViewModelTests.swift in Sources */, 023812F3297AC9ED0087098F /* CourseMock.generated.swift in Sources */, + DB205BFB2AE81B1200136EC2 /* CourseDateViewModelTests.swift in Sources */, 022EA8CB297AD63B0014A8F7 /* CourseContainerViewModelTests.swift in Sources */, 0262148F29AE17C4008BD75A /* HandoutsViewModelTests.swift in Sources */, ); @@ -685,6 +709,7 @@ 02B6B3BC28E1D14F00232911 /* CourseRepository.swift in Sources */, 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */, 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */, + DB7D6EB22ADFE9510036BB13 /* Data_CourseDates.swift in Sources */, 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */, 0766DFCE299AB26D00EBEF6A /* EncodedVideoPlayer.swift in Sources */, 0276D75B29DDA3890004CDF8 /* Data_ResumeBlock.swift in Sources */, @@ -700,8 +725,10 @@ 02A8076829474831007F53AB /* CourseVerticalView.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, + DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */, 02E685C028E4B629000AE015 /* CourseDetailsViewModel.swift in Sources */, 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */, + DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */, 02F066E829DC71750073E13B /* SubtittlesView.swift in Sources */, 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */, 02454CA62A26196C0043052A /* UnknownView.swift in Sources */, @@ -711,6 +738,7 @@ 02454CA82A2619890043052A /* DiscussionView.swift in Sources */, 0265B4B728E2141D00E6EAFD /* Strings.swift in Sources */, 02B6B3C128E1DBA100232911 /* Data_CourseDetailsResponse.swift in Sources */, + DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */, 0766DFCC299AA7A600EBEF6A /* YouTubeVideoPlayer.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02E685BE28E4B60A000AE015 /* CourseDetailsView.swift in Sources */, diff --git a/Course/Course.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Course/Course.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Course/Course.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Course/Course.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Course/Course.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Course/Course.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 68e833255..80ec3c3ef 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -19,6 +19,8 @@ public protocol CourseRepositoryProtocol { func getUpdates(courseID: String) async throws -> [CourseUpdate] func resumeBlock(courseID: String) async throws -> ResumeBlock func getSubtitles(url: String, selectedLanguage: String) async throws -> String + func getCourseDates(courseID: String) async throws -> CourseDates + func getCourseDatesOffline(courseID: String) async throws -> CourseDates } public class CourseRepository: CourseRepositoryProtocol { @@ -112,6 +114,18 @@ public class CourseRepository: CourseRepositoryProtocol { } } + public func getCourseDates(courseID: String) async throws -> CourseDates { + let courseDates = try await api.requestData( + CourseEndpoint.getCourseDates(courseID: courseID) + ).mapResponse(DataLayer.CourseDates.self).domain + persistence.saveCourseDates(courseID: courseID, courseDates: courseDates) + return courseDates + } + + public func getCourseDatesOffline(courseID: String) async throws -> CourseDates { + return try persistence.loadCourseDates(courseID: courseID) + } + private func parseCourseStructure(course: DataLayer.CourseStructure) -> CourseStructure { let blocks = Array(course.dict.values) let courseBlock = blocks.first(where: {$0.type == BlockType.course.rawValue })! @@ -220,6 +234,10 @@ public class CourseRepository: CourseRepositoryProtocol { // swiftlint:disable all #if DEBUG class CourseRepositoryMock: CourseRepositoryProtocol { + func getCourseDatesOffline(courseID: String) async throws -> CourseDates { + throw NoCachedDataError() + } + func resumeBlock(courseID: String) async throws -> ResumeBlock { ResumeBlock(blockID: "123") } @@ -232,6 +250,14 @@ class CourseRepositoryMock: CourseRepositoryProtocol { return [CourseUpdate(id: 1, date: "Date", content: "content", status: "status")] } + func getCourseDates(courseID: String) async throws -> CourseDates { + do { + let courseDates = try courseDatesJSON.data(using: .utf8)!.mapResponse(DataLayer.CourseDates.self) + return courseDates.domain + } catch { + throw error + } + } func getCourseDetailsOffline(courseID: String) async throws -> CourseDetails { return CourseDetails( @@ -1034,6 +1060,373 @@ And there are various ways of describing it-- call it oral poetry or "is_self_paced": false } """ + + private let courseDatesJSON: String = """ + { + "dates_banner_info": { + "missed_deadlines": false, + "content_type_gating_enabled": true, + "missed_gated_content": false, + "verified_upgrade_link": null + }, + "course_date_blocks": [ + { + "assignment_type": null, + "complete": null, + "date": "2023-08-30T15:00:00Z", + "date_type": "course-start-date", + "description": "", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Course starts", + "extra_info": null, + "first_component_block_id": "" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-14T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@ca19e125470846f2a36ad1225410e39a", + "link_text": "", + "title": "Problem Set 1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-14T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@ca19e125470846f2a36ad1225410e39aa", + "link_text": "", + "title": "Problem Set 1.1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530a" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-21T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ecec", + "link_text": "", + "title": "Problem Set 2", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abd" + }, + { + "assignment_type": "Problem Set", + "complete": true, + "date": "2023-09-21T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ececc", + "link_text": "", + "title": "Problem Set 2.1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abdc" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-21T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": false, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ececcc", + "link_text": "", + "title": "Problem Set 2.2", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abdcc" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-28T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@bfe9eb02884a4812883ff9e543887968", + "link_text": "", + "title": "Problem Set 3", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@5e117d71433647eaa6de63434641c011" + }, + { + "assignment_type": "Midterm", + "complete": false, + "date": "2023-10-04T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": false, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@bb284b9c4ff04091951f77b50e3b72f4", + "link_text": "", + "title": "Midterm Exam (time limit removed due to grader issues)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@vertical+block@ec1c5d83de6244d38b1f3ff4d32b6e17" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-10-12T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@64f4d344ecdc48d2bef514882e6236ab", + "link_text": "", + "title": "Problem Set 4", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@eeb64a67e52e4f3e80656b9233204f25" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-10-19T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@79d22d4ab4f740158930fca4e80d67db", + "link_text": "", + "title": "Problem Set 5", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@3dde572871fc4b6ebdb47722a184a514" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-10-26T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@3d419098708e4bcd9209ffa31a4cb3dc", + "link_text": "", + "title": "Problem Set 6", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@9b2a0176bf6a4c21ad4a63c2fce2d0cb" + }, + { + "assignment_type": "Final Exam", + "complete": false, + "date": "2023-10-31T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": false, + "link": "", + "link_text": "", + "title": "Final Exam (time limit removed due to grader issues)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@vertical+block@e7b4f091d7ad457097d0bbda9d9af267" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@221a4c17dba341d6a970a0d80343253c", + "link_text": "", + "title": "1. Introduction to Python (TIME: 1:03:12)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@ad9387910b7e47069c452efebd7b36dd" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@35f82f6c3ecb4e9e913dc279a9b73a9f", + "link_text": "", + "title": "2. Core Elements of Programs (TIME: 54:14)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@8fb4fa767a204d41a6366c2bc53bea22" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@62f08cc899344863a1ab678aee506dec", + "link_text": "", + "title": "3. Simple Algorithms (TIME: 41:06)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@1f2b055948c9467492649b59e24e8fdc" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@38007cdb67c44b46b124cdbce33510b5", + "link_text": "", + "title": "4. Functions (TIME: 1:08:06)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@9dc4c11c46274b87964c7534b449d50a" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@01df98c1e74a459b8fb20d2d785622cd", + "link_text": "", + "title": "5. Tuples and Lists", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@3464df78190b43948ba0507ef4287290" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@8a590293a22e46dd9760ec917d122ec1", + "link_text": "", + "title": "6. Dictionaries", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@d2abc5b3db0d43ba90c5d3a25e95e2d5" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@78648402e8bf4738ade97101cc1ba263", + "link_text": "", + "title": "7. Testing and Debugging", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@dd0621fbfe594e789b187a1e4f8406eb" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@c81c3de20ec54c37a04a8b3d1806e82c", + "link_text": "", + "title": "8. Exceptions and Assertions", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@6038a1b2f8a340eb8cdb41c021d62234" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@37cb9a5012e443bbaa776a80afd9c87a", + "link_text": "", + "title": "9. Classes and Inheritance", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@b87e596b827142f09e9664fac3ab0be0" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@54cd6b1bbbbe40f294ac0b5664c03f1e", + "link_text": "", + "title": "10. An Extended Example", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@6bc79b1a29ac46a7857caa53a8e203d0" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@1334ab336b1b4458b5c2972c50e903b2", + "link_text": "", + "title": "11. Computational Complexity", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@be73e5a3ee7847d98805a257189b9fad" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@a7387dbd3728491c8f834e29a73e0cf4", + "link_text": "", + "title": "12. Searching and Sorting Algorithms", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@fa7e29b3b95b4a3b963d1c5dfdd4e8f8" + }, + { + "assignment_type": null, + "complete": null, + "date": "2023-11-01T23:30:00Z", + "date_type": "course-end-date", + "description": "After the course ends, the course content will be archived and no longer active.", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Course ends", + "extra_info": null, + "first_component_block_id": "" + }, + { + "assignment_type": null, + "complete": null, + "date": "2023-11-03T00:00:00Z", + "date_type": "certificate-available-date", + "description": "Day certificates will become available for passing verified learners.", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Certificate Available", + "extra_info": null, + "first_component_block_id": "" + }, + { + "assignment_type": null, + "complete": null, + "date": "2023-11-23T12:34:28Z", + "date_type": "course-expired-date", + "description": "You lose all access to this course, including your progress.", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Audit Access Expires", + "extra_info": null, + "first_component_block_id": "" + } + ], + "has_ended": false, + "learner_is_full_access": false, + "user_timezone": null + } + """ } #endif // swiftlint:enable all diff --git a/Course/Course/Data/Model/Data_CourseDates.swift b/Course/Course/Data/Model/Data_CourseDates.swift new file mode 100644 index 000000000..b78691020 --- /dev/null +++ b/Course/Course/Data/Model/Data_CourseDates.swift @@ -0,0 +1,90 @@ +// +// Data_CourseDates.swift +// Course +// +// Created by Muhammad Umer on 10/18/23. +// + +import Foundation +import Core + +public extension DataLayer { + struct CourseDates: Codable { + let datesBannerInfo: DatesBannerInfo? + let courseDateBlocks: [CourseDateBlock] + let hasEnded, learnerIsFullAccess: Bool + let userTimezone: String? + + enum CodingKeys: String, CodingKey { + case datesBannerInfo = "dates_banner_info" + case courseDateBlocks = "course_date_blocks" + case hasEnded = "has_ended" + case learnerIsFullAccess = "learner_is_full_access" + case userTimezone = "user_timezone" + } + } + + struct CourseDateBlock: Codable { + let assignmentType: String? + let complete: Bool? + let date, dateType, description: String + let learnerHasAccess: Bool + let link, title: String + let linkText: String? + let extraInfo: String? + let firstComponentBlockID: String + + enum CodingKeys: String, CodingKey { + case assignmentType = "assignment_type" + case complete, date + case dateType = "date_type" + case description + case learnerHasAccess = "learner_has_access" + case link + case linkText = "link_text" + case title + case extraInfo = "extra_info" + case firstComponentBlockID = "first_component_block_id" + } + } + + struct DatesBannerInfo: Codable { + let missedDeadlines, contentTypeGatingEnabled, missedGatedContent: Bool + let verifiedUpgradeLink: String? + + enum CodingKeys: String, CodingKey { + case missedDeadlines = "missed_deadlines" + case contentTypeGatingEnabled = "content_type_gating_enabled" + case missedGatedContent = "missed_gated_content" + case verifiedUpgradeLink = "verified_upgrade_link" + } + } +} + +public extension DataLayer.CourseDates { + var domain: CourseDates { + return CourseDates( + datesBannerInfo: DatesBannerInfo( + missedDeadlines: datesBannerInfo?.missedDeadlines ?? false, + contentTypeGatingEnabled: datesBannerInfo?.contentTypeGatingEnabled ?? false, + missedGatedContent: datesBannerInfo?.missedGatedContent ?? false, + verifiedUpgradeLink: datesBannerInfo?.verifiedUpgradeLink), + courseDateBlocks: courseDateBlocks.map { block in + CourseDateBlock( + assignmentType: block.assignmentType, + complete: block.complete, + date: Date(iso8601: block.date), + dateType: block.dateType, + description: block.description, + learnerHasAccess: block.learnerHasAccess, + link: block.link, + linkText: block.linkText ?? nil, + title: block.title, + extraInfo: block.extraInfo, + firstComponentBlockID: block.firstComponentBlockID) + }, + hasEnded: hasEnded, + learnerIsFullAccess: learnerIsFullAccess, + userTimezone: userTimezone) + } +} diff --git a/Course/Course/Data/Model/Data_CourseDetailsResponse.swift b/Course/Course/Data/Model/Data_CourseDetailsResponse.swift index 306ca6f96..1047727e8 100644 --- a/Course/Course/Data/Model/Data_CourseDetailsResponse.swift +++ b/Course/Course/Data/Model/Data_CourseDetailsResponse.swift @@ -22,7 +22,7 @@ public extension DataLayer { public let name: String public let number: String public let org: String - public let shortDescription: String + public let shortDescription: String? public let start: String? public let startDisplay: String? public let startType: String? diff --git a/Course/Course/Data/Network/CourseEndpoint.swift b/Course/Course/Data/Network/CourseEndpoint.swift index 7b3109a9c..63ef3b4e1 100644 --- a/Course/Course/Data/Network/CourseEndpoint.swift +++ b/Course/Course/Data/Network/CourseEndpoint.swift @@ -19,6 +19,7 @@ enum CourseEndpoint: EndPointType { case getUpdates(courseID: String) case resumeBlock(userName: String, courseID: String) case getSubtitles(url: String, selectedLanguage: String) + case getCourseDates(courseID: String) var path: String { switch self { @@ -40,6 +41,8 @@ enum CourseEndpoint: EndPointType { return "/api/mobile/v1/users/\(userName)/course_status_info/\(courseID)" case let .getSubtitles(url, _): return url + case .getCourseDates(courseID: let courseID): + return "/api/course_home/v1/dates/\(courseID)" } } @@ -63,6 +66,8 @@ enum CourseEndpoint: EndPointType { return .get case .getSubtitles: return .get + case .getCourseDates: + return .get } } @@ -112,11 +117,13 @@ enum CourseEndpoint: EndPointType { case .resumeBlock: return .requestParameters(encoding: JSONEncoding.default) case let .getSubtitles(_, subtitleLanguage): -// let languageCode = Locale.current.languageCode ?? "en" + // let languageCode = Locale.current.languageCode ?? "en" let params: [String: Any] = [ "lang": subtitleLanguage ] return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + case .getCourseDates: + return .requestParameters(encoding: JSONEncoding.default) } } } diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index f83d58906..33202dee9 100644 --- a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents +++ b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -19,6 +19,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift b/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift index b17874645..9efb9d435 100644 --- a/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift +++ b/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift @@ -17,9 +17,11 @@ public protocol CoursePersistenceProtocol { func saveCourseStructure(structure: DataLayer.CourseStructure) func saveSubtitles(url: String, subtitlesString: String) func loadSubtitles(url: String) -> String? + func saveCourseDates(courseID: String, courseDates: CourseDates) + func loadCourseDates(courseID: String) throws -> CourseDates } public final class CourseBundle { private init() {} } - \ No newline at end of file + diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index df58f05a9..872b22bde 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -21,6 +21,7 @@ public protocol CourseInteractorProtocol { func getUpdates(courseID: String) async throws -> [CourseUpdate] func resumeBlock(courseID: String) async throws -> ResumeBlock func getSubtitles(url: String, selectedLanguage: String) async throws -> [Subtitle] + func getCourseDates(courseID: String) async throws -> CourseDates } public class CourseInteractor: CourseInteractorProtocol { @@ -94,6 +95,10 @@ public class CourseInteractor: CourseInteractorProtocol { return parseSubtitles(from: result) } + public func getCourseDates(courseID: String) async throws -> CourseDates { + return try await repository.getCourseDates(courseID: courseID) + } + private func filterChapter(chapter: CourseChapter) -> CourseChapter { var newChilds = [CourseSequential]() for sequential in chapter.childs { diff --git a/Course/Course/Domain/Model/CourseDates.swift b/Course/Course/Domain/Model/CourseDates.swift new file mode 100644 index 000000000..e60265941 --- /dev/null +++ b/Course/Course/Domain/Model/CourseDates.swift @@ -0,0 +1,211 @@ +// +// CourseDates.swift +// Course +// +// Created by Muhammad Umer on 10/18/23. +// + +import Foundation +import Core + +public struct CourseDates { + let datesBannerInfo: DatesBannerInfo + let courseDateBlocks: [CourseDateBlock] + let hasEnded, learnerIsFullAccess: Bool + let userTimezone: String? + + var sortedDateToCourseDateBlockDict: [Date: [CourseDateBlock]] { + var dateToCourseDateBlockDict: [Date: [CourseDateBlock]] = [:] + var hasToday = false + let today = Date.today + + for block in courseDateBlocks { + let date = block.date + if date == today { + hasToday = true + } + + dateToCourseDateBlockDict[date, default: []].append(block) + } + + if !hasToday { + let todayBlock = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: today, + dateType: "", + description: "", + learnerHasAccess: true, + link: "", linkText: nil, + title: CoreLocalization.CourseDates.today, + extraInfo: nil, + firstComponentBlockID: "uniqueIDForToday") + dateToCourseDateBlockDict[today] = [todayBlock] + } + + return dateToCourseDateBlockDict + } +} + +extension Date { + static var today: Date { + return Calendar.current.startOfDay(for: Date()) + } + + static func compare(_ fromDate: Date, to toDate: Date) -> ComparisonResult { + if fromDate > toDate { + return .orderedDescending + } else if fromDate < toDate { + return .orderedAscending + } + return .orderedSame + } + + var isInPast: Bool { + return Date.compare(self, to: .today) == .orderedAscending + } + + var isToday: Bool { + return Date.compare(self, to: .today) == .orderedSame + } + + var isInFuture: Bool { + return Date.compare(self, to: .today) == .orderedDescending + } +} + +public struct CourseDateBlock { + let assignmentType: String? + let complete: Bool? + let date: Date + let dateType, description: String + let learnerHasAccess: Bool + let link: String + let linkText: String? + let title: String + let extraInfo: String? + let firstComponentBlockID: String + + var blockTitle: String { + if isToday { + return CoreLocalization.CourseDates.today + } else { + return blockStatus.title + } + } + + var isInPast: Bool { + return date.isInPast + } + + var isToday: Bool { + if dateType.isEmpty { + return true + } else { + return date.isToday + } + } + + var isInFuture: Bool { + return date.isInFuture + } + + var isAssignment: Bool { + return BlockStatus.status(of: dateType) == .assignment + } + + var isVerifiedOnly: Bool { + return !learnerHasAccess + } + + var isComplete: Bool { + return complete ?? false + } + + var isLearnerAssignment: Bool { + return learnerHasAccess && isAssignment + } + + var isPastDue: Bool { + return !isComplete && (date < .today) + } + + var isUnreleased: Bool { + return link.isEmpty + } + + var canShowLink: Bool { + return !isUnreleased && isLearnerAssignment + } + + var blockStatus: BlockStatus { + if isComplete { + return .completed + } + + if !learnerHasAccess { + return .verifiedOnly + } + + if isAssignment { + if isInPast { + return isUnreleased ? .unreleased : .pastDue + } else if isToday || isInFuture { + return isUnreleased ? .unreleased : .dueNext + } + } + + return BlockStatus.status(of: dateType) + } +} + +public struct DatesBannerInfo { + let missedDeadlines, contentTypeGatingEnabled, missedGatedContent: Bool + let verifiedUpgradeLink: String? +} + +public enum BlockStatus { + case completed + case pastDue + case dueNext + case unreleased + case verifiedOnly + case assignment + case verifiedUpgradeDeadline + case courseExpiredDate + case verificationDeadlineDate + case certificateAvailbleDate + case courseStartDate + case courseEndDate + case event + + static func status(of type: String) -> BlockStatus { + switch type { + case "assignment-due-date": return .assignment + case "verified-upgrade-deadline": return .verifiedUpgradeDeadline + case "course-expired-date": return .courseExpiredDate + case "verification-deadline-date": return .verificationDeadlineDate + case "certificate-available-date": return .certificateAvailbleDate + case "course-start-date": return .courseStartDate + case "course-end-date": return .courseEndDate + default: return .event + } + } + + var title: String { + switch self { + case .completed: + return CoreLocalization.CourseDates.completed + case .pastDue: + return CoreLocalization.CourseDates.pastDue + case .dueNext: + return CoreLocalization.CourseDates.dueNext + case .unreleased: + return CoreLocalization.CourseDates.unreleased + case .verifiedOnly: + return CoreLocalization.CourseDates.verifiedOnly + default: + return "" + } + } +} diff --git a/Course/Course/Domain/Model/CourseDetails.swift b/Course/Course/Domain/Model/CourseDetails.swift index 0edb58854..6769aff53 100644 --- a/Course/Course/Domain/Model/CourseDetails.swift +++ b/Course/Course/Domain/Model/CourseDetails.swift @@ -11,7 +11,7 @@ public struct CourseDetails { public let courseID: String public let org: String public let courseTitle: String - public let courseDescription: String + public let courseDescription: String? public let courseStart: Date? public let courseEnd: Date? public let enrollmentStart: Date? @@ -24,7 +24,7 @@ public struct CourseDetails { public init(courseID: String, org: String, courseTitle: String, - courseDescription: String, + courseDescription: String?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 574f71b81..726cdaeb3 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -15,6 +15,7 @@ public struct CourseContainerView: View { enum CourseTab { case course case videos + case dates case discussion case handounds } @@ -74,6 +75,15 @@ public struct CourseContainerView: View { } .tag(CourseTab.videos) + CourseDatesView(courseID: courseID, + viewModel: Container.shared.resolve(CourseDatesViewModel.self, + argument: courseID)!) + .tabItem { + Image(systemName: "calendar").renderingMode(.template) + Text(CourseLocalization.CourseContainer.dates) + } + .tag(CourseTab.dates) + DiscussionTopicsView(courseID: courseID, viewModel: Container.shared.resolve(DiscussionTopicsViewModel.self, argument: title)!, @@ -122,6 +132,8 @@ public struct CourseContainerView: View { return DiscussionLocalization.title case .handounds: return CourseLocalization.CourseContainer.handouts + case .dates: + return CourseLocalization.CourseContainer.dates } } } diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 938c187a0..a3f90b8e9 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -165,6 +165,8 @@ public class CourseContainerViewModel: BaseCourseViewModel { analytics.courseOutlineCourseTabClicked(courseId: courseId, courseName: courseName) case .videos: analytics.courseOutlineVideosTabClicked(courseId: courseId, courseName: courseName) + case .dates: + analytics.courseOutlineDatesTabClicked(courseId: courseId, courseName: courseName) case .discussion: analytics.courseOutlineDiscussionTabClicked(courseId: courseId, courseName: courseName) case .handounds: diff --git a/Course/Course/Presentation/CourseAnalytics.swift b/Course/Course/Presentation/CourseAnalytics.swift index 6ad6e0389..7396438b4 100644 --- a/Course/Course/Presentation/CourseAnalytics.swift +++ b/Course/Course/Presentation/CourseAnalytics.swift @@ -22,6 +22,7 @@ public protocol CourseAnalytics { func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) func courseOutlineCourseTabClicked(courseId: String, courseName: String) func courseOutlineVideosTabClicked(courseId: String, courseName: String) + func courseOutlineDatesTabClicked(courseId: String, courseName: String) func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) } @@ -46,6 +47,7 @@ class CourseAnalyticsMock: CourseAnalytics { public func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) {} public func courseOutlineCourseTabClicked(courseId: String, courseName: String) {} public func courseOutlineVideosTabClicked(courseId: String, courseName: String) {} + public func courseOutlineDatesTabClicked(courseId: String, courseName: String) {} public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) {} public func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) {} } diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift new file mode 100644 index 000000000..34774e1f0 --- /dev/null +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -0,0 +1,279 @@ +// +// CourseDatesView.swift +// Discussion +// +// Created by Muhammad Umer on 10/17/23. +// + +import Foundation +import SwiftUI +import Core + +public struct CourseDatesView: View { + + private let courseID: String + + @StateObject + private var viewModel: CourseDatesViewModel + + public init( + courseID: String, + viewModel: CourseDatesViewModel + ) { + self.courseID = courseID + self._viewModel = StateObject(wrappedValue: { viewModel }()) + } + + public var body: some View { + ZStack { + VStack(alignment: .center) { + if viewModel.isShowProgress { + HStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + } + } else if let courseDates = viewModel.courseDates, !courseDates.courseDateBlocks.isEmpty { + CourseDateListView(viewModel: viewModel, courseDates: courseDates) + } + } + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } + } + } + .onFirstAppear { + Task { + await viewModel.getCourseDates(courseID: courseID) + } + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct Line: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + return path + } +} + +struct TimeLineView: View { + let date: Date + let firstDate: Date? + let lastDate: Date? + + var body: some View { + ZStack(alignment: .top) { + if lastDate == date { + VStack { + Line() + .stroke(style: StrokeStyle(lineWidth: 1)) + .frame(maxHeight: 10.0, alignment: .top) + Spacer() + } + } else if firstDate == date { + Line() + .stroke(style: StrokeStyle(lineWidth: 1)) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.top, 10) + } else { + Line() + .stroke(style: StrokeStyle(lineWidth: 1)) + .frame(maxHeight: .infinity, alignment: .top) + } + + Circle() + .frame(width: date.isToday ? 12 : 8, height: date.isToday ? 12 : 8) + .foregroundColor({ + if date.isToday { + return Theme.Colors.warning + } else if date.isInPast { + return Color.gray + } else { + return Color.black + } + }()) + .overlay(Circle().stroke(Color.black, lineWidth: 1)) + .padding(.top, 5) + } + .frame(width: 16) + } +} + +struct CourseDateListView: View { + @ObservedObject var viewModel: CourseDatesViewModel + var courseDates: CourseDates + + var body: some View { + VStack { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(viewModel.sortedDates, id: \.self) { date in + let blocks = courseDates.sortedDateToCourseDateBlockDict[date]! + + HStack(alignment: .center) { + TimeLineView(date: date, + firstDate: viewModel.sortedDates.first, + lastDate: viewModel.sortedDates.last) + + let ignoredStatuses: [BlockStatus] = [.courseStartDate, .courseEndDate] + let block = blocks[0] + let allHaveSameStatus = blocks + .filter { !ignoredStatuses.contains($0.blockStatus) } + .allSatisfy { $0.blockStatus == block.blockStatus } + + BlockStatusView(block: block, + allHaveSameStatus: allHaveSameStatus, + blocks: blocks) + + Spacer() + } + } + } + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +struct BlockStatusView: View { + let block: CourseDateBlock + let allHaveSameStatus: Bool + let blocks: [CourseDateBlock] + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(block.date.dateToString(style: .shortWeekdayMonthDayYear)) + .font(.subheadline) + .bold() + + if block.isToday { + Text(block.blockTitle) + .font(.footnote) + .foregroundColor(Color.black) + .padding(EdgeInsets(top: 2, leading: 6, bottom: 2, trailing: 8)) + .background(Theme.Colors.warning) + .cornerRadius(5) + } + + if allHaveSameStatus { + let lockImageText = block.isVerifiedOnly ? Text(Image(systemName: "lock.fill")) : Text("") + Text("\(lockImageText) \(block.blockTitle)") + .font(.footnote) + .foregroundColor(determineForegroundColor(for: block.blockStatus)) + .padding(EdgeInsets(top: 2, leading: 6, bottom: 2, trailing: 8)) + .background(determineBackgroundColor(for: block.blockStatus)) + .cornerRadius(5) + } + } + + ForEach(blocks, id: \.firstComponentBlockID) { block in + styleBlock(block: block, allHaveSameStatus: allHaveSameStatus) + } + } + .padding(.vertical, 0) + .padding(.leading, 5) + .padding(.bottom, 10) + } + + private func determineForegroundColor(for status: BlockStatus) -> Color { + switch status { + case .verifiedOnly: return Color.white + case .pastDue: return Color.black + case .dueNext: return Color.white + default: return Color.white.opacity(0) + } + } + + private func determineBackgroundColor(for status: BlockStatus) -> Color { + switch status { + case .verifiedOnly: return Color.black.opacity(0.5) + case .pastDue: return Color.gray.opacity(0.4) + case .dueNext: return Color.black.opacity(0.5) + default: return Color.white.opacity(0) + } + } + + func styleBlock(block: CourseDateBlock, allHaveSameStatus: Bool) -> some View { + var attrString = AttributedString("") + + if let prefix = block.assignmentType, !prefix.isEmpty { + attrString += AttributedString("\(prefix): ") + } + + attrString += block.canShowLink ? getAttributedUnderlineString(string: block.title) : AttributedString(block.title) + + if !allHaveSameStatus { + attrString += " " + let (status, foregroundColor, backgroundColor) = getStatusDetails(for: block.blockStatus) + attrString += getAttributedString(string: status, forgroundColor: foregroundColor, backgroundColor: backgroundColor) + } + + return Text(attrString).padding(.bottom, 2).font(.footnote) + } + + func getStatusDetails(for blockStatus: BlockStatus) -> (String, Color, Color) { + switch blockStatus { + case .verifiedOnly: + return (CoreLocalization.CourseDates.verifiedOnly, Color.white, Color.black.opacity(0.5)) + case .pastDue: + return (CoreLocalization.CourseDates.pastDue, Color.black, Color.gray.opacity(0.4)) + case .dueNext: + return (CoreLocalization.CourseDates.dueNext, Color.white, Color.black.opacity(0.5)) + case .unreleased: + return (CoreLocalization.CourseDates.unreleased, Color.white.opacity(0), Color.white.opacity(0)) + default: + return ("", Color.white.opacity(0), Color.white.opacity(0)) + } + } + + func getAttributedUnderlineString(string: String) -> AttributedString { + var attributedString = AttributedString(string) + attributedString.font = .footnote + attributedString.underlineStyle = .single + return attributedString + } + + func getAttributedString(string: String, forgroundColor: Color, backgroundColor: Color) -> AttributedString { + var attributedString = AttributedString(string) + attributedString.font = .footnote + attributedString.foregroundColor = forgroundColor + attributedString.backgroundColor = backgroundColor + return attributedString + } +} + +#if DEBUG +struct CourseDatesView_Previews: PreviewProvider { + static var previews: some View { + let viewModel = CourseDatesViewModel( + interactor: CourseInteractor(repository: CourseRepositoryMock()), + router: CourseRouterMock(), + cssInjector: CSSInjectorMock(), + connectivity: Connectivity(), + courseID: "") + + CourseDatesView( + courseID: "", + viewModel: viewModel) + } +} +#endif diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift new file mode 100644 index 000000000..e60d413d9 --- /dev/null +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -0,0 +1,72 @@ +// +// CourseDatesViewModel.swift +// Course +// +// Created by Muhammad Umer on 10/18/23. +// + +import Foundation +import Core +import SwiftUI + +public class CourseDatesViewModel: ObservableObject { + + @Published private(set) var isShowProgress = false + @Published var showError: Bool = false + @Published var courseDates: CourseDates? + + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + + private let interactor: CourseInteractorProtocol + let cssInjector: CSSInjector + let router: CourseRouter + let connectivity: ConnectivityProtocol + + public init( + interactor: CourseInteractorProtocol, + router: CourseRouter, + cssInjector: CSSInjector, + connectivity: ConnectivityProtocol, + courseID: String + ) { + self.interactor = interactor + self.router = router + self.cssInjector = cssInjector + self.connectivity = connectivity + } + + var sortedDates: [Date] { + courseDates?.sortedDateToCourseDateBlockDict.keys.sorted() ?? [] + } + + func blocks(for date: Date) -> [CourseDateBlock] { + courseDates?.sortedDateToCourseDateBlockDict[date] ?? [] + } + + @MainActor + func getCourseDates(courseID: String) async { + isShowProgress = true + do { + courseDates = try await interactor.getCourseDates(courseID: courseID) + if courseDates?.courseDateBlocks == nil { + isShowProgress = false + errorMessage = CoreLocalization.Error.unknownError + return + } + isShowProgress = false + } catch let error { + isShowProgress = false + if error.isInternetError || error is NoCachedDataError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } +} diff --git a/Course/Course/Presentation/Details/CourseDetailsView.swift b/Course/Course/Presentation/Details/CourseDetailsView.swift index 2b846d526..5323efc64 100644 --- a/Course/Course/Presentation/Details/CourseDetailsView.swift +++ b/Course/Course/Presentation/Details/CourseDetailsView.swift @@ -246,7 +246,7 @@ private struct CourseTitleView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - Text(courseDetails.courseDescription) + Text(courseDetails.courseDescription ?? "") .font(Theme.Fonts.labelSmall) .padding(.horizontal, 26) diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index f5719bf9d..9ec7b8b36 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -41,6 +41,8 @@ public enum CourseLocalization { public enum CourseContainer { /// Course public static let course = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.COURSE", fallback: "Course") + /// Dates + public static let dates = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.DATES", fallback: "Dates") /// Discussion public static let discussion = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.DISCUSSION", fallback: "Discussion") /// Handouts diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index 0f5edc88f..a37d426c0 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -37,6 +37,7 @@ "COURSE_CONTAINER.COURSE" = "Course"; "COURSE_CONTAINER.VIDEOS" = "Videos"; +"COURSE_CONTAINER.DATES" = "Dates"; "COURSE_CONTAINER.DISCUSSION" = "Discussion"; "COURSE_CONTAINER.HANDOUTS" = "Handouts"; "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING" = "Handouts In developing"; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index cedc987f1..4f7ff5f87 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -36,6 +36,7 @@ "COURSE_CONTAINER.COURSE" = "Курс"; "COURSE_CONTAINER.VIDEOS" = "Всі відео"; +//"COURSE_CONTAINER.DATES" = "Dates"; "COURSE_CONTAINER.DISCUSSION" = "Дискусії"; "COURSE_CONTAINER.HANDOUTS" = "Матеріали"; "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING" = "Матеріали в процесі розробки"; diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 122385f57..8c909304b 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -1124,6 +1124,12 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { perform?(`courseId`, `courseName`) } + open func courseOutlineDatesTabClicked(courseId: String, courseName: String) { + addInvocation(.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + open func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) { addInvocation(.m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) let perform = methodPerformValue(.m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void @@ -1151,6 +1157,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) @@ -1247,6 +1254,12 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) return Matcher.ComparisonResult(results) + case (.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + case (.m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) @@ -1277,6 +1290,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case let .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue } @@ -1296,6 +1310,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName: return ".finishVerticalBackToOutlineClicked(courseId:courseName:)" case .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineCourseTabClicked(courseId:courseName:)" case .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineVideosTabClicked(courseId:courseName:)" + case .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDatesTabClicked(courseId:courseName:)" case .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDiscussionTabClicked(courseId:courseName:)" case .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineHandoutsTabClicked(courseId:courseName:)" } @@ -1329,6 +1344,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func finishVerticalBackToOutlineClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineCourseTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineVideosTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func courseOutlineDatesTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineDiscussionTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineHandoutsTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} } @@ -1376,6 +1392,9 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func courseOutlineVideosTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) } + public static func courseOutlineDatesTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } public static func courseOutlineDiscussionTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) } @@ -1671,6 +1690,22 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { return __value } + open func getCourseDates(courseID: String) throws -> CourseDates { + addInvocation(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: CourseDates + do { + __value = try methodReturnValue(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getCourseDates(courseID: String). Use given") + Failure("Stub return value not specified for getCourseDates(courseID: String). Use given") + } catch { + throw error + } + return __value + } + fileprivate enum MethodType { case m_getCourseDetails__courseID_courseID(Parameter) @@ -1684,6 +1719,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case m_getUpdates__courseID_courseID(Parameter) case m_resumeBlock__courseID_courseID(Parameter) case m_getSubtitles__url_urlselectedLanguage_selectedLanguage(Parameter, Parameter) + case m_getCourseDates__courseID_courseID(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1743,6 +1779,11 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSelectedlanguage, rhs: rhsSelectedlanguage, with: matcher), lhsSelectedlanguage, rhsSelectedlanguage, "selectedLanguage")) return Matcher.ComparisonResult(results) + + case (.m_getCourseDates__courseID_courseID(let lhsCourseid), .m_getCourseDates__courseID_courseID(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + return Matcher.ComparisonResult(results) default: return .none } } @@ -1760,6 +1801,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case let .m_getUpdates__courseID_courseID(p0): return p0.intValue case let .m_resumeBlock__courseID_courseID(p0): return p0.intValue case let .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(p0, p1): return p0.intValue + p1.intValue + case let .m_getCourseDates__courseID_courseID(p0): return p0.intValue } } func assertionName() -> String { @@ -1775,6 +1817,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case .m_getUpdates__courseID_courseID: return ".getUpdates(courseID:)" case .m_resumeBlock__courseID_courseID: return ".resumeBlock(courseID:)" case .m_getSubtitles__url_urlselectedLanguage_selectedLanguage: return ".getSubtitles(url:selectedLanguage:)" + case .m_getCourseDates__courseID_courseID: return ".getCourseDates(courseID:)" } } } @@ -1818,6 +1861,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getSubtitles(url: Parameter, selectedLanguage: Parameter, willReturn: [Subtitle]...) -> MethodStub { return Given(method: .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(`url`, `selectedLanguage`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func getCourseDates(courseID: Parameter, willReturn: CourseDates...) -> MethodStub { + return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func getCourseVideoBlocks(fullStructure: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [CourseStructure] = [] let given: Given = { return Given(method: .m_getCourseVideoBlocks__fullStructure_fullStructure(`fullStructure`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -1925,6 +1971,16 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { willProduce(stubber) return given } + public static func getCourseDates(courseID: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getCourseDates(courseID: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (CourseDates).self) + willProduce(stubber) + return given + } } public struct Verify { @@ -1941,6 +1997,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getUpdates(courseID: Parameter) -> Verify { return Verify(method: .m_getUpdates__courseID_courseID(`courseID`))} public static func resumeBlock(courseID: Parameter) -> Verify { return Verify(method: .m_resumeBlock__courseID_courseID(`courseID`))} public static func getSubtitles(url: Parameter, selectedLanguage: Parameter) -> Verify { return Verify(method: .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(`url`, `selectedLanguage`))} + public static func getCourseDates(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseDates__courseID_courseID(`courseID`))} } public struct Perform { @@ -1980,6 +2037,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getSubtitles(url: Parameter, selectedLanguage: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(`url`, `selectedLanguage`), performs: perform) } + public static func getCourseDates(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getCourseDates__courseID_courseID(`courseID`), performs: perform) + } } public func given(_ method: Given) { diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift new file mode 100644 index 000000000..0b6f6f9bf --- /dev/null +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -0,0 +1,469 @@ +// +// CourseDateViewModelTests.swift +// CourseTests +// +// Created by Muhammad Umer on 10/24/23. +// + +import SwiftyMocky +import XCTest +@testable import Core +@testable import Course +import Alamofire +import SwiftUI + +final class CourseDateViewModelTests: XCTestCase { + func testGetCourseDatesSuccess() async throws { + let interactor = CourseInteractorProtocolMock() + let router = CourseRouterMock() + let cssInjector = CSSInjectorMock() + let connectivity = ConnectivityProtocolMock() + + let courseDates = CourseDates( + datesBannerInfo: + DatesBannerInfo( + missedDeadlines: false, + contentTypeGatingEnabled: false, + missedGatedContent: false, + verifiedUpgradeLink: ""), + courseDateBlocks: [], + hasEnded: false, + learnerIsFullAccess: false, + userTimezone: nil) + + Given(interactor, .getCourseDates(courseID: .any, willReturn: courseDates)) + + let viewModel = CourseDatesViewModel( + interactor: interactor, + router: router, + cssInjector: cssInjector, + connectivity: connectivity, + courseID: "1") + + await viewModel.getCourseDates(courseID: "1") + + Verify(interactor, .getCourseDates(courseID: .any)) + + XCTAssert((viewModel.courseDates != nil)) + XCTAssertFalse(viewModel.isShowProgress) + XCTAssertNil(viewModel.errorMessage) + XCTAssertFalse(viewModel.showError) + } + + func testGetCourseDatesUnknownError() async throws { + let interactor = CourseInteractorProtocolMock() + let router = CourseRouterMock() + let cssInjector = CSSInjectorMock() + let connectivity = ConnectivityProtocolMock() + + Given(interactor, .getCourseDates(courseID: .any, willThrow: NSError())) + + let viewModel = CourseDatesViewModel( + interactor: interactor, + router: router, + cssInjector: cssInjector, + connectivity: connectivity, + courseID: "1") + + await viewModel.getCourseDates(courseID: "1") + + Verify(interactor, .getCourseDates(courseID: .any)) + + XCTAssertTrue(viewModel.showError) + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) + } + + func testNoInternetConnectionError() async throws { + let interactor = CourseInteractorProtocolMock() + let router = CourseRouterMock() + let cssInjector = CSSInjectorMock() + let connectivity = ConnectivityProtocolMock() + + let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) + + Given(interactor, .getCourseDates(courseID: .any, willThrow: noInternetError)) + + let viewModel = CourseDatesViewModel( + interactor: interactor, + router: router, + cssInjector: cssInjector, + connectivity: connectivity, + courseID: "1") + + await viewModel.getCourseDates(courseID: "1") + + Verify(interactor, .getCourseDates(courseID: .any)) + + XCTAssertTrue(viewModel.showError) + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) + } + + func testSortedDateTodayToCourseDateBlockDict() { + let block1 = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today.addingTimeInterval(86400), + dateType: "event", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockID1" + ) + + let block2 = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today, + dateType: "event", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockID1" + ) + + let courseDates = CourseDates( + datesBannerInfo: DatesBannerInfo( + missedDeadlines: false, + contentTypeGatingEnabled: false, + missedGatedContent: false, + verifiedUpgradeLink: nil + ), + courseDateBlocks: [block1, block2], + hasEnded: false, + learnerIsFullAccess: true, + userTimezone: nil + ) + + let sortedDict = courseDates.sortedDateToCourseDateBlockDict + + XCTAssertEqual(sortedDict.keys.sorted().first, Date.today) + } + + func testMultipleBlocksForSameDate() { + let block1 = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today, + dateType: "event", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockID1" + ) + + let block2 = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today, + dateType: "event", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockID1" + ) + + let courseDates = CourseDates( + datesBannerInfo: DatesBannerInfo( + missedDeadlines: false, + contentTypeGatingEnabled: false, + missedGatedContent: false, + verifiedUpgradeLink: nil + ), + courseDateBlocks: [block1, block2], + hasEnded: false, + learnerIsFullAccess: true, + userTimezone: nil + ) + + let sortedDict = courseDates.sortedDateToCourseDateBlockDict + XCTAssertEqual(sortedDict[block1.date]?.count, 2, "There should be two blocks for the given date.") + } + + func testBlockStatusForAssignmentType() { + let block = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date(), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestAssignment", + extraInfo: nil, + firstComponentBlockID: "blockID3" + ) + + XCTAssertEqual(block.blockStatus, .dueNext) + } + + func testBadgeLogicForToday() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today, + dateType: "", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockTitle, "Today", "Block title for 'today' should be 'Today'") + } + + func testBadgeLogicForCompleted() { + let block = CourseDateBlock( + assignmentType: nil, + complete: true, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + XCTAssertEqual(block.blockStatus, .completed, "Block status for a completed assignment should be 'completed'") + } + + func testBadgeLogicForVerifiedOnly() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, .verifiedOnly, "Block status for a block without learner access should be 'verifiedOnly'") + } + + func testBadgeLogicForPastDue() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(-86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, .pastDue, "Block status for a past due assignment should be 'pastDue'") + } + + func testLinkForAvailableAssignment() { + let availableAssignment = CourseDateBlock( + assignmentType: nil, + complete: true, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + XCTAssertTrue(availableAssignment.canShowLink, "Available assignments should be hyperlinked.") + } + + func testIsAssignment() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertTrue(block.isAssignment) + } + + func testIsCourseStartDate() { + let block = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today.addingTimeInterval(-86400), + dateType: "course-start-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, BlockStatus.courseStartDate) + } + + func testIsCourseEndDate() { + let block = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today.addingTimeInterval(86400), + dateType: "course-end-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, BlockStatus.courseEndDate) + } + + func testVerifiedOnly() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertTrue(block.isVerifiedOnly) + } + + func testIsCompleted() { + let block = CourseDateBlock( + assignmentType: nil, + complete: true, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertTrue(block.isComplete) + } + + func testBadgeLogicForUnreleasedAssignment() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, .unreleased) + } + + func testNoLinkForUnavailableAssignment() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertFalse(block.canShowLink) + } + + func testNoLinkAvailableForUnreleasedAssignment() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertFalse(block.canShowLink) + } + + func testTodayProperty() { + let today = Date.today + let currentDay = Calendar.current.startOfDay(for: Date()) + XCTAssertEqual(today, currentDay, "The today property should equal the start of the current day.") + } + + func testDateIsInPastProperty() { + let pastDate = Date().addingTimeInterval(-100000) + XCTAssertTrue(pastDate.isInPast) + } + + func testDateIsInFutureProperty() { + let futureDate = Date().addingTimeInterval(100000) + XCTAssertTrue(futureDate.isInFuture) + } + + func testBlockStatusMapping() { + XCTAssertEqual(BlockStatus.status(of: "course-start-date"), .courseStartDate, "Incorrect mapping for 'course-start-date'") + XCTAssertEqual(BlockStatus.status(of: "course-end-date"), .courseEndDate, "Incorrect mapping for 'course-end-date'") + XCTAssertEqual(BlockStatus.status(of: "certificate-available-date"), .certificateAvailbleDate, "Incorrect mapping for 'certificate-available-date'") + XCTAssertEqual(BlockStatus.status(of: "verification-deadline-date"), .verificationDeadlineDate, "Incorrect mapping for 'verification-deadline-date'") + XCTAssertEqual(BlockStatus.status(of: "verified-upgrade-deadline"), .verifiedUpgradeDeadline, "Incorrect mapping for 'verified-upgrade-deadline'") + XCTAssertEqual(BlockStatus.status(of: "assignment-due-date"), .assignment, "Incorrect mapping for 'assignment-due-date'") + XCTAssertEqual(BlockStatus.status(of: ""), .event, "Incorrect mapping for ''") + } +} diff --git a/Discovery/Discovery.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Discovery/Discovery.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Discovery/Discovery.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Discovery/Discovery.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Discovery/Discovery.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Discovery/Discovery.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/OpenEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate b/OpenEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index b59556e65..000000000 Binary files a/OpenEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/OpenEdX/AnalyticsManager.swift b/OpenEdX/AnalyticsManager.swift index 04a11b640..598aac053 100644 --- a/OpenEdX/AnalyticsManager.swift +++ b/OpenEdX/AnalyticsManager.swift @@ -257,6 +257,14 @@ class AnalyticsManager: AuthorizationAnalytics, logEvent(.courseOutlineVideosTabClicked, parameters: parameters) } + public func courseOutlineDatesTabClicked(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.courseOutlineDatesTabClicked, parameters: parameters) + } + public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) { let parameters = [ Key.courseID: courseId, @@ -360,6 +368,7 @@ enum Event: String { case finishVerticalBackToOutlineClicked = "Finish_Vertical_Back_to_outline_Clicked" case courseOutlineCourseTabClicked = "Course_Outline_Course_tab_Clicked" case courseOutlineVideosTabClicked = "Course_Outline_Videos_tab_Clicked" + case courseOutlineDatesTabClicked = "Course_Outline_Dates_tab_Clicked" case courseOutlineDiscussionTabClicked = "Course_Outline_Discussion_tab_Clicked" case courseOutlineHandoutsTabClicked = "Course_Outline_Handouts_tab_Clicked" diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index c5c52df4c..eb4e04396 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -298,6 +298,15 @@ class ScreenAssembly: Assembly { ) } + container.register(CourseDatesViewModel.self) { r, courseID in + CourseDatesViewModel( + interactor: r.resolve(CourseInteractorProtocol.self)!, + router: r.resolve(CourseRouter.self)!, + cssInjector: r.resolve(CSSInjector.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)!, + courseID: courseID) + } + // MARK: Discussion container.register(DiscussionRepositoryProtocol.self) { r in DiscussionRepository( diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index cd61e5e6e..6c7f5d83e 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -215,4 +215,12 @@ public class CoursePersistence: CoursePersistenceProtocol { } return nil } + + public func saveCourseDates(courseID: String, courseDates: CourseDates) { + + } + + public func loadCourseDates(courseID: String) throws -> CourseDates { + throw NoCachedDataError() + } }