diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 25f7353d2..3d8d938c7 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -65,6 +65,8 @@ 028CE96929858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028CE96829858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift */; }; 028F9F37293A44C700DE65D0 /* Data_ResetPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028F9F36293A44C700DE65D0 /* Data_ResetPassword.swift */; }; 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028F9F38293A452B00DE65D0 /* ResetPassword.swift */; }; + 02935B732BCECAD000B22F66 /* Data_PrimaryEnrollment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B722BCECAD000B22F66 /* Data_PrimaryEnrollment.swift */; }; + 02935B752BCEE6D600B22F66 /* PrimaryEnrollment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B742BCEE6D600B22F66 /* PrimaryEnrollment.swift */; }; 0295C885299B99DD00ABE571 /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */; }; 029EE3ED2BF6650500F64F33 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029EE3EC2BF6650500F64F33 /* Bundle.swift */; }; 02A463112AEA966C00331037 /* AppReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A463102AEA966C00331037 /* AppReviewView.swift */; }; @@ -77,7 +79,8 @@ 02B2B594295C5C7A00914876 /* Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2B593295C5C7A00914876 /* Thread.swift */; }; 02B3E3B32930198600A50475 /* AVPlayerViewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */; }; 02B3F16E2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */; }; - 02C917F029CDA99E00DBB8BD /* Data_Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C917EF29CDA99E00DBB8BD /* Data_Dashboard.swift */; }; + 02C917F029CDA99E00DBB8BD /* Data_Enrollments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C917EF29CDA99E00DBB8BD /* Data_Enrollments.swift */; }; + 02CA59842BD7DDBE00D517AA /* DashboardConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CA59832BD7DDBE00D517AA /* DashboardConfig.swift */; }; 02CF46C829546AA200A698EE /* NoCachedDataError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CF46C729546AA200A698EE /* NoCachedDataError.swift */; }; 02D400612B0678190029D168 /* SKStoreReviewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D400602B0678190029D168 /* SKStoreReviewControllerExtension.swift */; }; 02D800CC29348F460099CF16 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D800CB29348F460099CF16 /* ImagePicker.swift */; }; @@ -250,6 +253,8 @@ 028CE96829858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlexibleKeyboardInputView.swift; sourceTree = ""; }; 028F9F36293A44C700DE65D0 /* Data_ResetPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_ResetPassword.swift; sourceTree = ""; }; 028F9F38293A452B00DE65D0 /* ResetPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPassword.swift; sourceTree = ""; }; + 02935B722BCECAD000B22F66 /* Data_PrimaryEnrollment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_PrimaryEnrollment.swift; sourceTree = ""; }; + 02935B742BCEE6D600B22F66 /* PrimaryEnrollment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryEnrollment.swift; sourceTree = ""; }; 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; 029EE3EC2BF6650500F64F33 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; 02A463102AEA966C00331037 /* AppReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewView.swift; sourceTree = ""; }; @@ -262,7 +267,8 @@ 02B2B593295C5C7A00914876 /* Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thread.swift; sourceTree = ""; }; 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayerViewControllerExtension.swift; sourceTree = ""; }; 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollViewCompat.swift; sourceTree = ""; }; - 02C917EF29CDA99E00DBB8BD /* Data_Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Dashboard.swift; sourceTree = ""; }; + 02C917EF29CDA99E00DBB8BD /* Data_Enrollments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Enrollments.swift; sourceTree = ""; }; + 02CA59832BD7DDBE00D517AA /* DashboardConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardConfig.swift; sourceTree = ""; }; 02CF46C729546AA200A698EE /* NoCachedDataError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCachedDataError.swift; sourceTree = ""; }; 02D400602B0678190029D168 /* SKStoreReviewControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKStoreReviewControllerExtension.swift; sourceTree = ""; }; 02D800CB29348F460099CF16 /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; @@ -592,7 +598,8 @@ 0727877628D23847002E9142 /* DataLayer.swift */, 0727878428D31657002E9142 /* Data_User.swift */, 0283347C28D4D3DE00C828FC /* Data_Discovery.swift */, - 02C917EF29CDA99E00DBB8BD /* Data_Dashboard.swift */, + 02C917EF29CDA99E00DBB8BD /* Data_Enrollments.swift */, + 02935B722BCECAD000B22F66 /* Data_PrimaryEnrollment.swift */, 021D924728DC860C00ACC565 /* Data_UserProfile.swift */, 0259104929C4A5B6004B5A55 /* UserSettings.swift */, 070019A428F6F17900D5FC78 /* Data_Media.swift */, @@ -618,6 +625,7 @@ children = ( 0727878828D31734002E9142 /* User.swift */, 0284DBFD28D48C5300830893 /* CourseItem.swift */, + 02935B742BCEE6D600B22F66 /* PrimaryEnrollment.swift */, 021D924F28DC89D100ACC565 /* UserProfile.swift */, 070019AB28F6FD0100D5FC78 /* CourseDetailBlock.swift */, 0248C92229C075EF00DC8402 /* CourseBlockModel.swift */, @@ -848,6 +856,7 @@ BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */, BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */, BAFB99912B14E23D007D09F9 /* AppleSignInConfig.swift */, + 02CA59832BD7DDBE00D517AA /* DashboardConfig.swift */, A53A32342B233DEC005FE38A /* ThemeConfig.swift */, E0D586192B2FF74C009B4BA7 /* DiscoveryConfig.swift */, ); @@ -1086,6 +1095,7 @@ 0255D5582936283A004DBC1A /* UploadBodyEncoding.swift in Sources */, 06619EAF2B973B25001FAADE /* AccessibilityInjection.swift in Sources */, BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */, + 02935B732BCECAD000B22F66 /* Data_PrimaryEnrollment.swift in Sources */, 027BD3B32909475900392132 /* Publishers+KeyboardState.swift in Sources */, 06DEA4A32BBD66A700110D20 /* BackNavigationButton.swift in Sources */, 0727877D28D25212002E9142 /* ProgressBar.swift in Sources */, @@ -1129,7 +1139,7 @@ 02AFCC1A2AEFDC18000360F0 /* ThirdPartyMailer.swift in Sources */, 0770DE5F28D0B22C006D8A5D /* Strings.swift in Sources */, BA981BCE2B8F5C49005707C2 /* Sequence+Extensions.swift in Sources */, - 02C917F029CDA99E00DBB8BD /* Data_Dashboard.swift in Sources */, + 02C917F029CDA99E00DBB8BD /* Data_Enrollments.swift in Sources */, 024FCD0028EF1CD300232339 /* WebBrowser.swift in Sources */, 027BD3B52909475900392132 /* KeyboardStateObserver.swift in Sources */, 0283347D28D4D3DE00C828FC /* Data_Discovery.swift in Sources */, @@ -1192,9 +1202,11 @@ 02F98A7F28F81EE900DE94C0 /* Container+App.swift in Sources */, 02B3F16E2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift in Sources */, 0649879A2B4D69FF0071642A /* WebViewHTML.swift in Sources */, + 02CA59842BD7DDBE00D517AA /* DashboardConfig.swift in Sources */, 0727877B28D24A1D002E9142 /* HeadersRedirectHandler.swift in Sources */, 0236961B28F9A28B00EEF206 /* AuthInteractor.swift in Sources */, 0770DE3028D09793006D8A5D /* EndPointType.swift in Sources */, + 02935B752BCEE6D600B22F66 /* PrimaryEnrollment.swift in Sources */, 020C31C9290AC3F700D6DEA2 /* PickerFields.swift in Sources */, 02F6EF3B28D9B8EC00835477 /* CourseCellView.swift in Sources */, 023A1138291432FD00D0D354 /* FieldConfiguration.swift in Sources */, diff --git a/Core/Core/Assets.xcassets/NavigationBar/learn.imageset/Contents.json b/Core/Core/Assets.xcassets/NavigationBar/learn.imageset/Contents.json new file mode 100644 index 000000000..718131171 --- /dev/null +++ b/Core/Core/Assets.xcassets/NavigationBar/learn.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "learn filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/NavigationBar/learn.imageset/learn filled.svg b/Core/Core/Assets.xcassets/NavigationBar/learn.imageset/learn filled.svg new file mode 100644 index 000000000..c961205bc --- /dev/null +++ b/Core/Core/Assets.xcassets/NavigationBar/learn.imageset/learn filled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/chevron_right.imageset/Contents.json b/Core/Core/Assets.xcassets/chevron_right.imageset/Contents.json new file mode 100644 index 000000000..a21ea6e5d --- /dev/null +++ b/Core/Core/Assets.xcassets/chevron_right.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "chevron_right.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/chevron_right.imageset/chevron_right.svg b/Core/Core/Assets.xcassets/chevron_right.imageset/chevron_right.svg new file mode 100644 index 000000000..e951c4282 --- /dev/null +++ b/Core/Core/Assets.xcassets/chevron_right.imageset/chevron_right.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/learn_empty.imageset/Contents.json b/Core/Core/Assets.xcassets/learn_empty.imageset/Contents.json new file mode 100644 index 000000000..f823d5953 --- /dev/null +++ b/Core/Core/Assets.xcassets/learn_empty.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "learn_big.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/learn_empty.imageset/learn_big.svg b/Core/Core/Assets.xcassets/learn_empty.imageset/learn_big.svg new file mode 100644 index 000000000..a1874861e --- /dev/null +++ b/Core/Core/Assets.xcassets/learn_empty.imageset/learn_big.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/resumeCourse.imageset/Contents.json b/Core/Core/Assets.xcassets/resumeCourse.imageset/Contents.json new file mode 100644 index 000000000..1ab6cc7ba --- /dev/null +++ b/Core/Core/Assets.xcassets/resumeCourse.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "resumeCourse.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/resumeCourse.imageset/resumeCourse.svg b/Core/Core/Assets.xcassets/resumeCourse.imageset/resumeCourse.svg new file mode 100644 index 000000000..0af03cb0c --- /dev/null +++ b/Core/Core/Assets.xcassets/resumeCourse.imageset/resumeCourse.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/settings.imageset/Contents.json b/Core/Core/Assets.xcassets/settings.imageset/Contents.json index aa6427af7..30cb38b07 100644 --- a/Core/Core/Assets.xcassets/settings.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/settings.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "settingsIcon.svg", + "filename" : "icon-manage_accounts.svg", "idiom" : "universal" } ], diff --git a/Core/Core/Assets.xcassets/settings.imageset/icon-manage_accounts.svg b/Core/Core/Assets.xcassets/settings.imageset/icon-manage_accounts.svg new file mode 100644 index 000000000..5cf416fb2 --- /dev/null +++ b/Core/Core/Assets.xcassets/settings.imageset/icon-manage_accounts.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Core/Core/Assets.xcassets/settings.imageset/settingsIcon.svg b/Core/Core/Assets.xcassets/settings.imageset/settingsIcon.svg deleted file mode 100644 index c1181ff8e..000000000 --- a/Core/Core/Assets.xcassets/settings.imageset/settingsIcon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/Core/Core/Assets.xcassets/viewAll.imageset/Contents.json b/Core/Core/Assets.xcassets/viewAll.imageset/Contents.json new file mode 100644 index 000000000..b044a6ae9 --- /dev/null +++ b/Core/Core/Assets.xcassets/viewAll.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "viewAll.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/viewAll.imageset/viewAll.svg b/Core/Core/Assets.xcassets/viewAll.imageset/viewAll.svg new file mode 100644 index 000000000..da32ef8c1 --- /dev/null +++ b/Core/Core/Assets.xcassets/viewAll.imageset/viewAll.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 0c3aa5782..bd75f3f89 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -25,6 +25,7 @@ public protocol ConfigProtocol { var theme: ThemeConfig { get } var uiComponents: UIComponentsConfig { get } var discovery: DiscoveryConfig { get } + var dashboard: DashboardConfig { get } var braze: BrazeConfig { get } var branch: BranchConfig { get } var segment: SegmentConfig { get } diff --git a/Core/Core/Configuration/Config/DashboardConfig.swift b/Core/Core/Configuration/Config/DashboardConfig.swift new file mode 100644 index 000000000..cd2b335b1 --- /dev/null +++ b/Core/Core/Configuration/Config/DashboardConfig.swift @@ -0,0 +1,34 @@ +// +// DashboardConfig.swift +// Core +// +// Created by  Stepanok Ivan on 23.04.2024. +// + +import Foundation + +public enum DashboardConfigType: String { + case gallery + case list +} + +private enum DashboardKeys: String, RawStringExtractable { + case dashboardType = "TYPE" +} + +public class DashboardConfig: NSObject { + public let type: DashboardConfigType + + init(dictionary: [String: AnyObject]) { + type = (dictionary[DashboardKeys.dashboardType] as? String).flatMap { + DashboardConfigType(rawValue: $0) + } ?? .gallery + } +} + +private let key = "DASHBOARD" +extension Config { + public var dashboard: DashboardConfig { + DashboardConfig(dictionary: self[key] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/Core/Configuration/Config/DiscoveryConfig.swift b/Core/Core/Configuration/Config/DiscoveryConfig.swift index 893ea0ca9..d6d29de2b 100644 --- a/Core/Core/Configuration/Config/DiscoveryConfig.swift +++ b/Core/Core/Configuration/Config/DiscoveryConfig.swift @@ -36,6 +36,11 @@ public class DiscoveryWebviewConfig: NSObject { public class DiscoveryConfig: NSObject { public let type: DiscoveryConfigType public let webview: DiscoveryWebviewConfig + public var isWebViewConfigured: Bool { + get { + return type == .webview && webview.baseURL != nil + } + } init(dictionary: [String: AnyObject]) { type = (dictionary[DiscoveryKeys.discoveryType] as? String).flatMap { diff --git a/Core/Core/Data/Model/Data_Discovery.swift b/Core/Core/Data/Model/Data_Discovery.swift index cb8dd9be8..e5e4d01d7 100644 --- a/Core/Core/Data/Model/Data_Discovery.swift +++ b/Core/Core/Data/Model/Data_Discovery.swift @@ -108,14 +108,16 @@ public extension DataLayer.DiscoveryResponce { CourseItem(name: $0.name, org: $0.org, shortDescription: $0.shortDescription ?? "", imageURL: $0.media.image?.small ?? "", - isActive: nil, + hasAccess: true, courseStart: Date(iso8601: $0.start ?? ""), courseEnd: Date(iso8601: $0.end ?? ""), enrollmentStart: Date(iso8601: $0.enrollmentStart ?? ""), enrollmentEnd: Date(iso8601: $0.enrollmentEnd ?? ""), courseID: $0.courseID ?? "", numPages: pagination.numPages, - coursesCount: pagination.count) + coursesCount: pagination.count, + progressEarned: 0, + progressPossible: 0) }) return listReady } diff --git a/Core/Core/Data/Model/Data_Dashboard.swift b/Core/Core/Data/Model/Data_Enrollments.swift similarity index 93% rename from Core/Core/Data/Model/Data_Dashboard.swift rename to Core/Core/Data/Model/Data_Enrollments.swift index d39d8aa2d..527a69daa 100644 --- a/Core/Core/Data/Model/Data_Dashboard.swift +++ b/Core/Core/Data/Model/Data_Enrollments.swift @@ -1,5 +1,5 @@ // -// Data_Dashboard.swift +// Data_Enrollments.swift // Core // // Created by  Stepanok Ivan on 24.03.2023. @@ -29,7 +29,7 @@ public extension DataLayer { public let numPages: Int? public let currentPage: Int? public let start: Int? - public let results: [Result] + public let results: [Enrollment] enum CodingKeys: String, CodingKey { case next @@ -48,7 +48,7 @@ public extension DataLayer { numPages: Int?, currentPage: Int?, start: Int?, - results: [Result] + results: [Enrollment] ) { self.next = next self.previous = previous @@ -60,14 +60,15 @@ public extension DataLayer { } } - // MARK: - Result - struct Result: Codable { + // MARK: - Enrollment + struct Enrollment: Codable { public let auditAccessExpires: String? public let created: String public let mode: Mode public let isActive: Bool public let course: DashboardCourse public let courseModes: [CourseMode] + public let progress: CourseProgress? enum CodingKeys: String, CodingKey { case auditAccessExpires = "audit_access_expires" @@ -76,6 +77,7 @@ public extension DataLayer { case isActive = "is_active" case course case courseModes = "course_modes" + case progress = "course_progress" } public init( @@ -84,7 +86,8 @@ public extension DataLayer { mode: Mode, isActive: Bool, course: DashboardCourse, - courseModes: [CourseMode] + courseModes: [CourseMode], + progress: CourseProgress? ) { self.auditAccessExpires = auditAccessExpires self.created = created @@ -92,6 +95,7 @@ public extension DataLayer { self.isActive = isActive self.course = course self.courseModes = courseModes + self.progress = progress } } @@ -244,7 +248,7 @@ public extension DataLayer.CourseEnrollments { org: course.org, shortDescription: "", imageURL: fullImageURL, - isActive: true, + hasAccess: course.coursewareAccess.hasAccess, courseStart: course.start != nil ? Date(iso8601: course.start!) : nil, courseEnd: course.end != nil ? Date(iso8601: course.end!) : nil, enrollmentStart: course.start != nil @@ -255,7 +259,9 @@ public extension DataLayer.CourseEnrollments { : nil, courseID: course.id, numPages: enrollments.numPages ?? 1, - coursesCount: enrollments.count ?? 0 + coursesCount: enrollments.count ?? 0, + progressEarned: 0, + progressPossible: 0 ) } } diff --git a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift new file mode 100644 index 000000000..1102bae78 --- /dev/null +++ b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift @@ -0,0 +1,269 @@ +// +// Data_PrimaryEnrollment.swift +// Core +// +// Created by  Stepanok Ivan on 16.04.2024. +// + +import Foundation + +public extension DataLayer { + struct PrimaryEnrollment: Codable { + public let userTimezone: String? + public let enrollments: Enrollments? + public let primary: ActiveEnrollment? + + enum CodingKeys: String, CodingKey { + case userTimezone = "user_timezone" + case enrollments + case primary + } + + public init(userTimezone: String?, enrollments: Enrollments?, primary: ActiveEnrollment?) { + self.userTimezone = userTimezone + self.enrollments = enrollments + self.primary = primary + } + } + + // MARK: - Primary + struct ActiveEnrollment: Codable { + public let auditAccessExpires: Date? + public let created: String? + public let mode: String? + public let isActive: Bool? + public let course: DashboardCourse? + public let certificate: DataLayer.Certificate? + public let courseModes: [CourseMode]? + public let courseStatus: CourseStatus? + public let progress: CourseProgress? + public let courseAssignments: CourseAssignments? + + enum CodingKeys: String, CodingKey { + case auditAccessExpires = "audit_access_expires" + case created + case mode + case isActive = "is_active" + case course + case certificate + case courseModes = "course_modes" + case courseStatus = "course_status" + case progress = "course_progress" + case courseAssignments = "course_assignments" + } + + public init( + auditAccessExpires: Date?, + created: String?, + mode: String?, + isActive: Bool?, + course: DashboardCourse?, + certificate: DataLayer.Certificate?, + courseModes: [CourseMode]?, + courseStatus: CourseStatus?, + progress: CourseProgress?, + courseAssignments: CourseAssignments? + ) { + self.auditAccessExpires = auditAccessExpires + self.created = created + self.mode = mode + self.isActive = isActive + self.course = course + self.certificate = certificate + self.courseModes = courseModes + self.courseStatus = courseStatus + self.progress = progress + self.courseAssignments = courseAssignments + } + } + + // MARK: - CourseStatus + struct CourseStatus: Codable { + public let lastVisitedModuleID: String? + public let lastVisitedModulePath: [String]? + public let lastVisitedBlockID: String? + public let lastVisitedUnitDisplayName: String? + + enum CodingKeys: String, CodingKey { + case lastVisitedModuleID = "last_visited_module_id" + case lastVisitedModulePath = "last_visited_module_path" + case lastVisitedBlockID = "last_visited_block_id" + case lastVisitedUnitDisplayName = "last_visited_unit_display_name" + } + } + + // MARK: - CourseAssignments + struct CourseAssignments: Codable { + public let futureAssignments: [Assignment]? + public let pastAssignments: [Assignment]? + + enum CodingKeys: String, CodingKey { + case futureAssignments = "future_assignments" + case pastAssignments = "past_assignments" + } + + public init(futureAssignments: [Assignment]?, pastAssignments: [Assignment]?) { + self.futureAssignments = futureAssignments + self.pastAssignments = pastAssignments + } + } + + // MARK: - Assignment + struct Assignment: Codable { + public let assignmentType: String? + public let complete: Bool? + public let date: String? + public let dateType: String? + public let description: String? + public let learnerHasAccess: Bool? + public let link: String? + public let linkText: String? + public let title: String? + public let extraInfo: String? + public let firstComponentBlockID: String? + + enum CodingKeys: String, CodingKey { + case assignmentType = "assignment_type" + case complete + case 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" + } + + public init( + assignmentType: String?, + complete: Bool?, + date: String?, + dateType: String?, + description: String?, + learnerHasAccess: Bool?, + link: String?, + linkText: String?, + title: String?, + extraInfo: String?, + firstComponentBlockID: String? + ) { + self.assignmentType = assignmentType + self.complete = complete + self.date = date + self.dateType = dateType + self.description = description + self.learnerHasAccess = learnerHasAccess + self.link = link + self.linkText = linkText + self.title = title + self.extraInfo = extraInfo + self.firstComponentBlockID = firstComponentBlockID + } + } + + // MARK: - CourseProgress + struct CourseProgress: Codable { + public let assignmentsCompleted: Int? + public let totalAssignmentsCount: Int? + + enum CodingKeys: String, CodingKey { + case assignmentsCompleted = "assignments_completed" + case totalAssignmentsCount = "total_assignments_count" + } + + public init(assignmentsCompleted: Int?, totalAssignmentsCount: Int?) { + self.assignmentsCompleted = assignmentsCompleted + self.totalAssignmentsCount = totalAssignmentsCount + } + } +} + +public extension DataLayer.PrimaryEnrollment { + + func domain(baseURL: String) -> PrimaryEnrollment { + let primaryCourse = createPrimaryCourse(from: self.primary, baseURL: baseURL) + let courses = createCourseItems(from: self.enrollments, baseURL: baseURL) + + return PrimaryEnrollment( + primaryCourse: primaryCourse, + courses: courses, + totalPages: enrollments?.numPages ?? 1, + count: enrollments?.count ?? 1 + ) + } + + private func createPrimaryCourse(from primary: DataLayer.ActiveEnrollment?, baseURL: String) -> PrimaryCourse? { + guard let primary = primary else { return nil } + + let futureAssignments = primary.courseAssignments?.futureAssignments ?? [] + let pastAssignments = primary.courseAssignments?.pastAssignments ?? [] + + return PrimaryCourse( + name: primary.course?.name ?? "", + org: primary.course?.org ?? "", + courseID: primary.course?.id ?? "", + hasAccess: primary.course?.coursewareAccess.hasAccess ?? true, + courseStart: primary.course?.start.flatMap { Date(iso8601: $0) }, + courseEnd: primary.course?.end.flatMap { Date(iso8601: $0) }, + courseBanner: baseURL + (primary.course?.media.courseImage?.url ?? ""), + futureAssignments: futureAssignments.map { createAssignment(from: $0) }, + pastAssignments: pastAssignments.map { createAssignment(from: $0) }, + progressEarned: primary.progress?.assignmentsCompleted ?? 0, + progressPossible: primary.progress?.totalAssignmentsCount ?? 0, + lastVisitedBlockID: primary.courseStatus?.lastVisitedBlockID, + resumeTitle: primary.courseStatus?.lastVisitedUnitDisplayName + ) + } + + private func createAssignment(from assignment: DataLayer.Assignment) -> Assignment { + return Assignment( + type: assignment.assignmentType ?? "", + title: assignment.title ?? "", + description: assignment.description ?? "", + date: Date(iso8601: assignment.date ?? ""), + complete: assignment.complete ?? false, + firstComponentBlockId: assignment.firstComponentBlockID + ) + } + + private func createCourseItems(from enrollments: DataLayer.Enrollments?, baseURL: String) -> [CourseItem] { + return enrollments?.results.map { + createCourseItem( + from: $0, + baseURL: baseURL, + numPages: enrollments?.numPages ?? 1, + count: enrollments?.count ?? 0 + ) + } ?? [] + } + + private func createCourseItem( + from enrollment: DataLayer.Enrollment, + baseURL: String, + numPages: Int, + count: Int + ) -> CourseItem { + let imageUrl = enrollment.course.media.courseImage?.url ?? "" + let encodedUrl = imageUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let fullImageURL = baseURL + encodedUrl + + return CourseItem( + name: enrollment.course.name, + org: enrollment.course.org, + shortDescription: "", + imageURL: fullImageURL, + hasAccess: enrollment.course.coursewareAccess.hasAccess, + courseStart: enrollment.course.start.flatMap { Date(iso8601: $0) }, + courseEnd: enrollment.course.end.flatMap { Date(iso8601: $0) }, + enrollmentStart: enrollment.course.start.flatMap { Date(iso8601: $0) }, + enrollmentEnd: enrollment.course.end.flatMap { Date(iso8601: $0) }, + courseID: enrollment.course.id, + numPages: numPages, + coursesCount: count, + progressEarned: enrollment.progress?.assignmentsCompleted ?? 0, + progressPossible: enrollment.progress?.totalAssignmentsCount ?? 0 + ) + } +} diff --git a/Core/Core/Domain/Model/CourseItem.swift b/Core/Core/Domain/Model/CourseItem.swift index 9229417f1..67647e038 100644 --- a/Core/Core/Domain/Model/CourseItem.swift +++ b/Core/Core/Domain/Model/CourseItem.swift @@ -12,7 +12,7 @@ public struct CourseItem: Hashable { public let org: String public let shortDescription: String public let imageURL: String - public let isActive: Bool? + public let hasAccess: Bool public let courseStart: Date? public let courseEnd: Date? public let enrollmentStart: Date? @@ -20,24 +20,28 @@ public struct CourseItem: Hashable { public let courseID: String public let numPages: Int public let coursesCount: Int + public let progressEarned: Int + public let progressPossible: Int public init(name: String, org: String, shortDescription: String, imageURL: String, - isActive: Bool?, + hasAccess: Bool, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, courseID: String, numPages: Int, - coursesCount: Int) { + coursesCount: Int, + progressEarned: Int, + progressPossible: Int) { self.name = name self.org = org self.shortDescription = shortDescription self.imageURL = imageURL - self.isActive = isActive + self.hasAccess = hasAccess self.courseStart = courseStart self.courseEnd = courseEnd self.enrollmentStart = enrollmentStart @@ -45,5 +49,7 @@ public struct CourseItem: Hashable { self.courseID = courseID self.numPages = numPages self.coursesCount = coursesCount + self.progressEarned = progressEarned + self.progressPossible = progressPossible } } diff --git a/Core/Core/Domain/Model/PrimaryEnrollment.swift b/Core/Core/Domain/Model/PrimaryEnrollment.swift new file mode 100644 index 000000000..3f213aae5 --- /dev/null +++ b/Core/Core/Domain/Model/PrimaryEnrollment.swift @@ -0,0 +1,93 @@ +// +// PrimaryEnrollment.swift +// Core +// +// Created by  Stepanok Ivan on 16.04.2024. +// + +import Foundation + +public struct PrimaryEnrollment: Hashable { + public let primaryCourse: PrimaryCourse? + public var courses: [CourseItem] + public let totalPages: Int + public let count: Int + + public init(primaryCourse: PrimaryCourse?, courses: [CourseItem], totalPages: Int, count: Int) { + self.primaryCourse = primaryCourse + self.courses = courses + self.totalPages = totalPages + self.count = count + } +} + +public struct PrimaryCourse: Hashable { + public let name: String + public let org: String + public let courseID: String + public let hasAccess: Bool + public let courseStart: Date? + public let courseEnd: Date? + public let courseBanner: String + public let futureAssignments: [Assignment] + public let pastAssignments: [Assignment] + public let progressEarned: Int + public let progressPossible: Int + public let lastVisitedBlockID: String? + public let resumeTitle: String? + + public init( + name: String, + org: String, + courseID: String, + hasAccess: Bool, + courseStart: Date?, + courseEnd: Date?, + courseBanner: String, + futureAssignments: [Assignment], + pastAssignments: [Assignment], + progressEarned: Int, + progressPossible: Int, + lastVisitedBlockID: String?, + resumeTitle: String? + ) { + self.name = name + self.org = org + self.courseID = courseID + self.hasAccess = hasAccess + self.courseStart = courseStart + self.courseEnd = courseEnd + self.courseBanner = courseBanner + self.futureAssignments = futureAssignments + self.pastAssignments = pastAssignments + self.progressEarned = progressEarned + self.progressPossible = progressPossible + self.lastVisitedBlockID = lastVisitedBlockID + self.resumeTitle = resumeTitle + } +} + +public struct Assignment: Hashable { + public let type: String + public let title: String + public let description: String? + public let date: Date + public let complete: Bool + public let firstComponentBlockId: String? + + public init( + type: String, + title: String, + description: String?, + date: Date, + complete: Bool, + firstComponentBlockId: String? + ) { + self.type = type + self.title = title + self.description = description + self.date = date + self.complete = complete + self.firstComponentBlockId = firstComponentBlockId + } +} diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index bbdb6834b..7be0c84ec 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -75,6 +75,8 @@ public extension Date { } public enum DateStringStyle { + case courseStartsMonthDDYear + case courseEndsMonthDDYear case startDDMonthYear case endedMonthDay case mmddyy @@ -103,6 +105,10 @@ public extension Date { dateFormatter.locale = Locale(identifier: "en_US_POSIX") switch style { + case .courseStartsMonthDDYear: + dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmDdYyyy + case .courseEndsMonthDDYear: + dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmDdYyyy case .endedMonthDay: dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmmDd case .mmddyy: @@ -122,6 +128,14 @@ public extension Date { let date = dateFormatter.string(from: self) switch style { + case .courseStartsMonthDDYear: + return CoreLocalization.Date.courseStarts + " " + date + case .courseEndsMonthDDYear: + if Date() < self { + return CoreLocalization.Date.courseEnds + " " + date + } else { + return CoreLocalization.Date.courseEnded + " " + date + } case .endedMonthDay: return CoreLocalization.Date.ended + " " + date case .mmddyy, .monthYear: diff --git a/Core/Core/Extensions/Notification.swift b/Core/Core/Extensions/Notification.swift index 9f792fb2a..d8e99731b 100644 --- a/Core/Core/Extensions/Notification.swift +++ b/Core/Core/Extensions/Notification.swift @@ -9,6 +9,7 @@ import Foundation public extension Notification.Name { static let onCourseEnrolled = Notification.Name("onCourseEnrolled") + static let onblockCompletionRequested = Notification.Name("onblockCompletionRequested") static let onTokenRefreshFailed = Notification.Name("onTokenRefreshFailed") static let onActualVersionReceived = Notification.Name("onActualVersionReceived") static let onAppUpgradeAccountSettingsTapped = Notification.Name("onAppUpgradeAccountSettingsTapped") diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index e791925ba..9db5e1087 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -247,7 +247,7 @@ public extension View { .onTapGesture(perform: action) } } - + func onTapBackground(enabled: Bool, _ action: @escaping () -> Void) -> some View { background( onTapBackgroundContent(enabled: enabled, action) diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index dfa0c9677..ea47e8ee4 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -74,6 +74,7 @@ public enum CoreAssets { public static let handouts = ImageAsset(name: "handouts") public static let dashboard = ImageAsset(name: "dashboard") public static let discovery = ImageAsset(name: "discovery") + public static let learn = ImageAsset(name: "learn") public static let profile = ImageAsset(name: "profile") public static let programs = ImageAsset(name: "programs") public static let addPhoto = ImageAsset(name: "addPhoto") @@ -102,10 +103,12 @@ public enum CoreAssets { public static let certificateBadge = ImageAsset(name: "certificateBadge") public static let check = ImageAsset(name: "check") public static let checkEmail = ImageAsset(name: "checkEmail") + public static let chevronRight = ImageAsset(name: "chevron_right") public static let clearInput = ImageAsset(name: "clearInput") public static let edit = ImageAsset(name: "edit") public static let favorite = ImageAsset(name: "favorite") public static let goodWork = ImageAsset(name: "goodWork") + public static let learnEmpty = ImageAsset(name: "learn_empty") public static let airmail = ImageAsset(name: "airmail") public static let defaultMail = ImageAsset(name: "defaultMail") public static let fastmail = ImageAsset(name: "fastmail") @@ -119,9 +122,11 @@ public enum CoreAssets { public static let noWifiMini = ImageAsset(name: "noWifiMini") public static let notAvaliable = ImageAsset(name: "notAvaliable") public static let playVideo = ImageAsset(name: "playVideo") + public static let resumeCourse = ImageAsset(name: "resumeCourse") public static let settings = ImageAsset(name: "settings") public static let star = ImageAsset(name: "star") public static let starOutline = ImageAsset(name: "star_outline") + public static let viewAll = ImageAsset(name: "viewAll") public static let warning = ImageAsset(name: "warning") public static let warningFilled = ImageAsset(name: "warning_filled") } diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index a5782d497..4bd41f9eb 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -79,6 +79,12 @@ public enum CoreLocalization { } } public enum Date { + /// Course Ended + public static let courseEnded = CoreLocalization.tr("Localizable", "DATE.COURSE_ENDED", fallback: "Course Ended") + /// Course Ends + public static let courseEnds = CoreLocalization.tr("Localizable", "DATE.COURSE_ENDS", fallback: "Course Ends") + /// Course Starts + public static let courseStarts = CoreLocalization.tr("Localizable", "DATE.COURSE_STARTS", fallback: "Course Starts") /// Ended public static let ended = CoreLocalization.tr("Localizable", "DATE.ENDED", fallback: "Ended") /// Just now @@ -93,6 +99,8 @@ public enum CoreLocalization { public static let mmmDdYyyy = CoreLocalization.tr("Localizable", "DATE_FORMAT.MMM_DD_YYYY", fallback: "MMM dd, yyyy") /// MMMM dd public static let mmmmDd = CoreLocalization.tr("Localizable", "DATE_FORMAT.MMMM_DD", fallback: "MMMM dd") + /// MMMM dd, yyyy + public static let mmmmDdYyyy = CoreLocalization.tr("Localizable", "DATE_FORMAT.MMMM_DD_YYYY", fallback: "MMMM dd, yyyy") } public enum DownloadManager { /// Completed @@ -136,6 +144,8 @@ public enum CoreLocalization { public static let discovery = CoreLocalization.tr("Localizable", "MAINSCREEN.DISCOVERY", fallback: "Discover") /// In developing public static let inDeveloping = CoreLocalization.tr("Localizable", "MAINSCREEN.IN_DEVELOPING", fallback: "In developing") + /// Learn + public static let learn = CoreLocalization.tr("Localizable", "MAINSCREEN.LEARN", fallback: "Learn") /// Profile public static let profile = CoreLocalization.tr("Localizable", "MAINSCREEN.PROFILE", fallback: "Profile") /// Programs diff --git a/Core/Core/View/Base/CourseCellView.swift b/Core/Core/View/Base/CourseCellView.swift index 6166b81a3..72f02b2bb 100644 --- a/Core/Core/View/Base/CourseCellView.swift +++ b/Core/Core/View/Base/CourseCellView.swift @@ -130,14 +130,17 @@ struct CourseCellView_Previews: PreviewProvider { org: "Edx", shortDescription: "", imageURL: "https://thumbs.dreamstime.com/b/logo-edx-samsung-tablet-edx-massive-open-online-course-mooc-provider-hosts-online-university-level-courses-wide-117763805.jpg", - isActive: true, + hasAccess: true, courseStart: Date(iso8601: "2032-05-26T12:13:14Z"), courseEnd: Date(iso8601: "2033-05-26T12:13:14Z"), enrollmentStart: nil, enrollmentEnd: nil, courseID: "1", numPages: 1, - coursesCount: 10) + coursesCount: 10, + progressEarned: 4, + progressPossible: 10 + ) static var previews: some View { ZStack { diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift index bd558d197..ef300e279 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift @@ -49,6 +49,9 @@ public struct ScrollSlidingTabBar: View { } .onTapGesture {} // Fix button tapable area bug – https://forums.developer.apple.com/forums/thread/745059 + .onAppear { + proxy.scrollTo(selection, anchor: .center) + } .onChange(of: selection) { newValue in withAnimation { proxy.scrollTo(newValue, anchor: .center) diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 27196487a..44186571a 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -11,6 +11,7 @@ "MAINSCREEN.IN_DEVELOPING" = "In developing"; "MAINSCREEN.PROGRAMS" = "Programs"; "MAINSCREEN.PROFILE" = "Profile"; +"MAINSCREEN.LEARN" = "Learn"; "VIEW.SNACKBAR.TRY_AGAIN_BTN" = "Try Again"; @@ -44,6 +45,9 @@ "ERROR.RELOAD" = "Reload"; +"DATE.COURSE_STARTS" = "Course Starts"; +"DATE.COURSE_ENDS" = "Course Ends"; +"DATE.COURSE_ENDED" = "Course Ended"; "DATE.ENDED" = "Ended"; "DATE.START" = "Start"; "DATE.STARTED" = "Started"; @@ -65,6 +69,7 @@ "DATE_FORMAT.MMMM_DD" = "MMMM dd"; "DATE_FORMAT.MMM_DD_YYYY" = "MMM dd, yyyy"; +"DATE_FORMAT.MMMM_DD_YYYY" = "MMMM dd, yyyy"; "DOWNLOAD_MANAGER.DOWNLOAD" = "Download"; "DOWNLOAD_MANAGER.DOWNLOADED" = "Downloaded"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index 1da07ab04..1026b82e0 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -11,6 +11,7 @@ "MAINSCREEN.IN_DEVELOPING" = "В розробці"; "MAINSCREEN.PROGRAMS" = "Програми"; "MAINSCREEN.PROFILE" = "Профіль"; +"MAINSCREEN.LEARN" = "Навчання"; "VIEW.SNACKBAR.TRY_AGAIN_BTN" = "Спробувати ще"; diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index dda82f3ae..d25cbc5ad 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 @@ - + @@ -65,8 +65,8 @@ + - @@ -101,4 +101,4 @@ - + \ No newline at end of file diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index 1b01e7a59..dfec16d70 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -64,6 +64,7 @@ public class CourseInteractor: CourseInteractorProtocol { } public func blockCompletionRequest(courseID: String, blockID: String) async throws { + NotificationCenter.default.post(name: .onblockCompletionRequested, object: courseID) return try await repository.blockCompletionRequest(courseID: courseID, blockID: blockID) } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index d79f12034..bc70c08f3 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -141,7 +141,7 @@ public struct CourseContainerView: View { courseDatesViewModel.resetEventState() } } - + private func backButton(containerWidth: CGFloat) -> some View { ZStack(alignment: .topLeading) { if !collapsed { @@ -338,6 +338,7 @@ struct CourseScreensView_Previews: PreviewProvider { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ), courseDatesViewModel: CourseDatesViewModel( @@ -350,7 +351,9 @@ struct CourseScreensView_Previews: PreviewProvider { courseName: "a", analytics: CourseAnalyticsMock() ), - courseID: "", title: "Title of Course") + courseID: "", + title: "Title of Course" + ) } } #endif diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index e8ce69707..f2678a915 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -14,13 +14,14 @@ public enum CourseTab: Int, CaseIterable, Identifiable { public var id: Int { rawValue } - case course case videos case discussion case dates case handounds +} +extension CourseTab { public var title: String { switch self { case .course: @@ -54,7 +55,7 @@ public enum CourseTab: Int, CaseIterable, Identifiable { public class CourseContainerViewModel: BaseCourseViewModel { - @Published public var selection: Int = CourseTab.course.rawValue + @Published public var selection: Int @Published var isShowProgress = true @Published var isShowRefresh = false @Published var courseStructure: CourseStructure? @@ -85,6 +86,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { let courseEnd: Date? let enrollmentStart: Date? let enrollmentEnd: Date? + let lastVisitedBlockID: String? var courseDownloadTasks: [DownloadDataTask] = [] private(set) var waitingDownloads: [CourseBlock]? @@ -109,7 +111,9 @@ public class CourseContainerViewModel: BaseCourseViewModel { courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - coreAnalytics: CoreAnalytics + lastVisitedBlockID: String?, + coreAnalytics: CoreAnalytics, + selection: CourseTab = CourseTab.course ) { self.interactor = interactor self.authInteractor = authInteractor @@ -125,12 +129,43 @@ public class CourseContainerViewModel: BaseCourseViewModel { self.storage = storage self.userSettings = storage.userSettings self.isInternetAvaliable = connectivity.isInternetAvaliable + self.lastVisitedBlockID = lastVisitedBlockID self.coreAnalytics = coreAnalytics + self.selection = selection.rawValue super.init(manager: manager) addObservers() } + func openLastVisitedBlock() { + guard let continueWith = continueWith, + let courseStructure = courseStructure else { return } + let chapter = courseStructure.childs[continueWith.chapterIndex] + let sequential = chapter.childs[continueWith.sequentialIndex] + let continueUnit = sequential.childs[continueWith.verticalIndex] + + var continueBlock: CourseBlock? + continueUnit.childs.forEach { block in + if block.id == continueWith.lastVisitedBlockId { + continueBlock = block + } + } + + trackResumeCourseClicked( + blockId: continueBlock?.id ?? "" + ) + + router.showCourseUnit( + courseName: courseStructure.displayName, + blockId: continueBlock?.id ?? "", + courseID: courseStructure.id, + verticalIndex: continueWith.verticalIndex, + chapters: courseStructure.childs, + chapterIndex: continueWith.chapterIndex, + sequentialIndex: continueWith.sequentialIndex + ) + } + @MainActor func getCourseBlocks(courseID: String, withProgress: Bool = true) async { guard let courseStart, courseStart < Date() else { return } @@ -144,13 +179,10 @@ public class CourseContainerViewModel: BaseCourseViewModel { isShowProgress = false isShowRefresh = false if let courseStructure { - let continueWith = try await getResumeBlock( + try await getResumeBlock( courseID: courseID, courseStructure: courseStructure ) - withAnimation { - self.continueWith = continueWith - } } } else { courseStructure = try await interactor.getLoadedCourseBlocks(courseID: courseID) @@ -237,12 +269,22 @@ public class CourseContainerViewModel: BaseCourseViewModel { } @MainActor - private func getResumeBlock(courseID: String, courseStructure: CourseStructure) async throws -> ContinueWith? { - let result = try await interactor.resumeBlock(courseID: courseID) - return findContinueVertical( - blockID: result.blockID, - courseStructure: courseStructure - ) + private func getResumeBlock(courseID: String, courseStructure: CourseStructure) async throws { + if let lastVisitedBlockID { + self.continueWith = findContinueVertical( + blockID: lastVisitedBlockID, + courseStructure: courseStructure + ) + openLastVisitedBlock() + } else { + let result = try await interactor.resumeBlock(courseID: courseID) + withAnimation { + self.continueWith = findContinueVertical( + blockID: result.blockID, + courseStructure: courseStructure + ) + } + } } @MainActor diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 8895cb65c..936ba835e 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -100,28 +100,7 @@ public struct CourseOutlineView: View { data: continueWith, courseContinueUnit: continueUnit ) { - var continueBlock: CourseBlock? - continueUnit.childs.forEach { block in - if block.id == continueWith.lastVisitedBlockId { - continueBlock = block - } - } - - viewModel.trackResumeCourseClicked( - blockId: continueBlock?.id ?? "" - ) - - if let course = viewModel.courseStructure { - viewModel.router.showCourseUnit( - courseName: course.displayName, - blockId: continueBlock?.id ?? "", - courseID: course.id, - verticalIndex: continueWith.verticalIndex, - chapters: course.childs, - chapterIndex: continueWith.chapterIndex, - sequentialIndex: continueWith.sequentialIndex - ) - } + viewModel.openLastVisitedBlock() } } @@ -345,6 +324,7 @@ struct CourseOutlineView_Previews: PreviewProvider { courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) Task { diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 435539972..cd69ebbb3 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -40,6 +40,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) @@ -148,6 +149,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) @@ -207,6 +209,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) @@ -250,6 +253,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) @@ -290,6 +294,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) @@ -330,6 +335,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) @@ -462,6 +468,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure @@ -583,6 +590,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure @@ -704,6 +712,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure @@ -826,6 +835,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure @@ -956,6 +966,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure @@ -1086,6 +1097,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure @@ -1236,6 +1248,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index 2a7b812fe..c6e7e4e52 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -7,14 +7,24 @@ objects = { /* Begin PBXBuildFile section */ - 027DB33328D8BDBA002B6862 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB33228D8BDBA002B6862 /* DashboardView.swift */; }; + 0277241E2BCE9E1500C2908D /* PrimaryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0277241D2BCE9E1500C2908D /* PrimaryCardView.swift */; }; + 027724202BCEA16C00C2908D /* ProgressLineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0277241F2BCEA16C00C2908D /* ProgressLineView.swift */; }; + 027DB33328D8BDBA002B6862 /* ListDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB33228D8BDBA002B6862 /* ListDashboardView.swift */; }; 027DB33928D8D9F6002B6862 /* DashboardEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB33828D8D9F6002B6862 /* DashboardEndpoint.swift */; }; 027DB33D28D8DB5E002B6862 /* DashboardRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB33C28D8DB5E002B6862 /* DashboardRepository.swift */; }; 027DB33F28D8E605002B6862 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 027DB33E28D8E605002B6862 /* Core.framework */; }; 027DB34328D8E89B002B6862 /* DashboardInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB34228D8E89B002B6862 /* DashboardInteractor.swift */; }; - 027DB34528D8E9D2002B6862 /* DashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB34428D8E9D2002B6862 /* DashboardViewModel.swift */; }; + 027DB34528D8E9D2002B6862 /* ListDashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB34428D8E9D2002B6862 /* ListDashboardViewModel.swift */; }; + 028895672BE3B34F00102D8C /* NoCoursesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028895662BE3B34E00102D8C /* NoCoursesView.swift */; }; + 02935B6F2BCEC91100B22F66 /* PrimaryCourseDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B6E2BCEC91100B22F66 /* PrimaryCourseDashboardView.swift */; }; + 02935B712BCEC91F00B22F66 /* PrimaryCourseDashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B702BCEC91F00B22F66 /* PrimaryCourseDashboardViewModel.swift */; }; + 02935B772BCFB2C100B22F66 /* CourseCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B762BCFB2C100B22F66 /* CourseCardView.swift */; }; 02A48B18295ACE200033D5E0 /* DashboardCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02A48B16295ACE200033D5E0 /* DashboardCoreModel.xcdatamodeld */; }; 02A48B1A295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A48B19295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift */; }; + 02A98F112BD96814009B46BD /* DropDownMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A98F102BD96814009B46BD /* DropDownMenu.swift */; }; + 02A98F132BD96B9D009B46BD /* AllCoursesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A98F122BD96B9D009B46BD /* AllCoursesView.swift */; }; + 02A98F152BD96BC2009B46BD /* AllCoursesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A98F142BD96BC2009B46BD /* AllCoursesViewModel.swift */; }; + 02A98F172BD97886009B46BD /* CategoryFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A98F162BD97885009B46BD /* CategoryFilterView.swift */; }; 02A9A90B2978194100B55797 /* DashboardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9A90A2978194100B55797 /* DashboardViewModelTests.swift */; }; 02A9A90C2978194100B55797 /* Dashboard.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02EF39E728D89F560058F6BD /* Dashboard.framework */; platformFilter = ios; }; 02A9A92929781A4D00B55797 /* DashboardMock.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9A92829781A4D00B55797 /* DashboardMock.generated.swift */; }; @@ -39,14 +49,24 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 027DB33228D8BDBA002B6862 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; + 0277241D2BCE9E1500C2908D /* PrimaryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryCardView.swift; sourceTree = ""; }; + 0277241F2BCEA16C00C2908D /* ProgressLineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressLineView.swift; sourceTree = ""; }; + 027DB33228D8BDBA002B6862 /* ListDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListDashboardView.swift; sourceTree = ""; }; 027DB33828D8D9F6002B6862 /* DashboardEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardEndpoint.swift; sourceTree = ""; }; 027DB33C28D8DB5E002B6862 /* DashboardRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardRepository.swift; sourceTree = ""; }; 027DB33E28D8E605002B6862 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 027DB34228D8E89B002B6862 /* DashboardInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardInteractor.swift; sourceTree = ""; }; - 027DB34428D8E9D2002B6862 /* DashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModel.swift; sourceTree = ""; }; + 027DB34428D8E9D2002B6862 /* ListDashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListDashboardViewModel.swift; sourceTree = ""; }; + 028895662BE3B34E00102D8C /* NoCoursesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCoursesView.swift; sourceTree = ""; }; + 02935B6E2BCEC91100B22F66 /* PrimaryCourseDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryCourseDashboardView.swift; sourceTree = ""; }; + 02935B702BCEC91F00B22F66 /* PrimaryCourseDashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryCourseDashboardViewModel.swift; sourceTree = ""; }; + 02935B762BCFB2C100B22F66 /* CourseCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseCardView.swift; sourceTree = ""; }; 02A48B17295ACE200033D5E0 /* DashboardCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DashboardCoreModel.xcdatamodel; sourceTree = ""; }; 02A48B19295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPersistenceProtocol.swift; sourceTree = ""; }; + 02A98F102BD96814009B46BD /* DropDownMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropDownMenu.swift; sourceTree = ""; }; + 02A98F122BD96B9D009B46BD /* AllCoursesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllCoursesView.swift; sourceTree = ""; }; + 02A98F142BD96BC2009B46BD /* AllCoursesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllCoursesViewModel.swift; sourceTree = ""; }; + 02A98F162BD97885009B46BD /* CategoryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryFilterView.swift; sourceTree = ""; }; 02A9A9082978194100B55797 /* DashboardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DashboardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 02A9A90A2978194100B55797 /* DashboardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModelTests.swift; sourceTree = ""; }; 02A9A92829781A4D00B55797 /* DashboardMock.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DashboardMock.generated.swift; sourceTree = ""; }; @@ -109,6 +129,19 @@ path = Persistence; sourceTree = ""; }; + 0277241C2BCE9DF300C2908D /* Elements */ = { + isa = PBXGroup; + children = ( + 0277241D2BCE9E1500C2908D /* PrimaryCardView.swift */, + 02A98F102BD96814009B46BD /* DropDownMenu.swift */, + 02A98F162BD97885009B46BD /* CategoryFilterView.swift */, + 02935B762BCFB2C100B22F66 /* CourseCardView.swift */, + 0277241F2BCEA16C00C2908D /* ProgressLineView.swift */, + 028895662BE3B34E00102D8C /* NoCoursesView.swift */, + ); + path = Elements; + sourceTree = ""; + }; 027DB33628D8D851002B6862 /* Domain */ = { isa = PBXGroup; children = ( @@ -181,8 +214,13 @@ 02F6EF3F28D9ECA200835477 /* Presentation */ = { isa = PBXGroup; children = ( - 027DB33228D8BDBA002B6862 /* DashboardView.swift */, - 027DB34428D8E9D2002B6862 /* DashboardViewModel.swift */, + 0277241C2BCE9DF300C2908D /* Elements */, + 02935B6E2BCEC91100B22F66 /* PrimaryCourseDashboardView.swift */, + 02935B702BCEC91F00B22F66 /* PrimaryCourseDashboardViewModel.swift */, + 02A98F122BD96B9D009B46BD /* AllCoursesView.swift */, + 02A98F142BD96BC2009B46BD /* AllCoursesViewModel.swift */, + 027DB33228D8BDBA002B6862 /* ListDashboardView.swift */, + 027DB34428D8E9D2002B6862 /* ListDashboardViewModel.swift */, 02F3BFE029252FCB0051930C /* DashboardRouter.swift */, 02F175322A4DABBF0019CD70 /* DashboardAnalytics.swift */, ); @@ -455,14 +493,24 @@ buildActionMask = 2147483647; files = ( 02A48B1A295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift in Sources */, + 028895672BE3B34F00102D8C /* NoCoursesView.swift in Sources */, + 02935B6F2BCEC91100B22F66 /* PrimaryCourseDashboardView.swift in Sources */, + 02A98F172BD97886009B46BD /* CategoryFilterView.swift in Sources */, 027DB33D28D8DB5E002B6862 /* DashboardRepository.swift in Sources */, + 02935B712BCEC91F00B22F66 /* PrimaryCourseDashboardViewModel.swift in Sources */, + 02A98F112BD96814009B46BD /* DropDownMenu.swift in Sources */, 02A48B18295ACE200033D5E0 /* DashboardCoreModel.xcdatamodeld in Sources */, + 02935B772BCFB2C100B22F66 /* CourseCardView.swift in Sources */, 02F175332A4DABBF0019CD70 /* DashboardAnalytics.swift in Sources */, - 027DB34528D8E9D2002B6862 /* DashboardViewModel.swift in Sources */, - 027DB33328D8BDBA002B6862 /* DashboardView.swift in Sources */, + 027DB34528D8E9D2002B6862 /* ListDashboardViewModel.swift in Sources */, + 0277241E2BCE9E1500C2908D /* PrimaryCardView.swift in Sources */, + 027DB33328D8BDBA002B6862 /* ListDashboardView.swift in Sources */, + 02A98F152BD96BC2009B46BD /* AllCoursesViewModel.swift in Sources */, + 02A98F132BD96B9D009B46BD /* AllCoursesView.swift in Sources */, 02F3BFE129252FCB0051930C /* DashboardRouter.swift in Sources */, 027DB33928D8D9F6002B6862 /* DashboardEndpoint.swift in Sources */, 02F6EF4828D9ED8300835477 /* Strings.swift in Sources */, + 027724202BCEA16C00C2908D /* ProgressLineView.swift in Sources */, 97E7DF0B2B7A3EAF00A2A09B /* CourseEnrollmentsMock.swift in Sources */, 027DB34328D8E89B002B6862 /* DashboardInteractor.swift in Sources */, ); diff --git a/Dashboard/Dashboard/Data/DashboardRepository.swift b/Dashboard/Dashboard/Data/DashboardRepository.swift index 65816872f..df8cea243 100644 --- a/Dashboard/Dashboard/Data/DashboardRepository.swift +++ b/Dashboard/Dashboard/Data/DashboardRepository.swift @@ -9,8 +9,11 @@ import Foundation import Core public protocol DashboardRepositoryProtocol { - func getMyCourses(page: Int) async throws -> [CourseItem] - func getMyCoursesOffline() throws -> [CourseItem] + func getEnrollments(page: Int) async throws -> [CourseItem] + func getEnrollmentsOffline() throws -> [CourseItem] + func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment + func getPrimaryEnrollmentOffline() async throws -> PrimaryEnrollment + func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment } public class DashboardRepository: DashboardRepositoryProtocol { @@ -27,39 +30,58 @@ public class DashboardRepository: DashboardRepositoryProtocol { self.persistence = persistence } - public func getMyCourses(page: Int) async throws -> [CourseItem] { + public func getEnrollments(page: Int) async throws -> [CourseItem] { let result = try await api.requestData( - DashboardEndpoint.getMyCourses(username: storage.user?.username ?? "", page: page) + DashboardEndpoint.getEnrollments(username: storage.user?.username ?? "", page: page) ) .mapResponse(DataLayer.CourseEnrollments.self) .domain(baseURL: config.baseURL.absoluteString) - persistence.saveMyCourses(items: result) + persistence.saveEnrollments(items: result) return result } - public func getMyCoursesOffline() throws -> [CourseItem] { - return try persistence.loadMyCourses() + public func getEnrollmentsOffline() throws -> [CourseItem] { + return try persistence.loadEnrollments() } + public func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment { + let result = try await api.requestData( + DashboardEndpoint.getPrimaryEnrollment( + username: storage.user?.username ?? "", + pageSize: pageSize + ) + ) + .mapResponse(DataLayer.PrimaryEnrollment.self) + .domain(baseURL: config.baseURL.absoluteString) + persistence.savePrimaryEnrollment(enrollments: result) + return result + } + + public func getPrimaryEnrollmentOffline() async throws -> PrimaryEnrollment { + return try persistence.loadPrimaryEnrollment() + } + + public func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment { + let result = try await api.requestData( + DashboardEndpoint.getAllCourses( + username: storage.user?.username ?? "", + filteredBy: filteredBy, + page: page + ) + ) + .mapResponse(DataLayer.PrimaryEnrollment.self) + .domain(baseURL: config.baseURL.absoluteString) + return result + } } +// swiftlint:disable all // Mark - For testing and SwiftUI preview #if DEBUG class DashboardRepositoryMock: DashboardRepositoryProtocol { - func getCourseEnrollments(baseURL: String) async throws -> [CourseItem] { - do { - let courseEnrollments = try - DashboardRepository.CourseEnrollmentsJSON.data(using: .utf8)! - .mapResponse(DataLayer.CourseEnrollments.self) - .domain(baseURL: baseURL) - return courseEnrollments - } catch { - throw error - } - } - func getMyCourses(page: Int) async throws -> [CourseItem] { + func getEnrollments(page: Int) async throws -> [CourseItem] { var models: [CourseItem] = [] for i in 0...10 { models.append( @@ -68,20 +90,104 @@ class DashboardRepositoryMock: DashboardRepositoryProtocol { org: "Organization", shortDescription: "shortDescription", imageURL: "", - isActive: true, + hasAccess: true, courseStart: nil, courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, courseID: "course_id_\(i)", numPages: 1, - coursesCount: 0 + coursesCount: 0, + progressEarned: 0, + progressPossible: 0 ) ) } return models } - func getMyCoursesOffline() throws -> [CourseItem] { return [] } + func getEnrollmentsOffline() throws -> [CourseItem] { return [] } + + public func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment { + + var courses: [CourseItem] = [] + for i in 0...10 { + courses.append( + CourseItem( + name: "Course name \(i)", + org: "Organization", + shortDescription: "shortDescription", + imageURL: "", + hasAccess: true, + courseStart: nil, + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil, + courseID: "course_id_\(i)", + numPages: 1, + coursesCount: 0, + progressEarned: 4, + progressPossible: 10 + ) + ) + } + + let futureAssignment = Assignment( + type: "Final Exam", + title: "Subsection 3", + description: "", + date: Date(), + complete: false, + firstComponentBlockId: nil + ) + + let primaryCourse = PrimaryCourse( + name: "Primary Course", + org: "Organization", + courseID: "123", + hasAccess: true, + courseStart: Date(), + courseEnd: Date(), + courseBanner: "https://thumbs.dreamstime.com/b/logo-edx-samsung-tablet-edx-massive-open-online-course-mooc-provider-hosts-online-university-level-courses-wide-117763805.jpg", + futureAssignments: [futureAssignment], + pastAssignments: [futureAssignment], + progressEarned: 2, + progressPossible: 5, + lastVisitedBlockID: nil, + resumeTitle: nil + ) + return PrimaryEnrollment(primaryCourse: primaryCourse, courses: courses, totalPages: 1, count: 1) + } + + func getPrimaryEnrollmentOffline() async throws -> Core.PrimaryEnrollment { + Core.PrimaryEnrollment(primaryCourse: nil, courses: [], totalPages: 1, count: 1) + } + + func getAllCourses(filteredBy: String, page: Int) async throws -> Core.PrimaryEnrollment { + var courses: [CourseItem] = [] + for i in 0...10 { + courses.append( + CourseItem( + name: "Course name \(i)", + org: "Organization", + shortDescription: "shortDescription", + imageURL: "", + hasAccess: true, + courseStart: nil, + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil, + courseID: "course_id_\(i)", + numPages: 1, + coursesCount: 0, + progressEarned: 4, + progressPossible: 10 + ) + ) + } + + return PrimaryEnrollment(primaryCourse: nil, courses: courses, totalPages: 1, count: 1) + } } #endif +// swiftlint:enable all diff --git a/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift b/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift index 1d6845214..cda65b250 100644 --- a/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift +++ b/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift @@ -10,18 +10,24 @@ import Core import Alamofire enum DashboardEndpoint: EndPointType { - case getMyCourses(username: String, page: Int) + case getEnrollments(username: String, page: Int) + case getPrimaryEnrollment(username: String, pageSize: Int) + case getAllCourses(username: String, filteredBy: String, page: Int) var path: String { switch self { - case let .getMyCourses(username, _): + case let .getEnrollments(username, _): return "/api/mobile/v3/users/\(username)/course_enrollments" + case let .getPrimaryEnrollment(username, _): + return "/api/mobile/v4/users/\(username)/course_enrollments" + case let .getAllCourses(username, _, _): + return "/api/mobile/v4/users/\(username)/course_enrollments" } } var httpMethod: HTTPMethod { switch self { - case .getMyCourses: + case .getEnrollments, .getPrimaryEnrollment, .getAllCourses: return .get } } @@ -32,11 +38,26 @@ enum DashboardEndpoint: EndPointType { var task: HTTPTask { switch self { - case let .getMyCourses(_, page): + case let .getEnrollments(_, page): let params: Parameters = [ "page": page ] return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + + case let .getPrimaryEnrollment(_, pageSize): + let params: Parameters = [ + "page_size": pageSize + ] + return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + + case let .getAllCourses(_, filteredBy, page): + let params: Parameters = [ + "page_size": 10, + "status": filteredBy, + "requested_fields": "course_progress", + "page": page + ] + return .requestParameters(parameters: params, encoding: URLEncoding.queryString) } } } diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents index eeee515fe..d3bea41fc 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents +++ b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents @@ -1,5 +1,18 @@ - + + + + + + + + + + + + + + @@ -9,10 +22,13 @@ + + + @@ -20,4 +36,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift b/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift index 14bad2aaa..3747d2c8e 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift +++ b/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift @@ -9,8 +9,10 @@ import CoreData import Core public protocol DashboardPersistenceProtocol { - func loadMyCourses() throws -> [CourseItem] - func saveMyCourses(items: [CourseItem]) + func loadEnrollments() throws -> [CourseItem] + func saveEnrollments(items: [CourseItem]) + func loadPrimaryEnrollment() throws -> PrimaryEnrollment + func savePrimaryEnrollment(enrollments: PrimaryEnrollment) } public final class DashboardBundle { diff --git a/Dashboard/Dashboard/Domain/DashboardInteractor.swift b/Dashboard/Dashboard/Domain/DashboardInteractor.swift index 8e84d847b..0d55f0a4e 100644 --- a/Dashboard/Dashboard/Domain/DashboardInteractor.swift +++ b/Dashboard/Dashboard/Domain/DashboardInteractor.swift @@ -10,8 +10,11 @@ import Core //sourcery: AutoMockable public protocol DashboardInteractorProtocol { - func getMyCourses(page: Int) async throws -> [CourseItem] - func discoveryOffline() throws -> [CourseItem] + func getEnrollments(page: Int) async throws -> [CourseItem] + func getEnrollmentsOffline() throws -> [CourseItem] + func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment + func getPrimaryEnrollmentOffline() async throws -> PrimaryEnrollment + func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment } public class DashboardInteractor: DashboardInteractorProtocol { @@ -23,12 +26,24 @@ public class DashboardInteractor: DashboardInteractorProtocol { } @discardableResult - public func getMyCourses(page: Int) async throws -> [CourseItem] { - return try await repository.getMyCourses(page: page) + public func getEnrollments(page: Int) async throws -> [CourseItem] { + return try await repository.getEnrollments(page: page) } - public func discoveryOffline() throws -> [CourseItem] { - return try repository.getMyCoursesOffline() + public func getEnrollmentsOffline() throws -> [CourseItem] { + return try repository.getEnrollmentsOffline() + } + + public func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment { + return try await repository.getPrimaryEnrollment(pageSize: pageSize) + } + + public func getPrimaryEnrollmentOffline() async throws -> PrimaryEnrollment { + return try await repository.getPrimaryEnrollmentOffline() + } + + public func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment { + return try await repository.getAllCourses(filteredBy: filteredBy, page: page) } } diff --git a/Dashboard/Dashboard/Presentation/AllCoursesView.swift b/Dashboard/Dashboard/Presentation/AllCoursesView.swift new file mode 100644 index 000000000..332ae13c4 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/AllCoursesView.swift @@ -0,0 +1,211 @@ +// +// AllCoursesView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 24.04.2024. +// + +import SwiftUI +import Core +import Theme + +public struct AllCoursesView: View { + + @ObservedObject + private var viewModel: AllCoursesViewModel + private let router: DashboardRouter + @Environment (\.isHorizontal) private var isHorizontal + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + public init(viewModel: AllCoursesViewModel, router: DashboardRouter) { + self.viewModel = viewModel + self.router = router + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .top) { + VStack { + BackNavigationButton( + color: Theme.Colors.textPrimary, + action: { + router.back() + } + ) + .backViewStyle() + .padding(.top, isHorizontal ? 32 : 16) + .padding(.leading, 7) + + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + .zIndex(1) + + if let myEnrollments = viewModel.myEnrollments, + myEnrollments.courses.isEmpty, + !viewModel.fetchInProgress, + !viewModel.refresh { + NoCoursesView(selectedMenu: viewModel.selectedMenu) + } + // MARK: - Page body + VStack(alignment: .center) { + learnTitleAndSearch() + .frameLimit(width: proxy.size.width) + RefreshableScrollViewCompat(action: { + await viewModel.getCourses(page: 1, refresh: true) + }) { + CategoryFilterView(selectedOption: $viewModel.selectedMenu) + .disabled(viewModel.fetchInProgress) + .frameLimit(width: proxy.size.width) + if let myEnrollments = viewModel.myEnrollments { + LazyVGrid(columns: columns(), spacing: 15) { + ForEach( + Array(myEnrollments.courses.enumerated()), + id: \.offset + ) { index, course in + Button(action: { + viewModel.trackDashboardCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + router.showCourseScreens( + courseID: course.courseID, + hasAccess: course.hasAccess, + courseStart: course.courseStart, + courseEnd: course.courseEnd, + enrollmentStart: course.enrollmentStart, + enrollmentEnd: course.enrollmentEnd, + title: course.name, + showDates: false, + lastVisitedBlockID: nil + ) + }, label: { + CourseCardView( + courseName: course.name, + courseImage: course.imageURL, + progressEarned: course.progressEarned, + progressPossible: course.progressPossible, + courseStartDate: course.courseStart, + courseEndDate: course.courseEnd, + hasAccess: course.hasAccess, + showProgress: true + ).padding(8) + }) + .accessibilityIdentifier("course_item") + .onAppear { + Task { + await viewModel.getMyCoursesPagination(index: index) + } + } + } + } + .padding(10) + .frameLimit(width: proxy.size.width) + } + // MARK: - ProgressBar + if viewModel.nextPage <= viewModel.totalPages, !viewModel.refresh { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) + } + VStack {}.frame(height: 40) + } + .accessibilityAction {} + } + .padding(.top, 8) + + // MARK: - Offline mode SnackBar + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: { + await viewModel.getCourses(page: 1, refresh: true) + } + ) + + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .padding(.bottom, viewModel.connectivity.isInternetAvaliable + ? 0 : OfflineSnackBarView.height) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } + } + } + .onFirstAppear { + Task { + await viewModel.getCourses(page: 1) + } + } + .onChange(of: viewModel.selectedMenu) { _ in + Task { + viewModel.myEnrollments?.courses = [] + await viewModel.getCourses(page: 1, refresh: false) + } + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + .navigationBarBackButtonHidden(true) + .navigationBarHidden(true) + .navigationTitle(DashboardLocalization.Learn.allCourses) + } + } + + private func columns() -> [GridItem] { + isHorizontal || idiom == .pad + ? [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ] + : [ + GridItem(.flexible()), + GridItem(.flexible()) + ] + } + + private func learnTitleAndSearch() -> some View { + HStack(alignment: .center) { + Text(DashboardLocalization.Learn.allCourses) + .font(Theme.Fonts.displaySmall) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("all_courses_header_text") + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 10) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DashboardLocalization.Learn.allCourses) + } +} + +#if DEBUG +struct AllCoursesView_Previews: PreviewProvider { + static var previews: some View { + let vm = AllCoursesViewModel( + interactor: DashboardInteractor.mock, + connectivity: Connectivity(), + analytics: DashboardAnalyticsMock() + ) + + AllCoursesView(viewModel: vm, router: DashboardRouterMock()) + .preferredColorScheme(.light) + .previewDisplayName("AllCoursesView Light") + + AllCoursesView(viewModel: vm, router: DashboardRouterMock()) + .preferredColorScheme(.dark) + .previewDisplayName("AllCoursesView Dark") + } +} +#endif diff --git a/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift b/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift new file mode 100644 index 000000000..750c0936b --- /dev/null +++ b/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift @@ -0,0 +1,105 @@ +// +// AllCoursesViewModel.swift +// Dashboard +// +// Created by  Stepanok Ivan on 24.04.2024. +// + +import Foundation +import Core +import SwiftUI +import Combine + +public class AllCoursesViewModel: ObservableObject { + + var nextPage = 1 + var totalPages = 1 + @Published private(set) var fetchInProgress = false + @Published private(set) var refresh = false + @Published var selectedMenu: CategoryOption = .all + + @Published var myEnrollments: PrimaryEnrollment? + @Published var showError: Bool = false + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + + let connectivity: ConnectivityProtocol + private let interactor: DashboardInteractorProtocol + private let analytics: DashboardAnalytics + private var onCourseEnrolledCancellable: AnyCancellable? + + public init( + interactor: DashboardInteractorProtocol, + connectivity: ConnectivityProtocol, + analytics: DashboardAnalytics + ) { + self.interactor = interactor + self.connectivity = connectivity + self.analytics = analytics + + onCourseEnrolledCancellable = NotificationCenter.default + .publisher(for: .onCourseEnrolled) + .sink { [weak self] _ in + guard let self = self else { return } + Task { + await self.getCourses(page: 1, refresh: true) + } + } + } + + @MainActor + public func getCourses(page: Int, refresh: Bool = false) async { + self.refresh = refresh + do { + if refresh || page == 1 { + fetchInProgress = true + myEnrollments?.courses = [] + nextPage = 1 + myEnrollments = try await interactor.getAllCourses(filteredBy: selectedMenu.status, page: page) + self.totalPages = myEnrollments?.totalPages ?? 1 + } else { + fetchInProgress = true + myEnrollments?.courses += try await interactor.getAllCourses( + filteredBy: selectedMenu.status, page: page + ).courses + } + self.nextPage += 1 + totalPages = myEnrollments?.totalPages ?? 1 + fetchInProgress = false + self.refresh = false + } catch let error { + fetchInProgress = false + self.refresh = false + if error is NoCachedDataError { + errorMessage = CoreLocalization.Error.noCachedData + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } + + @MainActor + public func getMyCoursesPagination(index: Int) async { + guard let courses = myEnrollments?.courses else { return } + if !fetchInProgress { + if totalPages > 1 { + if index == courses.count - 3 { + if totalPages != 1 { + if nextPage <= totalPages { + await getCourses(page: self.nextPage) + } + } + } + } + } + } + + func trackDashboardCourseClicked(courseID: String, courseName: String) { + analytics.dashboardCourseClicked(courseID: courseID, courseName: courseName) + } +} diff --git a/Dashboard/Dashboard/Presentation/DashboardRouter.swift b/Dashboard/Dashboard/Presentation/DashboardRouter.swift index 40bc86c41..0e66ff2a8 100644 --- a/Dashboard/Dashboard/Presentation/DashboardRouter.swift +++ b/Dashboard/Dashboard/Presentation/DashboardRouter.swift @@ -11,12 +11,20 @@ import Core public protocol DashboardRouter: BaseRouter { func showCourseScreens(courseID: String, - isActive: Bool?, + hasAccess: Bool?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - title: String) + title: String, + showDates: Bool, + lastVisitedBlockID: String?) + + func showAllCourses(courses: [CourseItem]) + + func showDiscoverySearch(searchQuery: String?) + + func showSettings() } @@ -27,12 +35,19 @@ public class DashboardRouterMock: BaseRouterMock, DashboardRouter { public override init() {} public func showCourseScreens(courseID: String, - isActive: Bool?, + hasAccess: Bool?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - title: String) {} + title: String, + showDates: Bool, + lastVisitedBlockID: String?) {} + + public func showAllCourses(courses: [CourseItem]) {} + + public func showDiscoverySearch(searchQuery: String?) {} + public func showSettings() {} } #endif diff --git a/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift b/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift new file mode 100644 index 000000000..c01444840 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift @@ -0,0 +1,91 @@ +// +// CategoryFilterView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 24.04.2024. +// + +import Theme +import Core +import SwiftUI + +enum CategoryOption: String, CaseIterable { + case all + case inProgress + case completed + case expired + + var status: String { + switch self { + case .all: + "all" + case .inProgress: + "in_progress" + case .completed: + "completed" + case .expired: + "expired" + } + } + + var text: String { + switch self { + case .all: + DashboardLocalization.Learn.Category.all + case .inProgress: + DashboardLocalization.Learn.Category.inProgress + case .completed: + DashboardLocalization.Learn.Category.completed + case .expired: + DashboardLocalization.Learn.Category.expired + } + } +} + +struct CategoryFilterView: View { + @Binding var selectedOption: CategoryOption + @Environment (\.colorScheme) var colorScheme + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(Array(CategoryOption.allCases.enumerated()), id: \.offset) { index, option in + Button(action: { + selectedOption = option + }, + label: { + HStack { + Text(option.text) + .font(Theme.Fonts.titleSmall) + .foregroundColor( + option == selectedOption ? Theme.Colors.white : ( + colorScheme == .light ? Theme.Colors.accentColor : .white + ) + ) + } + .padding(.horizontal, 17) + .padding(.vertical, 8) + .background { + ZStack { + RoundedRectangle(cornerRadius: 20) + .foregroundStyle( + option == selectedOption + ? Theme.Colors.accentColor + : Theme.Colors.cardViewBackground + ) + RoundedRectangle(cornerRadius: 20) + .stroke( + colorScheme == .light ? Theme.Colors.accentColor : .clear, + style: .init(lineWidth: 1) + ) + } + .padding(.vertical, 1) + } + }) + .padding(.leading, index == 0 ? 16 : 0) + } + } + .fixedSize() + } + } +} diff --git a/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift b/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift new file mode 100644 index 000000000..53a0911db --- /dev/null +++ b/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift @@ -0,0 +1,125 @@ +// +// CourseCardView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 17.04.2024. +// + +import SwiftUI +import Theme +import Kingfisher +import Core + +struct CourseCardView: View { + + private let courseName: String + private let courseImage: String + private let progressEarned: Int + private let progressPossible: Int + private let courseStartDate: Date? + private let courseEndDate: Date? + private let hasAccess: Bool + private let showProgress: Bool + + init( + courseName: String, + courseImage: String, + progressEarned: Int, + progressPossible: Int, + courseStartDate: Date?, + courseEndDate: Date?, + hasAccess: Bool, + showProgress: Bool + ) { + self.courseName = courseName + self.courseImage = courseImage + self.progressEarned = progressEarned + self.progressPossible = progressPossible + self.courseStartDate = courseStartDate + self.courseEndDate = courseEndDate + self.hasAccess = hasAccess + self.showProgress = showProgress + } + + var body: some View { + ZStack(alignment: .topTrailing) { + VStack(alignment: .leading, spacing: 0) { + courseBanner + if showProgress { + ProgressLineView( + progressEarned: progressEarned, + progressPossible: progressPossible, + height: 4 + ) + } + courseTitle + } + if !hasAccess { + ZStack(alignment: .center) { + Circle() + .foregroundStyle(Theme.Colors.primaryHeaderColor) + .opacity(0.7) + .frame(width: 24, height: 24) + CoreAssets.lockIcon.swiftUIImage + .foregroundStyle(Theme.Colors.textPrimary) + } + .padding(8) + } + } + .background(Theme.Colors.background) + .cornerRadius(8) + .shadow(color: Theme.Colors.courseCardShadow, radius: 6, x: 2, y: 2) + } + + private var courseBanner: some View { + return KFImage(URL(string: courseImage)) + .onFailureImage(CoreAssets.noCourseImage.image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(minWidth: 120, minHeight: 90, maxHeight: 100) + .clipped() + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("course_image") + } + + private var courseTitle: some View { + VStack(alignment: .leading, spacing: 3) { + if let courseEndDate { + Text(courseEndDate.dateToString(style: .courseEndsMonthDDYear)) + .font(Theme.Fonts.labelSmall) + .foregroundStyle(Theme.Colors.textSecondaryLight) + .multilineTextAlignment(.leading) + } else if let courseStartDate { + Text(courseStartDate.dateToString(style: .courseStartsMonthDDYear)) + .font(Theme.Fonts.labelSmall) + .foregroundStyle(Theme.Colors.textSecondaryLight) + .multilineTextAlignment(.leading) + } + Text(courseName) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textPrimary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .frame(height: showProgress ? 51 : 40, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 10) + .padding(.horizontal, 12) + .padding(.bottom, 16) + } +} + +#if DEBUG +#Preview { + CourseCardView( + courseName: "Six Sigma Part 2: Analyze, Improve, Control", + courseImage: "https://thumbs.dreamstime.com/b/logo-edx-samsung-tablet-edx-massive-open-online-course-mooc-provider-hosts-online-university-level-courses-wide-117763805.jpg", + progressEarned: 4, + progressPossible: 8, + courseStartDate: nil, + courseEndDate: Date(), + hasAccess: true, + showProgress: true + ).frame(width: 170) +} +#endif diff --git a/Dashboard/Dashboard/Presentation/Elements/DropDownMenu.swift b/Dashboard/Dashboard/Presentation/Elements/DropDownMenu.swift new file mode 100644 index 000000000..85ba10548 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/Elements/DropDownMenu.swift @@ -0,0 +1,91 @@ +// +// DropDownMenu.swift +// Dashboard +// +// Created by  Stepanok Ivan on 24.04.2024. +// + +import Theme +import Core +import SwiftUI + +enum MenuOption: String, CaseIterable { + case courses + case programs + + var text: String { + switch self { + case .courses: + DashboardLocalization.Learn.DropdownMenu.courses + case .programs: + DashboardLocalization.Learn.DropdownMenu.programs + } + } +} + +struct DropDownMenu: View { + @Binding var selectedOption: MenuOption + @State private var expanded: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(selectedOption.text) + .font(Theme.Fonts.titleSmall) + .accessibilityIdentifier("dropdown_menu_text") + Image(systemName: "chevron.down") + .rotation3DEffect( + .degrees(expanded ? 180 : 0), + axis: (x: 1.0, y: 0.0, z: 0.0) + ) + } + .foregroundColor(Theme.Colors.textPrimary) + .onTapGesture { + withAnimation(.snappy(duration: 0.2)) { + expanded.toggle() + } + } + + if expanded { + VStack(spacing: 0) { + ForEach(Array(MenuOption.allCases.enumerated()), id: \.offset) { index, option in + Button( + action: { + selectedOption = option + expanded = false + }, label: { + HStack { + Text(option.text) + .font(Theme.Fonts.titleSmall) + .foregroundColor( + option == selectedOption ? Theme.Colors.white : Theme.Colors.textPrimary + ) + Spacer() + } + .padding(10) + .background { + ZStack { + RoundedCorners(bl: index == MenuOption.allCases.count-1 ? 8 : 0, + br: index == MenuOption.allCases.count-1 ? 8 : 0) + .foregroundStyle(option == selectedOption + ? Theme.Colors.accentColor + : Theme.Colors.cardViewBackground) + RoundedCorners(bl: index == MenuOption.allCases.count-1 ? 8 : 0, + br: index == MenuOption.allCases.count-1 ? 8 : 0) + .stroke(Theme.Colors.cardViewStroke, style: .init(lineWidth: 1)) + } + } + } + ) + } + } + .frame(minWidth: 182) + .fixedSize() + } + } + .onTapBackground(enabled: expanded, { expanded = false }) + .onDisappear { + expanded = false + } + } +} diff --git a/Dashboard/Dashboard/Presentation/Elements/NoCoursesView.swift b/Dashboard/Dashboard/Presentation/Elements/NoCoursesView.swift new file mode 100644 index 000000000..82a471509 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/Elements/NoCoursesView.swift @@ -0,0 +1,89 @@ +// +// NoCoursesView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 02.05.2024. +// + +import SwiftUI +import Core +import Theme + +struct NoCoursesView: View { + + enum NoCoursesType { + case primary + case inProgress + case completed + case expired + + var title: String { + switch self { + case .primary: + DashboardLocalization.Learn.NoCoursesView.noCourses + case .inProgress: + DashboardLocalization.Learn.NoCoursesView.noCoursesInProgress + case .completed: + DashboardLocalization.Learn.NoCoursesView.noCompletedCourses + case .expired: + DashboardLocalization.Learn.NoCoursesView.noExpiredCourses + } + } + + var description: String? { + switch self { + case .primary: + DashboardLocalization.Learn.NoCoursesView.noCoursesDescription + case .inProgress, .completed, .expired: + nil + } + } + } + + private let type: NoCoursesType + private var openDiscovery: (() -> Void) + + init(openDiscovery: @escaping (() -> Void)) { + self.type = .primary + self.openDiscovery = openDiscovery + } + + init(selectedMenu: CategoryOption) { + switch selectedMenu { + case .all: + type = .inProgress + case .inProgress: + type = .inProgress + case .completed: + type = .completed + case .expired: + type = .expired + } + openDiscovery = {} + } + + var body: some View { + VStack(spacing: 8) { + Spacer() + CoreAssets.learnEmpty.swiftUIImage + .resizable() + .frame(width: 96, height: 96) + .foregroundStyle(Theme.Colors.textSecondaryLight) + Text(type.title) + .foregroundStyle(Theme.Colors.textPrimary) + .font(Theme.Fonts.titleMedium) + if let description = type.description { + Text(description) + .multilineTextAlignment(.center) + .foregroundStyle(Theme.Colors.textPrimary) + .font(Theme.Fonts.labelMedium) + .frame(width: 245) + } + Spacer() + if type == .primary { + StyledButton(DashboardLocalization.Learn.NoCoursesView.findACourse, action: { openDiscovery() }) + .padding(24) + } + } + } +} diff --git a/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift new file mode 100644 index 000000000..8428c1857 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift @@ -0,0 +1,276 @@ +// +// PrimaryCardView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 16.04.2024. +// + +import SwiftUI +import Kingfisher +import Theme +import Core + +public struct PrimaryCardView: View { + + private let courseName: String + private let org: String + private let courseImage: String + private let courseStartDate: Date? + private let courseEndDate: Date? + private var futureAssignments: [Assignment] + private let pastAssignments: [Assignment] + private let progressEarned: Int + private let progressPossible: Int + private let canResume: Bool + private let resumeTitle: String? + private var assignmentAction: (String?) -> Void + private var openCourseAction: () -> Void + private var resumeAction: () -> Void + + public init( + courseName: String, + org: String, + courseImage: String, + courseStartDate: Date?, + courseEndDate: Date?, + futureAssignments: [Assignment], + pastAssignments: [Assignment], + progressEarned: Int, + progressPossible: Int, + canResume: Bool, + resumeTitle: String?, + assignmentAction: @escaping (String?) -> Void, + openCourseAction: @escaping () -> Void, + resumeAction: @escaping () -> Void + ) { + self.courseName = courseName + self.org = org + self.courseImage = courseImage + self.courseStartDate = courseStartDate + self.courseEndDate = courseEndDate + self.futureAssignments = futureAssignments + self.pastAssignments = pastAssignments + self.progressEarned = progressEarned + self.progressPossible = progressPossible + self.canResume = canResume + self.resumeTitle = resumeTitle + self.assignmentAction = assignmentAction + self.openCourseAction = openCourseAction + self.resumeAction = resumeAction + } + + public var body: some View { + ZStack { + VStack(alignment: .leading, spacing: 0) { + Group { + courseBanner + ProgressLineView(progressEarned: progressEarned, progressPossible: progressPossible) + courseTitle + } + .onTapGesture { + openCourseAction() + } + assignments + } + } + .background(Theme.Colors.background) + .cornerRadius(8) + .shadow(color: Theme.Colors.courseCardShadow, radius: 4, x: 0, y: 3) + .padding(20) + } + + private var assignments: some View { + VStack(alignment: .leading, spacing: 8) { + // pastAssignments + if pastAssignments.count == 1, let pastAssignment = pastAssignments.first { + courseButton( + title: pastAssignment.title, + description: DashboardLocalization.Learn.PrimaryCard.onePastAssignment, + icon: CoreAssets.warning.swiftUIImage, + selected: false, + action: { assignmentAction(pastAssignments.first?.firstComponentBlockId) } + ) + } else if pastAssignments.count > 1 { + courseButton( + title: DashboardLocalization.Learn.PrimaryCard.viewAssignments, + description: DashboardLocalization.Learn.PrimaryCard.pastAssignments(pastAssignments.count), + icon: CoreAssets.warning.swiftUIImage, + selected: false, + action: { assignmentAction(nil) } + ) + } + + // futureAssignment + if !futureAssignments.isEmpty { + if futureAssignments.count == 1, let futureAssignment = futureAssignments.first { + let daysRemaining = Calendar.current.dateComponents( + [.day], + from: Date(), + to: futureAssignment.date + ).day ?? 0 + courseButton( + title: futureAssignment.title, + description: DashboardLocalization.Learn.PrimaryCard.dueDays( + futureAssignment.type, + daysRemaining + ), + icon: CoreAssets.chapter.swiftUIImage, + selected: false, + action: { + assignmentAction(futureAssignments.first?.firstComponentBlockId) + } + ) + } else if futureAssignments.count > 1 { + if let firtsData = futureAssignments.sorted(by: { $0.date < $1.date }).first { + courseButton( + title: DashboardLocalization.Learn.PrimaryCard.futureAssignments( + futureAssignments.count, + firtsData.date.dateToString(style: .lastPost) + ), + description: nil, + icon: CoreAssets.chapter.swiftUIImage, + selected: false, + action: { + assignmentAction(nil) + } + ) + } + } + } + + // ResumeButton + if canResume { + courseButton( + title: resumeTitle ?? "", + description: DashboardLocalization.Learn.PrimaryCard.resume, + icon: CoreAssets.resumeCourse.swiftUIImage, + selected: true, + action: { resumeAction() } + ) + } else { + courseButton( + title: DashboardLocalization.Learn.PrimaryCard.startCourse, + description: nil, + icon: CoreAssets.resumeCourse.swiftUIImage, + selected: true, + action: { resumeAction() } + ) + } + } + } + + private func courseButton( + title: String, + description: String?, + icon: Image, + selected: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: { + action() + }, label: { + ZStack(alignment: .top) { + Rectangle().frame(height: selected ? 0 : 1) + .foregroundStyle(Theme.Colors.cardViewStroke) + HStack(alignment: .center) { + VStack(alignment: .leading) { + HStack(spacing: 0) { + icon + .renderingMode(.template) + .resizable() + .frame(width: 24, height: 24) + .foregroundStyle( + selected ? Theme.Colors.white : Theme.Colors.textPrimary + ) + .padding(12) + + VStack(alignment: .leading, spacing: 6) { + if let description { + Text(description) + .font(Theme.Fonts.labelSmall) + .multilineTextAlignment(.leading) + .lineLimit(1) + .foregroundStyle(selected ? Theme.Colors.white : Theme.Colors.textPrimary) + } + Text(title) + .font(Theme.Fonts.titleSmall) + .multilineTextAlignment(.leading) + .lineLimit(1) + .foregroundStyle(selected ? Theme.Colors.white : Theme.Colors.textPrimary) + } + .padding(.top, 2) + } + } + Spacer() + CoreAssets.chevronRight.swiftUIImage + .foregroundStyle(selected ? Theme.Colors.white : Theme.Colors.textPrimary) + .padding(8) + } + .padding(.top, 8) + .padding(.bottom, selected ? 10 : 0) + }.background(selected ? Theme.Colors.accentColor : .clear) + }) + } + + private var courseBanner: some View { + return KFImage(URL(string: courseImage)) + .onFailureImage(CoreAssets.noCourseImage.image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 140) + .clipped() + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("course_image") + } + + private var courseTitle: some View { + VStack(alignment: .leading, spacing: 3) { + Text(org) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textSecondaryLight) + Text(courseName) + .font(Theme.Fonts.titleLarge) + .foregroundStyle(Theme.Colors.textPrimary) + .lineLimit(3) + if let courseEndDate { + Text(courseEndDate.dateToString(style: .courseEndsMonthDDYear)) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textSecondaryLight) + } else if let courseStartDate { + Text(courseStartDate.dateToString(style: .courseStartsMonthDDYear)) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textSecondaryLight) + } + } + .padding(.top, 10) + .padding(.horizontal, 12) + .padding(.bottom, 16) + } +} + +#if DEBUG +struct PrimaryCardView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Theme.Colors.background + PrimaryCardView( + courseName: "Course Title", + org: "Organization", + courseImage: "https://thumbs.dreamstime.com/b/logo-edx-samsung-tablet-edx-massive-open-online-course-mooc-provider-hosts-online-university-level-courses-wide-117763805.jpg", + courseStartDate: nil, + courseEndDate: Date(), + futureAssignments: [], + pastAssignments: [], + progressEarned: 10, + progressPossible: 45, + canResume: true, + resumeTitle: "Course Chapter 1", + assignmentAction: {_ in }, + openCourseAction: {}, + resumeAction: {} + ) + .loadFonts() + } + } +} +#endif diff --git a/Dashboard/Dashboard/Presentation/Elements/ProgressLineView.swift b/Dashboard/Dashboard/Presentation/Elements/ProgressLineView.swift new file mode 100644 index 000000000..611ddbcb1 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/Elements/ProgressLineView.swift @@ -0,0 +1,48 @@ +// +// ProgressLineView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 16.04.2024. +// + +import SwiftUI +import Theme + +struct ProgressLineView: View { + private let progressEarned: Int + private let progressPossible: Int + private let height: CGFloat + + var progressValue: CGFloat { + guard progressPossible != 0 else { return 0 } + return CGFloat(progressEarned) / CGFloat(progressPossible) + } + + init(progressEarned: Int, progressPossible: Int, height: CGFloat = 8) { + self.progressEarned = progressEarned + self.progressPossible = progressPossible + self.height = height + } + + var body: some View { + ZStack(alignment: .leading) { + GeometryReader { geometry in + Rectangle() + .foregroundStyle(Theme.Colors.cardViewStroke) + Rectangle() + .foregroundStyle(Theme.Colors.accentColor) + .frame(width: geometry.size.width * progressValue) + }.frame(height: height) + } + } +} + +#if DEBUG +struct ProgressLineView_Previews: PreviewProvider { + static var previews: some View { + ProgressLineView(progressEarned: 4, progressPossible: 6) + .frame(height: 8) + .padding() + } +} +#endif diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/ListDashboardView.swift similarity index 90% rename from Dashboard/Dashboard/Presentation/DashboardView.swift rename to Dashboard/Dashboard/Presentation/ListDashboardView.swift index 44c6c3fc8..d3787ebad 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardView.swift @@ -1,5 +1,5 @@ // -// DashboardView.swift +// ListDashboardView.swift // Dashboard // // Created by  Stepanok Ivan on 19.09.2022. @@ -9,7 +9,7 @@ import SwiftUI import Core import Theme -public struct DashboardView: View { +public struct ListDashboardView: View { private let dashboardCourses: some View = VStack(alignment: .leading) { Text(DashboardLocalization.Header.courses) .font(Theme.Fonts.displaySmall) @@ -25,10 +25,10 @@ public struct DashboardView: View { .accessibilityLabel(DashboardLocalization.Header.courses + DashboardLocalization.Header.welcomeBack) @StateObject - private var viewModel: DashboardViewModel + private var viewModel: ListDashboardViewModel private let router: DashboardRouter - public init(viewModel: DashboardViewModel, router: DashboardRouter) { + public init(viewModel: ListDashboardViewModel, router: DashboardRouter) { self._viewModel = StateObject(wrappedValue: { viewModel }()) self.router = router } @@ -76,12 +76,14 @@ public struct DashboardView: View { ) router.showCourseScreens( courseID: course.courseID, - isActive: course.isActive, + hasAccess: course.hasAccess, courseStart: course.courseStart, courseEnd: course.courseEnd, enrollmentStart: course.enrollmentStart, enrollmentEnd: course.enrollmentEnd, - title: course.name + title: course.name, + showDates: false, + lastVisitedBlockID: nil ) } .accessibilityIdentifier("course_item") @@ -138,22 +140,22 @@ public struct DashboardView: View { } #if DEBUG -struct DashboardView_Previews: PreviewProvider { +struct ListDashboardView_Previews: PreviewProvider { static var previews: some View { - let vm = DashboardViewModel( + let vm = ListDashboardViewModel( interactor: DashboardInteractor.mock, connectivity: Connectivity(), analytics: DashboardAnalyticsMock() ) let router = DashboardRouterMock() - DashboardView(viewModel: vm, router: router) + ListDashboardView(viewModel: vm, router: router) .preferredColorScheme(.light) - .previewDisplayName("DashboardView Light") + .previewDisplayName("ListDashboardView Light") - DashboardView(viewModel: vm, router: router) + ListDashboardView(viewModel: vm, router: router) .preferredColorScheme(.dark) - .previewDisplayName("DashboardView Dark") + .previewDisplayName("ListDashboardView Dark") } } #endif diff --git a/Dashboard/Dashboard/Presentation/DashboardViewModel.swift b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift similarity index 90% rename from Dashboard/Dashboard/Presentation/DashboardViewModel.swift rename to Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift index 6e4d9974a..4e79877c2 100644 --- a/Dashboard/Dashboard/Presentation/DashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift @@ -1,5 +1,5 @@ // -// DashboardViewModel.swift +// ListDashboardViewModel.swift // Dashboard // // Created by  Stepanok Ivan on 19.09.2022. @@ -10,7 +10,7 @@ import Core import SwiftUI import Combine -public class DashboardViewModel: ObservableObject { +public class ListDashboardViewModel: ObservableObject { public var nextPage = 1 public var totalPages = 1 @@ -54,11 +54,11 @@ public class DashboardViewModel: ObservableObject { fetchInProgress = true if connectivity.isInternetAvaliable { if refresh { - courses = try await interactor.getMyCourses(page: page) + courses = try await interactor.getEnrollments(page: page) self.totalPages = 1 self.nextPage = 2 } else { - courses += try await interactor.getMyCourses(page: page) + courses += try await interactor.getEnrollments(page: page) self.nextPage += 1 } if !courses.isEmpty { @@ -66,7 +66,7 @@ public class DashboardViewModel: ObservableObject { } fetchInProgress = false } else { - courses = try interactor.discoveryOffline() + courses = try interactor.getEnrollmentsOffline() self.nextPage += 1 fetchInProgress = false } diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift new file mode 100644 index 000000000..7ac041110 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift @@ -0,0 +1,357 @@ +// +// PrimaryCourseDashboardView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 16.04.2024. +// + +import SwiftUI +import Core +import Theme +import Swinject + +public struct PrimaryCourseDashboardView: View { + + @StateObject private var viewModel: PrimaryCourseDashboardViewModel + private let router: DashboardRouter + @ViewBuilder let programView: ProgramView + private var openDiscoveryPage: () -> Void + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + @State private var selectedMenu: MenuOption = .courses + + public init( + viewModel: PrimaryCourseDashboardViewModel, + router: DashboardRouter, + programView: ProgramView, + openDiscoveryPage: @escaping () -> Void + ) { + self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.router = router + self.programView = programView + self.openDiscoveryPage = openDiscoveryPage + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .top) { + if viewModel.enrollments?.primaryCourse == nil + && !viewModel.fetchInProgress + && selectedMenu == .courses { + NoCoursesView(openDiscovery: { + openDiscoveryPage() + }).zIndex(1) + } + learnTitleAndSearch(proxy: proxy) + .zIndex(1) + // MARK: - Page body + VStack(alignment: .leading) { + Spacer(minLength: 50) + switch selectedMenu { + case .courses: + RefreshableScrollViewCompat(action: { + await viewModel.getEnrollments(showProgress: false) + }) { + ZStack(alignment: .topLeading) { + if viewModel.fetchInProgress { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) + } else { + LazyVStack(spacing: 0) { + if let enrollments = viewModel.enrollments { + if let primary = enrollments.primaryCourse { + PrimaryCardView( + courseName: primary.name, + org: primary.org, + courseImage: primary.courseBanner, + courseStartDate: primary.courseStart, + courseEndDate: primary.courseEnd, + futureAssignments: primary.futureAssignments, + pastAssignments: primary.pastAssignments, + progressEarned: primary.progressEarned, + progressPossible: primary.progressPossible, + canResume: primary.lastVisitedBlockID != nil, + resumeTitle: primary.resumeTitle, + assignmentAction: { lastVisitedBlockID in + router.showCourseScreens( + courseID: primary.courseID, + hasAccess: primary.hasAccess, + courseStart: primary.courseStart, + courseEnd: primary.courseEnd, + enrollmentStart: nil, + enrollmentEnd: nil, + title: primary.name, + showDates: lastVisitedBlockID == nil, + lastVisitedBlockID: lastVisitedBlockID + ) + }, + openCourseAction: { + router.showCourseScreens( + courseID: primary.courseID, + hasAccess: primary.hasAccess, + courseStart: primary.courseStart, + courseEnd: primary.courseEnd, + enrollmentStart: nil, + enrollmentEnd: nil, + title: primary.name, + showDates: false, + lastVisitedBlockID: nil + ) + }, + resumeAction: { + router.showCourseScreens( + courseID: primary.courseID, + hasAccess: primary.hasAccess, + courseStart: primary.courseStart, + courseEnd: primary.courseEnd, + enrollmentStart: nil, + enrollmentEnd: nil, + title: primary.name, + showDates: false, + lastVisitedBlockID: primary.lastVisitedBlockID + ) + } + ) + } + if !enrollments.courses.isEmpty { + viewAll(enrollments) + } + if idiom == .pad { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ], + alignment: .leading, + spacing: 15 + ) { + courses(enrollments) + } + .padding(20) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + courses(enrollments) + } + .padding(20) + } + } + Spacer(minLength: 100) + } + } + } + } + .frameLimit(width: proxy.size.width) + }.accessibilityAction {} + case .programs: + programView + } + }.padding(.top, 8) + // MARK: - Offline mode SnackBar + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: { + await viewModel.getEnrollments(showProgress: false) + } + ).zIndex(2) + + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .padding( + .bottom, + viewModel.connectivity.isInternetAvaliable ? 0 : OfflineSnackBarView.height + ) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } + .zIndex(2) + } + } + .onFirstAppear { + Task { + await viewModel.getEnrollments() + } + } + .onAppear { + viewModel.updateNeeded = true + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + .navigationBarBackButtonHidden(true) + .navigationBarHidden(true) + .navigationTitle(DashboardLocalization.title) + } + } + + @ViewBuilder + private func courses(_ enrollments: PrimaryEnrollment) -> some View { + ForEach( + Array(enrollments.courses.enumerated()), + id: \.offset + ) { _, course in + Button(action: { + viewModel.trackDashboardCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + router.showCourseScreens( + courseID: course.courseID, + hasAccess: course.hasAccess, + courseStart: course.courseStart, + courseEnd: course.courseEnd, + enrollmentStart: course.enrollmentStart, + enrollmentEnd: course.enrollmentEnd, + title: course.name, + showDates: false, + lastVisitedBlockID: nil + ) + }, label: { + CourseCardView( + courseName: course.name, + courseImage: course.imageURL, + progressEarned: 0, + progressPossible: 0, + courseStartDate: nil, + courseEndDate: nil, + hasAccess: course.hasAccess, + showProgress: false + ).frame(width: idiom == .pad ? nil : 120) + } + ) + .accessibilityIdentifier("course_item") + } + if enrollments.courses.count < enrollments.count { + viewAllButton(enrollments) + } + } + + private func viewAllButton(_ enrollments: PrimaryEnrollment) -> some View { + Button(action: { + router.showAllCourses(courses: enrollments.courses) + }, label: { + ZStack(alignment: .topTrailing) { + HStack { + Spacer() + VStack(alignment: .leading, spacing: 0) { + Spacer() + CoreAssets.viewAll.swiftUIImage + Text(DashboardLocalization.Learn.viewAll) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textPrimary) + Spacer() + } + Spacer() + } + .frame(width: idiom == .pad ? nil : 120) + } + .background(Theme.Colors.cardViewBackground) + .cornerRadius(8) + .shadow(color: Theme.Colors.courseCardShadow, radius: 6, x: 2, y: 2) + }) + } + + private func viewAll(_ enrollments: PrimaryEnrollment) -> some View { + Button(action: { + router.showAllCourses(courses: enrollments.courses) + }, label: { + HStack { + Text(DashboardLocalization.Learn.viewAllCourses(enrollments.count + 1)) + .font(Theme.Fonts.titleSmall) + .accessibilityIdentifier("courses_welcomeback_text") + Image(systemName: "chevron.right") + } + .padding(.horizontal, 16) + .foregroundColor(Theme.Colors.textPrimary) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + }) + } + + private func learnTitleAndSearch(proxy: GeometryProxy) -> some View { + let showDropdown = viewModel.config.program.enabled && viewModel.config.program.isWebViewConfigured + return ZStack(alignment: .top) { + Theme.Colors.background + .frame(height: showDropdown ? 70 : 50) + ZStack(alignment: .topTrailing) { + VStack { + HStack(alignment: .center) { + Text(DashboardLocalization.Learn.title) + .font(Theme.Fonts.displaySmall) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("courses_header_text") + Spacer() + } + if showDropdown { + HStack(alignment: .center) { + DropDownMenu(selectedOption: $selectedMenu) + Spacer() + } + } + } + .frameLimit(width: proxy.size.width) + HStack { + Spacer() + Button(action: { + router.showSettings() + }, label: { + CoreAssets.settings.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.accentColor) + }) + } + .padding(.top, 8) + .offset(x: idiom == .pad ? 1 : 5, y: idiom == .pad ? 4 : -5) + } + + .listRowBackground(Color.clear) + .padding(.horizontal, 20) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DashboardLocalization.Header.courses + DashboardLocalization.Header.welcomeBack) + } + } +} + +#if DEBUG +struct PrimaryCourseDashboardView_Previews: PreviewProvider { + static var previews: some View { + let vm = PrimaryCourseDashboardViewModel( + interactor: DashboardInteractor.mock, + connectivity: Connectivity(), + analytics: DashboardAnalyticsMock(), + config: ConfigMock() + ) + + PrimaryCourseDashboardView( + viewModel: vm, + router: DashboardRouterMock(), + programView: EmptyView(), + openDiscoveryPage: { + } + ) + .preferredColorScheme(.light) + .previewDisplayName("DashboardView Light") + + PrimaryCourseDashboardView( + viewModel: vm, + router: DashboardRouterMock(), + programView: EmptyView(), + openDiscoveryPage: { + } + ) + .preferredColorScheme(.dark) + .previewDisplayName("DashboardView Dark") + } +} +#endif diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift new file mode 100644 index 000000000..7b3a51e37 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift @@ -0,0 +1,102 @@ +// +// PrimaryCourseDashboardViewModel.swift +// Dashboard +// +// Created by  Stepanok Ivan on 16.04.2024. +// + +import Foundation +import Core +import SwiftUI +import Combine + +public class PrimaryCourseDashboardViewModel: ObservableObject { + + var nextPage = 1 + var totalPages = 1 + @Published public private(set) var fetchInProgress = true + @Published var enrollments: PrimaryEnrollment? + @Published var showError: Bool = false + @Published var updateNeeded: Bool = false + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + + let connectivity: ConnectivityProtocol + private let interactor: DashboardInteractorProtocol + private let analytics: DashboardAnalytics + let config: ConfigProtocol + private var cancellables = Set() + + private let ipadPageSize = 7 + private let iphonePageSize = 5 + + public init( + interactor: DashboardInteractorProtocol, + connectivity: ConnectivityProtocol, + analytics: DashboardAnalytics, + config: ConfigProtocol + ) { + self.interactor = interactor + self.connectivity = connectivity + self.analytics = analytics + self.config = config + + let enrollmentPublisher = NotificationCenter.default.publisher(for: .onCourseEnrolled) + let completionPublisher = NotificationCenter.default.publisher(for: .onblockCompletionRequested) + + enrollmentPublisher + .sink { [weak self] _ in + guard let self = self else { return } + Task { + await self.getEnrollments() + } + } + .store(in: &cancellables) + + completionPublisher + .sink { [weak self] _ in + guard let self = self else { return } + updateEnrollmentsIfNeeded() + } + .store(in: &cancellables) + } + + private func updateEnrollmentsIfNeeded() { + guard updateNeeded else { return } + Task { + await getEnrollments() + updateNeeded = false + } + } + + @MainActor + public func getEnrollments(showProgress: Bool = true) async { + let pageSize = UIDevice.current.userInterfaceIdiom == .pad ? ipadPageSize : iphonePageSize + fetchInProgress = showProgress + do { + if connectivity.isInternetAvaliable { + enrollments = try await interactor.getPrimaryEnrollment(pageSize: pageSize) + fetchInProgress = false + } else { + enrollments = try await interactor.getPrimaryEnrollmentOffline() + fetchInProgress = false + } + } catch let error { + fetchInProgress = false + if error is NoCachedDataError { + errorMessage = CoreLocalization.Error.noCachedData + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } + + func trackDashboardCourseClicked(courseID: String, courseName: String) { + analytics.dashboardCourseClicked(courseID: courseID, courseName: courseName) + } +} diff --git a/Dashboard/Dashboard/SwiftGen/Strings.swift b/Dashboard/Dashboard/SwiftGen/Strings.swift index aac74931c..7b5924613 100644 --- a/Dashboard/Dashboard/SwiftGen/Strings.swift +++ b/Dashboard/Dashboard/SwiftGen/Strings.swift @@ -10,6 +10,8 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum DashboardLocalization { + /// Search + public static let search = DashboardLocalization.tr("Localizable", "SEARCH", fallback: "Search") /// Localizable.strings /// Dashboard /// @@ -25,6 +27,70 @@ public enum DashboardLocalization { /// Welcome back. Let's keep learning. public static let welcomeBack = DashboardLocalization.tr("Localizable", "HEADER.WELCOME_BACK", fallback: "Welcome back. Let's keep learning.") } + public enum Learn { + /// All Courses + public static let allCourses = DashboardLocalization.tr("Localizable", "LEARN.ALL_COURSES", fallback: "All Courses") + /// Learn + public static let title = DashboardLocalization.tr("Localizable", "LEARN.TITLE", fallback: "Learn") + /// View All + public static let viewAll = DashboardLocalization.tr("Localizable", "LEARN.VIEW_ALL", fallback: "View All") + /// View All Courses (%@) + public static func viewAllCourses(_ p1: Any) -> String { + return DashboardLocalization.tr("Localizable", "LEARN.VIEW_ALL_COURSES", String(describing: p1), fallback: "View All Courses (%@)") + } + public enum Category { + /// All + public static let all = DashboardLocalization.tr("Localizable", "LEARN.CATEGORY.ALL", fallback: "All") + /// Completed + public static let completed = DashboardLocalization.tr("Localizable", "LEARN.CATEGORY.COMPLETED", fallback: "Completed") + /// Expired + public static let expired = DashboardLocalization.tr("Localizable", "LEARN.CATEGORY.EXPIRED", fallback: "Expired") + /// In Progress + public static let inProgress = DashboardLocalization.tr("Localizable", "LEARN.CATEGORY.IN_PROGRESS", fallback: "In Progress") + } + public enum DropdownMenu { + /// Courses + public static let courses = DashboardLocalization.tr("Localizable", "LEARN.DROPDOWN_MENU.COURSES", fallback: "Courses") + /// Programs + public static let programs = DashboardLocalization.tr("Localizable", "LEARN.DROPDOWN_MENU.PROGRAMS", fallback: "Programs") + } + public enum NoCoursesView { + /// Find a Course + public static let findACourse = DashboardLocalization.tr("Localizable", "LEARN.NO_COURSES_VIEW.FIND_A_COURSE", fallback: "Find a Course") + /// No Completed Courses + public static let noCompletedCourses = DashboardLocalization.tr("Localizable", "LEARN.NO_COURSES_VIEW.NO_COMPLETED_COURSES", fallback: "No Completed Courses") + /// No Courses + public static let noCourses = DashboardLocalization.tr("Localizable", "LEARN.NO_COURSES_VIEW.NO_COURSES", fallback: "No Courses") + /// You are not currently enrolled in any courses, would you like to explore the course catalog? + public static let noCoursesDescription = DashboardLocalization.tr("Localizable", "LEARN.NO_COURSES_VIEW.NO_COURSES_DESCRIPTION", fallback: "You are not currently enrolled in any courses, would you like to explore the course catalog?") + /// No Courses in Progress + public static let noCoursesInProgress = DashboardLocalization.tr("Localizable", "LEARN.NO_COURSES_VIEW.NO_COURSES_IN_PROGRESS", fallback: "No Courses in Progress") + /// No Expired Courses + public static let noExpiredCourses = DashboardLocalization.tr("Localizable", "LEARN.NO_COURSES_VIEW.NO_EXPIRED_COURSES", fallback: "No Expired Courses") + } + public enum PrimaryCard { + /// %@ Due in %@ Days + public static func dueDays(_ p1: Any, _ p2: Any) -> String { + return DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.DUE_DAYS", String(describing: p1), String(describing: p2), fallback: "%@ Due in %@ Days") + } + /// %@ Assignments Due %@ + public static func futureAssignments(_ p1: Any, _ p2: Any) -> String { + return DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.FUTURE_ASSIGNMENTS", String(describing: p1), String(describing: p2), fallback: "%@ Assignments Due %@ ") + } + /// 1 Past Due Assignment + public static let onePastAssignment = DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.ONE_PAST_ASSIGNMENT", fallback: "1 Past Due Assignment") + /// %@ Past Due Assignments + public static func pastAssignments(_ p1: Any) -> String { + return DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.PAST_ASSIGNMENTS", String(describing: p1), fallback: "%@ Past Due Assignments") + } + /// Resume Course + public static let resume = DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.RESUME", fallback: "Resume Course") + /// Start Course + public static let startCourse = DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.START_COURSE", fallback: "Start Course") + /// View Assignments + public static let viewAssignments = DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.VIEW_ASSIGNMENTS", fallback: "View Assignments") + } + } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/Dashboard/Dashboard/en.lproj/Localizable.strings b/Dashboard/Dashboard/en.lproj/Localizable.strings index 88fc5d371..406b6c34e 100644 --- a/Dashboard/Dashboard/en.lproj/Localizable.strings +++ b/Dashboard/Dashboard/en.lproj/Localizable.strings @@ -10,4 +10,36 @@ "HEADER.COURSES" = "Courses"; "HEADER.WELCOME_BACK" = "Welcome back. Let's keep learning."; +"SEARCH" = "Search"; + "EMPTY.SUBTITLE" = "You are not enrolled in any courses yet."; + +"LEARN.TITLE" = "Learn"; +"LEARN.VIEW_ALL" = "View All"; +"LEARN.VIEW_ALL_COURSES" = "View All Courses (%@)"; +"LEARN.ALL_COURSES" = "All Courses"; + +"LEARN.PRIMARY_CARD.ONE_PAST_ASSIGNMENT" = "1 Past Due Assignment"; +"LEARN.PRIMARY_CARD.VIEW_ASSIGNMENTS" = "View Assignments"; +"LEARN.PRIMARY_CARD.PAST_ASSIGNMENTS" = "%@ Past Due Assignments"; +"LEARN.PRIMARY_CARD.FUTURE_ASSIGNMENTS" = "%@ Assignments Due %@ "; +"LEARN.PRIMARY_CARD.DUE_DAYS" = "%@ Due in %@ Days"; +"LEARN.PRIMARY_CARD.RESUME" = "Resume Course"; +"LEARN.PRIMARY_CARD.START_COURSE" = "Start Course"; + +"LEARN.DROPDOWN_MENU.COURSES" = "Courses"; +"LEARN.DROPDOWN_MENU.PROGRAMS" = "Programs"; + +"LEARN.CATEGORY.ALL" = "All"; +"LEARN.CATEGORY.IN_PROGRESS" = "In Progress"; +"LEARN.CATEGORY.COMPLETED" = "Completed"; +"LEARN.CATEGORY.EXPIRED" = "Expired"; + +"LEARN.NO_COURSES_VIEW.NO_COURSES" = "No Courses"; +"LEARN.NO_COURSES_VIEW.NO_COURSES_IN_PROGRESS" = "No Courses in Progress"; +"LEARN.NO_COURSES_VIEW.NO_COMPLETED_COURSES" = "No Completed Courses"; +"LEARN.NO_COURSES_VIEW.NO_EXPIRED_COURSES" = "No Expired Courses"; + +"LEARN.NO_COURSES_VIEW.NO_COURSES_DESCRIPTION" = "You are not currently enrolled in any courses, would you like to explore the course catalog?"; + +"LEARN.NO_COURSES_VIEW.FIND_A_COURSE" = "Find a Course"; diff --git a/Dashboard/Dashboard/uk.lproj/Localizable.strings b/Dashboard/Dashboard/uk.lproj/Localizable.strings index 748f2c021..e02337c90 100644 --- a/Dashboard/Dashboard/uk.lproj/Localizable.strings +++ b/Dashboard/Dashboard/uk.lproj/Localizable.strings @@ -10,5 +10,36 @@ "HEADER.COURSES" = "Курси"; "HEADER.WELCOME_BACK" = "З поверненням. Давайте продовжимо вчитись."; +"SEARCH" = "Пошук"; + "EMPTY.TITLE" = "Нічого немає"; "EMPTY.SUBTITLE" = "Ви не підписані на жодний курс."; + +"LEARN.TITLE" = "Навчання"; +"LEARN.VIEW_ALL" = "Переглянути все (%@)"; +"LEARN.ALL_COURSES" = "Усі курси"; + +"LEARN.PRIMARY_CARD.ONE_PAST_ASSIGNMENT" = "1 прострочене завдання"; +"LEARN.PRIMARY_CARD.VIEW_ASSIGNMENTS" = "Переглянути завдання"; +"LEARN.PRIMARY_CARD.PAST_ASSIGNMENTS" = "%@ Прострочені завдання"; +"LEARN.PRIMARY_CARD.FUTURE_ASSIGNMENTS" = "%@ Завданнь %@"; +"LEARN.PRIMARY_CARD.DUE_DAYS" = "%@ Оплата через %@ днів"; +"LEARN.PRIMARY_CARD.RESUME" = "Відновити курс"; +"LEARN.PRIMARY_CARD.START_COURSE" = "Розпочати курс"; + +"LEARN.DROPDOWN_MENU.COURSES" = "Курси"; +"LEARN.DROPDOWN_MENU.PROGRAMS" = "Програми"; + +"LEARN.CATEGORY.ALL" = "Усі"; +"LEARN.CATEGORY.IN_PROGRESS" = "Виконується"; +"LEARN.CATEGORY.COMPLETED" = "Завершено"; +"LEARN.CATEGORY.EXPIRED" = "Закінчився"; + +"LEARN.NO_COURSES_VIEW.NO_COURSES" = "Немає курсів"; +"LEARN.NO_COURSES_VIEW.NO_COURSES_IN_PROGRESS" = "Немає поточних курсів"; +"LEARN.NO_COURSES_VIEW.NO_COMPLETED_COURSES" = "Немає завершених курсів"; +"LEARN.NO_COURSES_VIEW.NO_EXPIRED_COURSES" = "Немає прострочених курсів"; + +"LEARN.NO_COURSES_VIEW.NO_COURSES_DESCRIPTION" = "Наразі ви не зареєстровані на жодному курсі, бажаєте переглянути каталог?"; + +"LEARN.NO_COURSES_VIEW.FIND_A_COURSE" = "Знайти курс"; diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index fb6a1334e..27620fef4 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1609,32 +1609,80 @@ open class DashboardInteractorProtocolMock: DashboardInteractorProtocol, Mock { - open func getMyCourses(page: Int) throws -> [CourseItem] { - addInvocation(.m_getMyCourses__page_page(Parameter.value(`page`))) - let perform = methodPerformValue(.m_getMyCourses__page_page(Parameter.value(`page`))) as? (Int) -> Void + open func getEnrollments(page: Int) throws -> [CourseItem] { + addInvocation(.m_getEnrollments__page_page(Parameter.value(`page`))) + let perform = methodPerformValue(.m_getEnrollments__page_page(Parameter.value(`page`))) as? (Int) -> Void perform?(`page`) var __value: [CourseItem] do { - __value = try methodReturnValue(.m_getMyCourses__page_page(Parameter.value(`page`))).casted() + __value = try methodReturnValue(.m_getEnrollments__page_page(Parameter.value(`page`))).casted() } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for getMyCourses(page: Int). Use given") - Failure("Stub return value not specified for getMyCourses(page: Int). Use given") + onFatalFailure("Stub return value not specified for getEnrollments(page: Int). Use given") + Failure("Stub return value not specified for getEnrollments(page: Int). Use given") } catch { throw error } return __value } - open func discoveryOffline() throws -> [CourseItem] { - addInvocation(.m_discoveryOffline) - let perform = methodPerformValue(.m_discoveryOffline) as? () -> Void + open func getEnrollmentsOffline() throws -> [CourseItem] { + addInvocation(.m_getEnrollmentsOffline) + let perform = methodPerformValue(.m_getEnrollmentsOffline) as? () -> Void perform?() var __value: [CourseItem] do { - __value = try methodReturnValue(.m_discoveryOffline).casted() + __value = try methodReturnValue(.m_getEnrollmentsOffline).casted() } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for discoveryOffline(). Use given") - Failure("Stub return value not specified for discoveryOffline(). Use given") + onFatalFailure("Stub return value not specified for getEnrollmentsOffline(). Use given") + Failure("Stub return value not specified for getEnrollmentsOffline(). Use given") + } catch { + throw error + } + return __value + } + + open func getPrimaryEnrollment(pageSize: Int) throws -> PrimaryEnrollment { + addInvocation(.m_getPrimaryEnrollment__pageSize_pageSize(Parameter.value(`pageSize`))) + let perform = methodPerformValue(.m_getPrimaryEnrollment__pageSize_pageSize(Parameter.value(`pageSize`))) as? (Int) -> Void + perform?(`pageSize`) + var __value: PrimaryEnrollment + do { + __value = try methodReturnValue(.m_getPrimaryEnrollment__pageSize_pageSize(Parameter.value(`pageSize`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getPrimaryEnrollment(pageSize: Int). Use given") + Failure("Stub return value not specified for getPrimaryEnrollment(pageSize: Int). Use given") + } catch { + throw error + } + return __value + } + + open func getPrimaryEnrollmentOffline() throws -> PrimaryEnrollment { + addInvocation(.m_getPrimaryEnrollmentOffline) + let perform = methodPerformValue(.m_getPrimaryEnrollmentOffline) as? () -> Void + perform?() + var __value: PrimaryEnrollment + do { + __value = try methodReturnValue(.m_getPrimaryEnrollmentOffline).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getPrimaryEnrollmentOffline(). Use given") + Failure("Stub return value not specified for getPrimaryEnrollmentOffline(). Use given") + } catch { + throw error + } + return __value + } + + open func getAllCourses(filteredBy: String, page: Int) throws -> PrimaryEnrollment { + addInvocation(.m_getAllCourses__filteredBy_filteredBypage_page(Parameter.value(`filteredBy`), Parameter.value(`page`))) + let perform = methodPerformValue(.m_getAllCourses__filteredBy_filteredBypage_page(Parameter.value(`filteredBy`), Parameter.value(`page`))) as? (String, Int) -> Void + perform?(`filteredBy`, `page`) + var __value: PrimaryEnrollment + do { + __value = try methodReturnValue(.m_getAllCourses__filteredBy_filteredBypage_page(Parameter.value(`filteredBy`), Parameter.value(`page`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getAllCourses(filteredBy: String, page: Int). Use given") + Failure("Stub return value not specified for getAllCourses(filteredBy: String, page: Int). Use given") } catch { throw error } @@ -1643,31 +1691,53 @@ open class DashboardInteractorProtocolMock: DashboardInteractorProtocol, Mock { fileprivate enum MethodType { - case m_getMyCourses__page_page(Parameter) - case m_discoveryOffline + case m_getEnrollments__page_page(Parameter) + case m_getEnrollmentsOffline + case m_getPrimaryEnrollment__pageSize_pageSize(Parameter) + case m_getPrimaryEnrollmentOffline + case m_getAllCourses__filteredBy_filteredBypage_page(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { - case (.m_getMyCourses__page_page(let lhsPage), .m_getMyCourses__page_page(let rhsPage)): + case (.m_getEnrollments__page_page(let lhsPage), .m_getEnrollments__page_page(let rhsPage)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPage, rhs: rhsPage, with: matcher), lhsPage, rhsPage, "page")) return Matcher.ComparisonResult(results) - case (.m_discoveryOffline, .m_discoveryOffline): return .match + case (.m_getEnrollmentsOffline, .m_getEnrollmentsOffline): return .match + + case (.m_getPrimaryEnrollment__pageSize_pageSize(let lhsPagesize), .m_getPrimaryEnrollment__pageSize_pageSize(let rhsPagesize)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPagesize, rhs: rhsPagesize, with: matcher), lhsPagesize, rhsPagesize, "pageSize")) + return Matcher.ComparisonResult(results) + + case (.m_getPrimaryEnrollmentOffline, .m_getPrimaryEnrollmentOffline): return .match + + case (.m_getAllCourses__filteredBy_filteredBypage_page(let lhsFilteredby, let lhsPage), .m_getAllCourses__filteredBy_filteredBypage_page(let rhsFilteredby, let rhsPage)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFilteredby, rhs: rhsFilteredby, with: matcher), lhsFilteredby, rhsFilteredby, "filteredBy")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPage, rhs: rhsPage, with: matcher), lhsPage, rhsPage, "page")) + return Matcher.ComparisonResult(results) default: return .none } } func intValue() -> Int { switch self { - case let .m_getMyCourses__page_page(p0): return p0.intValue - case .m_discoveryOffline: return 0 + case let .m_getEnrollments__page_page(p0): return p0.intValue + case .m_getEnrollmentsOffline: return 0 + case let .m_getPrimaryEnrollment__pageSize_pageSize(p0): return p0.intValue + case .m_getPrimaryEnrollmentOffline: return 0 + case let .m_getAllCourses__filteredBy_filteredBypage_page(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { switch self { - case .m_getMyCourses__page_page: return ".getMyCourses(page:)" - case .m_discoveryOffline: return ".discoveryOffline()" + case .m_getEnrollments__page_page: return ".getEnrollments(page:)" + case .m_getEnrollmentsOffline: return ".getEnrollmentsOffline()" + case .m_getPrimaryEnrollment__pageSize_pageSize: return ".getPrimaryEnrollment(pageSize:)" + case .m_getPrimaryEnrollmentOffline: return ".getPrimaryEnrollmentOffline()" + case .m_getAllCourses__filteredBy_filteredBypage_page: return ".getAllCourses(filteredBy:page:)" } } } @@ -1681,50 +1751,101 @@ open class DashboardInteractorProtocolMock: DashboardInteractorProtocol, Mock { } - public static func getMyCourses(page: Parameter, willReturn: [CourseItem]...) -> MethodStub { - return Given(method: .m_getMyCourses__page_page(`page`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func getEnrollments(page: Parameter, willReturn: [CourseItem]...) -> MethodStub { + return Given(method: .m_getEnrollments__page_page(`page`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getEnrollmentsOffline(willReturn: [CourseItem]...) -> MethodStub { + return Given(method: .m_getEnrollmentsOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func discoveryOffline(willReturn: [CourseItem]...) -> MethodStub { - return Given(method: .m_discoveryOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func getPrimaryEnrollment(pageSize: Parameter, willReturn: PrimaryEnrollment...) -> MethodStub { + return Given(method: .m_getPrimaryEnrollment__pageSize_pageSize(`pageSize`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getMyCourses(page: Parameter, willThrow: Error...) -> MethodStub { - return Given(method: .m_getMyCourses__page_page(`page`), products: willThrow.map({ StubProduct.throw($0) })) + public static func getPrimaryEnrollmentOffline(willReturn: PrimaryEnrollment...) -> MethodStub { + return Given(method: .m_getPrimaryEnrollmentOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getMyCourses(page: Parameter, willProduce: (StubberThrows<[CourseItem]>) -> Void) -> MethodStub { + public static func getAllCourses(filteredBy: Parameter, page: Parameter, willReturn: PrimaryEnrollment...) -> MethodStub { + return Given(method: .m_getAllCourses__filteredBy_filteredBypage_page(`filteredBy`, `page`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getEnrollments(page: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getEnrollments__page_page(`page`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getEnrollments(page: Parameter, willProduce: (StubberThrows<[CourseItem]>) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_getMyCourses__page_page(`page`), products: willThrow.map({ StubProduct.throw($0) })) }() + let given: Given = { return Given(method: .m_getEnrollments__page_page(`page`), products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: ([CourseItem]).self) willProduce(stubber) return given } - public static func discoveryOffline(willThrow: Error...) -> MethodStub { - return Given(method: .m_discoveryOffline, products: willThrow.map({ StubProduct.throw($0) })) + public static func getEnrollmentsOffline(willThrow: Error...) -> MethodStub { + return Given(method: .m_getEnrollmentsOffline, products: willThrow.map({ StubProduct.throw($0) })) } - public static func discoveryOffline(willProduce: (StubberThrows<[CourseItem]>) -> Void) -> MethodStub { + public static func getEnrollmentsOffline(willProduce: (StubberThrows<[CourseItem]>) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_discoveryOffline, products: willThrow.map({ StubProduct.throw($0) })) }() + let given: Given = { return Given(method: .m_getEnrollmentsOffline, products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: ([CourseItem]).self) willProduce(stubber) return given } + public static func getPrimaryEnrollment(pageSize: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getPrimaryEnrollment__pageSize_pageSize(`pageSize`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getPrimaryEnrollment(pageSize: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getPrimaryEnrollment__pageSize_pageSize(`pageSize`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (PrimaryEnrollment).self) + willProduce(stubber) + return given + } + public static func getPrimaryEnrollmentOffline(willThrow: Error...) -> MethodStub { + return Given(method: .m_getPrimaryEnrollmentOffline, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getPrimaryEnrollmentOffline(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getPrimaryEnrollmentOffline, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (PrimaryEnrollment).self) + willProduce(stubber) + return given + } + public static func getAllCourses(filteredBy: Parameter, page: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getAllCourses__filteredBy_filteredBypage_page(`filteredBy`, `page`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getAllCourses(filteredBy: Parameter, page: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getAllCourses__filteredBy_filteredBypage_page(`filteredBy`, `page`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (PrimaryEnrollment).self) + willProduce(stubber) + return given + } } public struct Verify { fileprivate var method: MethodType - public static func getMyCourses(page: Parameter) -> Verify { return Verify(method: .m_getMyCourses__page_page(`page`))} - public static func discoveryOffline() -> Verify { return Verify(method: .m_discoveryOffline)} + public static func getEnrollments(page: Parameter) -> Verify { return Verify(method: .m_getEnrollments__page_page(`page`))} + public static func getEnrollmentsOffline() -> Verify { return Verify(method: .m_getEnrollmentsOffline)} + public static func getPrimaryEnrollment(pageSize: Parameter) -> Verify { return Verify(method: .m_getPrimaryEnrollment__pageSize_pageSize(`pageSize`))} + public static func getPrimaryEnrollmentOffline() -> Verify { return Verify(method: .m_getPrimaryEnrollmentOffline)} + public static func getAllCourses(filteredBy: Parameter, page: Parameter) -> Verify { return Verify(method: .m_getAllCourses__filteredBy_filteredBypage_page(`filteredBy`, `page`))} } public struct Perform { fileprivate var method: MethodType var performs: Any - public static func getMyCourses(page: Parameter, perform: @escaping (Int) -> Void) -> Perform { - return Perform(method: .m_getMyCourses__page_page(`page`), performs: perform) + public static func getEnrollments(page: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_getEnrollments__page_page(`page`), performs: perform) + } + public static func getEnrollmentsOffline(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getEnrollmentsOffline, performs: perform) + } + public static func getPrimaryEnrollment(pageSize: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_getPrimaryEnrollment__pageSize_pageSize(`pageSize`), performs: perform) + } + public static func getPrimaryEnrollmentOffline(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getPrimaryEnrollmentOffline, performs: perform) } - public static func discoveryOffline(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_discoveryOffline, performs: perform) + public static func getAllCourses(filteredBy: Parameter, page: Parameter, perform: @escaping (String, Int) -> Void) -> Perform { + return Perform(method: .m_getAllCourses__filteredBy_filteredBypage_page(`filteredBy`, `page`), performs: perform) } } diff --git a/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift b/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift index d3261b52d..1d3ab3db9 100644 --- a/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift +++ b/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift @@ -1,5 +1,5 @@ // -// DashboardViewModelTests.swift +// ListDashboardViewModelTests.swift // DashboardTests // // Created by  Stepanok Ivan on 18.01.2023. @@ -12,47 +12,51 @@ import XCTest import Alamofire import SwiftUI -final class DashboardViewModelTests: XCTestCase { +final class ListDashboardViewModelTests: XCTestCase { func testGetMyCoursesSuccess() async throws { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DashboardAnalyticsMock() - let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = ListDashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) let items = [ CourseItem(name: "Test", org: "org", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "123", numPages: 2, - coursesCount: 2), + coursesCount: 2, + progressEarned: 0, + progressPossible: 0), CourseItem(name: "Test2", org: "org2", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "1243", numPages: 1, - coursesCount: 2) + coursesCount: 2, + progressEarned: 0, + progressPossible: 0) ] Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .getMyCourses(page: .any, willReturn: items)) + Given(interactor, .getEnrollments(page: .any, willReturn: items)) await viewModel.getMyCourses(page: 1) - Verify(interactor, 1, .getMyCourses(page: .value(1))) + Verify(interactor, 1, .getEnrollments(page: .value(1))) XCTAssertTrue(viewModel.courses == items) XCTAssertNil(viewModel.errorMessage) @@ -63,41 +67,45 @@ final class DashboardViewModelTests: XCTestCase { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DashboardAnalyticsMock() - let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = ListDashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) let items = [ CourseItem(name: "Test", org: "org", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "123", numPages: 2, - coursesCount: 2), + coursesCount: 2, + progressEarned: 0, + progressPossible: 0), CourseItem(name: "Test2", org: "org2", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "1243", numPages: 1, - coursesCount: 2) + coursesCount: 2, + progressEarned: 0, + progressPossible: 0) ] Given(connectivity, .isInternetAvaliable(getter: false)) - Given(interactor, .discoveryOffline(willReturn: items)) + Given(interactor, .getEnrollmentsOffline(willReturn: items)) await viewModel.getMyCourses(page: 1) - Verify(interactor, 1, .discoveryOffline()) + Verify(interactor, 1, .getEnrollmentsOffline()) XCTAssertTrue(viewModel.courses == items) XCTAssertNil(viewModel.errorMessage) @@ -108,14 +116,14 @@ final class DashboardViewModelTests: XCTestCase { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DashboardAnalyticsMock() - let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = ListDashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .getMyCourses(page: .any, willThrow: NoCachedDataError()) ) + Given(interactor, .getEnrollments(page: .any, willThrow: NoCachedDataError()) ) await viewModel.getMyCourses(page: 1) - Verify(interactor, 1, .getMyCourses(page: .value(1))) + Verify(interactor, 1, .getEnrollments(page: .value(1))) XCTAssertTrue(viewModel.courses.isEmpty) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.noCachedData) @@ -126,14 +134,14 @@ final class DashboardViewModelTests: XCTestCase { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DashboardAnalyticsMock() - let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = ListDashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .getMyCourses(page: .any, willThrow: NSError()) ) + Given(interactor, .getEnrollments(page: .any, willThrow: NSError()) ) await viewModel.getMyCourses(page: 1) - Verify(interactor, 1, .getMyCourses(page: .value(1))) + Verify(interactor, 1, .getEnrollments(page: .value(1))) XCTAssertTrue(viewModel.courses.isEmpty) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) diff --git a/Discovery/Discovery/Data/DiscoveryRepository.swift b/Discovery/Discovery/Data/DiscoveryRepository.swift index 3b20f84a3..1d6f5e0a9 100644 --- a/Discovery/Discovery/Data/DiscoveryRepository.swift +++ b/Discovery/Discovery/Data/DiscoveryRepository.swift @@ -128,13 +128,15 @@ class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { org: "Organization", shortDescription: "shortDescription", imageURL: "", - isActive: true, + hasAccess: true, courseStart: nil, courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, courseID: "course_id_\(i)", - numPages: 1, coursesCount: 10 + numPages: 1, coursesCount: 10, + progressEarned: 0, + progressPossible: 0 ) ) } @@ -150,13 +152,15 @@ class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { org: "Organization", shortDescription: "shortDescription", imageURL: "", - isActive: nil, + hasAccess: true, courseStart: nil, courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, courseID: "course_id_\(i)", - numPages: 1, coursesCount: 10 + numPages: 1, coursesCount: 10, + progressEarned: 0, + progressPossible: 0 ) ) } @@ -172,14 +176,16 @@ class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { org: "Organization", shortDescription: "shortDescription", imageURL: "", - isActive: true, + hasAccess: true, courseStart: nil, courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, courseID: "course_id_\(i)", numPages: 1, - coursesCount: 10 + coursesCount: 10, + progressEarned: 0, + progressPossible: 0 ) ) } diff --git a/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents b/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents index 154df9ca8..2c838b0dd 100644 --- a/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents +++ b/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -27,8 +27,8 @@ + - diff --git a/Discovery/Discovery/Presentation/DiscoveryRouter.swift b/Discovery/Discovery/Presentation/DiscoveryRouter.swift index cca463e95..4416d9659 100644 --- a/Discovery/Discovery/Presentation/DiscoveryRouter.swift +++ b/Discovery/Discovery/Presentation/DiscoveryRouter.swift @@ -20,12 +20,14 @@ public protocol DiscoveryRouter: BaseRouter { func showDiscoverySearch(searchQuery: String?) func showCourseScreens( courseID: String, - isActive: Bool?, + hasAccess: Bool?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - title: String + title: String, + showDates: Bool, + lastVisitedBlockID: String? ) func showWebProgramDetails( @@ -51,12 +53,14 @@ public class DiscoveryRouterMock: BaseRouterMock, DiscoveryRouter { public func showDiscoverySearch(searchQuery: String? = nil) {} public func showCourseScreens( courseID: String, - isActive: Bool?, + hasAccess: Bool?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - title: String + title: String, + showDates: Bool, + lastVisitedBlockID: String? ) {} public func showWebProgramDetails( diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index 2300433ef..80864b8fd 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -275,12 +275,14 @@ private struct CourseStateView: View { ) viewModel.router.showCourseScreens( courseID: courseDetails.courseID, - isActive: nil, + hasAccess: nil, courseStart: courseDetails.courseStart, courseEnd: courseDetails.courseEnd, enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, - title: title + title: title, + showDates: false, + lastVisitedBlockID: nil ) } }) diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift index 265631697..99d7ecea9 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift @@ -173,8 +173,8 @@ public struct SearchView: View { .onDisappear { viewModel.searchText = "" } - .background(Theme.Colors.background.ignoresSafeArea()) .avoidKeyboard(dismissKeyboardByTap: true) + .background(Theme.Colors.background.ignoresSafeArea()) } } diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index 18e91733b..836323072 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -231,12 +231,14 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { router.showCourseScreens( courseID: courseDetails.courseID, - isActive: nil, + hasAccess: nil, courseStart: courseDetails.courseStart, courseEnd: courseDetails.courseEnd, enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, - title: courseDetails.courseTitle + title: courseDetails.courseTitle, + showDates: false, + lastVisitedBlockID: nil ) return true diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift index 82c234882..ad0c89987 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift @@ -219,12 +219,14 @@ extension ProgramWebviewViewModel: WebViewNavigationDelegate { router.showCourseScreens( courseID: courseDetails.courseID, - isActive: nil, + hasAccess: nil, courseStart: courseDetails.courseStart, courseEnd: courseDetails.courseEnd, enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, - title: courseDetails.courseTitle + title: courseDetails.courseTitle, + showDates: false, + lastVisitedBlockID: nil ) return true diff --git a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift index 30b6b5706..241178b03 100644 --- a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift @@ -38,26 +38,30 @@ final class DiscoveryViewModelTests: XCTestCase { org: "org", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "123", numPages: 2, - coursesCount: 2), + coursesCount: 2, + progressEarned: 0, + progressPossible: 0), CourseItem(name: "Test2", org: "org2", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "1243", numPages: 1, - coursesCount: 2) + coursesCount: 2, + progressEarned: 0, + progressPossible: 0) ] viewModel.courses = items + items + items viewModel.totalPages = 2 @@ -87,26 +91,30 @@ final class DiscoveryViewModelTests: XCTestCase { org: "org", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "123", numPages: 2, - coursesCount: 0), + coursesCount: 0, + progressEarned: 0, + progressPossible: 0), CourseItem(name: "Test2", org: "org2", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "1243", numPages: 1, - coursesCount: 0) + coursesCount: 0, + progressEarned: 0, + progressPossible: 0) ] Given(interactor, .discovery(page: 1, willReturn: items)) @@ -135,26 +143,30 @@ final class DiscoveryViewModelTests: XCTestCase { org: "org", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "123", numPages: 2, - coursesCount: 2), + coursesCount: 2, + progressEarned: 0, + progressPossible: 0), CourseItem(name: "Test2", org: "org2", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "1243", numPages: 1, - coursesCount: 2) + coursesCount: 2, + progressEarned: 0, + progressPossible: 0) ] Given(connectivity, .isInternetAvaliable(getter: false)) diff --git a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift index 3baf45321..e1596add9 100644 --- a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift @@ -40,26 +40,30 @@ final class SearchViewModelTests: XCTestCase { org: "org", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "123", numPages: 2, - coursesCount: 0), + coursesCount: 0, + progressEarned: 0, + progressPossible: 0), CourseItem(name: "Test2", org: "org2", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "1243", numPages: 1, - coursesCount: 0) + coursesCount: 0, + progressEarned: 0, + progressPossible: 0) ] Given(interactor, .search(page: 1, searchTerm: .any, willReturn: items)) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 2c94092fc..8341e2233 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -44,8 +44,8 @@ 07A7D79028F5C9060000BE81 /* Core.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 07A7D78E28F5C9060000BE81 /* Core.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */; }; 07D5DA3E28D075AB00752FD9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */; }; - 0AF04487E2B198E7A14F037F /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEBF1DCBDEE79A04144660EB /* Pods_App_OpenEdX.framework */; }; 149FF39E2B9F1AB50034B33F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 149FF39C2B9F1AB50034B33F /* LaunchScreen.storyboard */; }; + 1924EDE8164C7AB17AD4946B /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC49621A0942E5EE74BDC895 /* Pods_App_OpenEdX.framework */; }; A500668B2B613ED10024680B /* PushNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668A2B613ED10024680B /* PushNotificationsManager.swift */; }; A500668D2B6143000024680B /* FCMProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668C2B6143000024680B /* FCMProvider.swift */; }; A50066912B61467B0024680B /* BrazeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066902B61467B0024680B /* BrazeProvider.swift */; }; @@ -128,9 +128,9 @@ 07D5DA3128D075AA00752FD9 /* OpenEdX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenEdX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 0F08B37CE833845FF5CD43E0 /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; 149FF39D2B9F1AB50034B33F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 5E9E8EEB795809CB9424EBA6 /* Pods-App-OpenEdX.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releaseprod.xcconfig"; sourceTree = ""; }; + 1BB1F1D0FABF8788646FBAF2 /* Pods-App-OpenEdX.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugstage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugstage.xcconfig"; sourceTree = ""; }; + 37C50995093E34142FDE0ED9 /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; A500668A2B613ED10024680B /* PushNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationsManager.swift; sourceTree = ""; }; A500668C2B6143000024680B /* FCMProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMProvider.swift; sourceTree = ""; }; A50066902B61467B0024680B /* BrazeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeProvider.swift; sourceTree = ""; }; @@ -142,13 +142,13 @@ A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; A59702282B83C87900CA064C /* FirebaseAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseAnalyticsService.swift; sourceTree = ""; }; A5C10D8E2B861A70008E864D /* SegmentAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentAnalyticsService.swift; sourceTree = ""; }; + A681C3929FC384F83BCB6648 /* Pods-App-OpenEdX.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugprod.xcconfig"; sourceTree = ""; }; + AA8BE99557031F3F33F8037C /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; BA7468752B96201D00793145 /* DeepLinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkRouter.swift; sourceTree = ""; }; - BEDEC3C1F88936685DCF7AE5 /* Pods-App-OpenEdX.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugdev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugdev.xcconfig"; sourceTree = ""; }; - C9D8705F03FC185BBE66984C /* Pods-App-OpenEdX.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugprod.xcconfig"; sourceTree = ""; }; - D19FBC727E9AD036986A0B8D /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; - E02715409EC1643835C9EFEE /* Pods-App-OpenEdX.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugstage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugstage.xcconfig"; sourceTree = ""; }; + DAD1882A21DDAF1F67E4C546 /* Pods-App-OpenEdX.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugdev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugdev.xcconfig"; sourceTree = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - FEBF1DCBDEE79A04144660EB /* Pods_App_OpenEdX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_OpenEdX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FC49621A0942E5EE74BDC895 /* Pods_App_OpenEdX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_OpenEdX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FC8F87F82A110A0F7A1B0725 /* Pods-App-OpenEdX.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releaseprod.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -170,7 +170,7 @@ A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */, A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */, A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */, - 0AF04487E2B198E7A14F037F /* Pods_App_OpenEdX.framework in Frameworks */, + 1924EDE8164C7AB17AD4946B /* Pods_App_OpenEdX.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -263,7 +263,7 @@ 072787B028D34D83002E9142 /* Discovery.framework */, 0770DE4A28D0A462006D8A5D /* Authorization.framework */, 0770DE1228D07845006D8A5D /* Core.framework */, - FEBF1DCBDEE79A04144660EB /* Pods_App_OpenEdX.framework */, + FC49621A0942E5EE74BDC895 /* Pods_App_OpenEdX.framework */, ); name = Frameworks; sourceTree = ""; @@ -271,12 +271,12 @@ 55A895025FB07897BA68E063 /* Pods */ = { isa = PBXGroup; children = ( - C9D8705F03FC185BBE66984C /* Pods-App-OpenEdX.debugprod.xcconfig */, - E02715409EC1643835C9EFEE /* Pods-App-OpenEdX.debugstage.xcconfig */, - BEDEC3C1F88936685DCF7AE5 /* Pods-App-OpenEdX.debugdev.xcconfig */, - 5E9E8EEB795809CB9424EBA6 /* Pods-App-OpenEdX.releaseprod.xcconfig */, - 0F08B37CE833845FF5CD43E0 /* Pods-App-OpenEdX.releasestage.xcconfig */, - D19FBC727E9AD036986A0B8D /* Pods-App-OpenEdX.releasedev.xcconfig */, + A681C3929FC384F83BCB6648 /* Pods-App-OpenEdX.debugprod.xcconfig */, + 1BB1F1D0FABF8788646FBAF2 /* Pods-App-OpenEdX.debugstage.xcconfig */, + DAD1882A21DDAF1F67E4C546 /* Pods-App-OpenEdX.debugdev.xcconfig */, + FC8F87F82A110A0F7A1B0725 /* Pods-App-OpenEdX.releaseprod.xcconfig */, + AA8BE99557031F3F33F8037C /* Pods-App-OpenEdX.releasestage.xcconfig */, + 37C50995093E34142FDE0ED9 /* Pods-App-OpenEdX.releasedev.xcconfig */, ); path = Pods; sourceTree = ""; @@ -390,7 +390,7 @@ isa = PBXNativeTarget; buildConfigurationList = 07D5DA4528D075AB00752FD9 /* Build configuration list for PBXNativeTarget "OpenEdX" */; buildPhases = ( - CC61D82AF6FA18BD30E5E8E5 /* [CP] Check Pods Manifest.lock */, + B9442FD26CE9A85A43FC2CFA /* [CP] Check Pods Manifest.lock */, 0770DE2328D08647006D8A5D /* SwiftLint */, 07D5DA2D28D075AA00752FD9 /* Sources */, 07D5DA2E28D075AA00752FD9 /* Frameworks */, @@ -512,7 +512,7 @@ shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n"; }; - CC61D82AF6FA18BD30E5E8E5 /* [CP] Check Pods Manifest.lock */ = { + B9442FD26CE9A85A43FC2CFA /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -695,7 +695,7 @@ }; 02DD1C9629E80CC200F35DCE /* DebugStage */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E02715409EC1643835C9EFEE /* Pods-App-OpenEdX.debugstage.xcconfig */; + baseConfigurationReference = 1BB1F1D0FABF8788646FBAF2 /* Pods-App-OpenEdX.debugstage.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -783,7 +783,7 @@ }; 02DD1C9829E80CCB00F35DCE /* ReleaseStage */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 0F08B37CE833845FF5CD43E0 /* Pods-App-OpenEdX.releasestage.xcconfig */; + baseConfigurationReference = AA8BE99557031F3F33F8037C /* Pods-App-OpenEdX.releasestage.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -877,7 +877,7 @@ }; 0727875928D231FD002E9142 /* DebugDev */ = { isa = XCBuildConfiguration; - baseConfigurationReference = BEDEC3C1F88936685DCF7AE5 /* Pods-App-OpenEdX.debugdev.xcconfig */; + baseConfigurationReference = DAD1882A21DDAF1F67E4C546 /* Pods-App-OpenEdX.debugdev.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -965,7 +965,7 @@ }; 0727875B28D23204002E9142 /* ReleaseDev */ = { isa = XCBuildConfiguration; - baseConfigurationReference = D19FBC727E9AD036986A0B8D /* Pods-App-OpenEdX.releasedev.xcconfig */; + baseConfigurationReference = 37C50995093E34142FDE0ED9 /* Pods-App-OpenEdX.releasedev.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1113,7 +1113,7 @@ }; 07D5DA4628D075AB00752FD9 /* DebugProd */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C9D8705F03FC185BBE66984C /* Pods-App-OpenEdX.debugprod.xcconfig */; + baseConfigurationReference = A681C3929FC384F83BCB6648 /* Pods-App-OpenEdX.debugprod.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1147,7 +1147,7 @@ }; 07D5DA4728D075AB00752FD9 /* ReleaseProd */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5E9E8EEB795809CB9424EBA6 /* Pods-App-OpenEdX.releaseprod.xcconfig */; + baseConfigurationReference = FC8F87F82A110A0F7A1B0725 /* Pods-App-OpenEdX.releaseprod.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 3ef1aac7a..44b421731 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -162,8 +162,25 @@ class ScreenAssembly: Assembly { repository: r.resolve(DashboardRepositoryProtocol.self)! ) } - container.register(DashboardViewModel.self) { r in - DashboardViewModel( + container.register(ListDashboardViewModel.self) { r in + ListDashboardViewModel( + interactor: r.resolve(DashboardInteractorProtocol.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)!, + analytics: r.resolve(DashboardAnalytics.self)! + ) + } + + container.register(PrimaryCourseDashboardViewModel.self) { r in + PrimaryCourseDashboardViewModel( + interactor: r.resolve(DashboardInteractorProtocol.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)!, + analytics: r.resolve(DashboardAnalytics.self)!, + config: r.resolve(ConfigProtocol.self)! + ) + } + + container.register(AllCoursesViewModel.self) { r in + AllCoursesViewModel( interactor: r.resolve(DashboardInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, analytics: r.resolve(DashboardAnalytics.self)! @@ -274,7 +291,7 @@ class ScreenAssembly: Assembly { // MARK: CourseScreensView container.register( CourseContainerViewModel.self - ) { r, isActive, courseStart, courseEnd, enrollmentStart, enrollmentEnd in + ) { r, isActive, courseStart, courseEnd, enrollmentStart, enrollmentEnd, selection, lastVisitedBlockID in CourseContainerViewModel( interactor: r.resolve(CourseInteractorProtocol.self)!, authInteractor: r.resolve(AuthInteractorProtocol.self)!, @@ -289,7 +306,9 @@ class ScreenAssembly: Assembly { courseEnd: courseEnd, enrollmentStart: enrollmentStart, enrollmentEnd: enrollmentEnd, - coreAnalytics: r.resolve(CoreAnalytics.self)! + lastVisitedBlockID: lastVisitedBlockID, + coreAnalytics: r.resolve(CoreAnalytics.self)!, + selection: selection ) } diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index e2fc37e54..74005ca53 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -25,14 +25,16 @@ public class CoursePersistence: CoursePersistenceProtocol { org: $0.org ?? "", shortDescription: $0.desc ?? "", imageURL: $0.imageURL ?? "", - isActive: $0.isActive, + hasAccess: $0.hasAccess, courseStart: $0.courseStart, courseEnd: $0.courseEnd, enrollmentStart: $0.enrollmentStart, enrollmentEnd: $0.enrollmentEnd, courseID: $0.courseID ?? "", numPages: Int($0.numPages), - coursesCount: Int($0.courseCount))} + coursesCount: Int($0.courseCount), + progressEarned: 0, + progressPossible: 0)} if let result, !result.isEmpty { return result } else { @@ -48,9 +50,7 @@ public class CoursePersistence: CoursePersistenceProtocol { newItem.org = item.org newItem.desc = item.shortDescription newItem.imageURL = item.imageURL - if let isActive = item.isActive { - newItem.isActive = isActive - } + newItem.hasAccess = item.hasAccess newItem.courseStart = item.courseStart newItem.courseEnd = item.courseEnd newItem.enrollmentStart = item.enrollmentStart @@ -146,6 +146,7 @@ public class CoursePersistence: CoursePersistenceProtocol { public func saveCourseStructure(structure: DataLayer.CourseStructure) { context.performAndWait { + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let newStructure = CDCourseStructure(context: self.context) newStructure.certificate = structure.certificate?.url newStructure.mediaSmall = structure.media.image.small diff --git a/OpenEdX/Data/DashboardPersistence.swift b/OpenEdX/Data/DashboardPersistence.swift index 06241c00f..b7e0f062a 100644 --- a/OpenEdX/Data/DashboardPersistence.swift +++ b/OpenEdX/Data/DashboardPersistence.swift @@ -18,20 +18,22 @@ public class DashboardPersistence: DashboardPersistenceProtocol { self.context = context } - public func loadMyCourses() throws -> [CourseItem] { + public func loadEnrollments() throws -> [CourseItem] { let result = try? context.fetch(CDDashboardCourse.fetchRequest()) .map { CourseItem(name: $0.name ?? "", org: $0.org ?? "", shortDescription: $0.desc ?? "", imageURL: $0.imageURL ?? "", - isActive: nil, + hasAccess: $0.hasAccess, courseStart: $0.courseStart, courseEnd: $0.courseEnd, enrollmentStart: $0.enrollmentStart, enrollmentEnd: $0.enrollmentEnd, courseID: $0.courseID ?? "", numPages: Int($0.numPages), - coursesCount: Int($0.courseCount))} + coursesCount: Int($0.courseCount), + progressEarned: 0, + progressPossible: 0)} if let result, !result.isEmpty { return result } else { @@ -39,15 +41,16 @@ public class DashboardPersistence: DashboardPersistenceProtocol { } } - public func saveMyCourses(items: [CourseItem]) { + public func saveEnrollments(items: [CourseItem]) { for item in items { context.performAndWait { - let newItem = CDDashboardCourse(context: context) + let newItem = CDDashboardCourse(context: self.context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newItem.name = item.name newItem.org = item.org newItem.desc = item.shortDescription newItem.imageURL = item.imageURL + newItem.hasAccess = item.hasAccess newItem.courseStart = item.courseStart newItem.courseEnd = item.courseEnd newItem.enrollmentStart = item.enrollmentStart @@ -63,4 +66,183 @@ public class DashboardPersistence: DashboardPersistenceProtocol { } } } + + public func loadPrimaryEnrollment() throws -> PrimaryEnrollment { + let request = CDMyEnrollments.fetchRequest() + if let result = try context.fetch(request).first { + let primaryCourse = result.primaryCourse.flatMap { cdPrimaryCourse -> PrimaryCourse? in + + let futureAssignments = (cdPrimaryCourse.futureAssignments as? Set ?? []) + .map { future in + return Assignment( + type: future.type ?? "", + title: future.title ?? "", + description: future.descript ?? "", + date: future.date ?? Date(), + complete: future.complete, + firstComponentBlockId: future.firstComponentBlockId + ) + } + + let pastAssignments = (cdPrimaryCourse.pastAssignments as? Set ?? []) + .map { past in + return Assignment( + type: past.type ?? "", + title: past.title ?? "", + description: past.descript ?? "", + date: past.date ?? Date(), + complete: past.complete, + firstComponentBlockId: past.firstComponentBlockId + ) + } + + return PrimaryCourse( + name: cdPrimaryCourse.name ?? "", + org: cdPrimaryCourse.org ?? "", + courseID: cdPrimaryCourse.courseID ?? "", + hasAccess: cdPrimaryCourse.hasAccess, + courseStart: cdPrimaryCourse.courseStart, + courseEnd: cdPrimaryCourse.courseEnd, + courseBanner: cdPrimaryCourse.courseBanner ?? "", + futureAssignments: futureAssignments, + pastAssignments: pastAssignments, + progressEarned: Int(cdPrimaryCourse.progressEarned), + progressPossible: Int(cdPrimaryCourse.progressPossible), + lastVisitedBlockID: cdPrimaryCourse.lastVisitedBlockID ?? "", + resumeTitle: cdPrimaryCourse.resumeTitle + ) + } + + let courses = (result.courses as? Set ?? []) + .map { cdCourse in + return CourseItem( + name: cdCourse.name ?? "", + org: cdCourse.org ?? "", + shortDescription: cdCourse.desc ?? "", + imageURL: cdCourse.imageURL ?? "", + hasAccess: cdCourse.hasAccess, + courseStart: cdCourse.courseStart, + courseEnd: cdCourse.courseEnd, + enrollmentStart: cdCourse.enrollmentStart, + enrollmentEnd: cdCourse.enrollmentEnd, + courseID: cdCourse.courseID ?? "", + numPages: Int(cdCourse.numPages), + coursesCount: Int(cdCourse.courseCount), + progressEarned: Int(cdCourse.progressEarned), + progressPossible: Int(cdCourse.progressPossible) + ) + } + + return PrimaryEnrollment( + primaryCourse: primaryCourse, + courses: courses, + totalPages: Int(result.totalPages), + count: Int(result.count) + ) + } else { + throw NoCachedDataError() + } + } + + // swiftlint:disable function_body_length + public func savePrimaryEnrollment(enrollments: PrimaryEnrollment) { + context.performAndWait { + // Deleting all old data before saving new ones + clearOldEnrollmentsData() + + let newEnrollment = CDMyEnrollments(context: context) + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + + // Saving new courses + newEnrollment.courses = NSSet(array: enrollments.courses.map { course in + let cdCourse = CDDashboardCourse(context: self.context) + cdCourse.name = course.name + cdCourse.org = course.org + cdCourse.desc = course.shortDescription + cdCourse.imageURL = course.imageURL + cdCourse.courseStart = course.courseStart + cdCourse.courseEnd = course.courseEnd + cdCourse.enrollmentStart = course.enrollmentStart + cdCourse.enrollmentEnd = course.enrollmentEnd + cdCourse.courseID = course.courseID + cdCourse.numPages = Int32(course.numPages) + cdCourse.hasAccess = course.hasAccess + cdCourse.progressEarned = Int32(course.progressEarned) + cdCourse.progressPossible = Int32(course.progressPossible) + return cdCourse + }) + + // Saving PrimaryCourse + if let primaryCourse = enrollments.primaryCourse { + let cdPrimaryCourse = CDPrimaryCourse(context: context) + + let futureAssignments = primaryCourse.futureAssignments.map { assignment in + let cdAssignment = CDAssignment(context: self.context) + cdAssignment.type = assignment.type + cdAssignment.title = assignment.title + cdAssignment.descript = assignment.description + cdAssignment.date = assignment.date + cdAssignment.complete = assignment.complete + cdAssignment.firstComponentBlockId = assignment.firstComponentBlockId + return cdAssignment + } + cdPrimaryCourse.futureAssignments = NSSet(array: futureAssignments) + + let pastAssignments = primaryCourse.pastAssignments.map { assignment in + let cdAssignment = CDAssignment(context: self.context) + cdAssignment.type = assignment.type + cdAssignment.title = assignment.title + cdAssignment.descript = assignment.description + cdAssignment.date = assignment.date + cdAssignment.complete = assignment.complete + cdAssignment.firstComponentBlockId = assignment.firstComponentBlockId + return cdAssignment + } + cdPrimaryCourse.pastAssignments = NSSet(array: pastAssignments) + + cdPrimaryCourse.name = primaryCourse.name + cdPrimaryCourse.org = primaryCourse.org + cdPrimaryCourse.courseID = primaryCourse.courseID + cdPrimaryCourse.hasAccess = primaryCourse.hasAccess + cdPrimaryCourse.courseStart = primaryCourse.courseStart + cdPrimaryCourse.courseEnd = primaryCourse.courseEnd + cdPrimaryCourse.courseBanner = primaryCourse.courseBanner + cdPrimaryCourse.progressEarned = Int32(primaryCourse.progressEarned) + cdPrimaryCourse.progressPossible = Int32(primaryCourse.progressPossible) + cdPrimaryCourse.lastVisitedBlockID = primaryCourse.lastVisitedBlockID + cdPrimaryCourse.resumeTitle = primaryCourse.resumeTitle + + newEnrollment.primaryCourse = cdPrimaryCourse + } + + newEnrollment.totalPages = Int32(enrollments.totalPages) + newEnrollment.count = Int32(enrollments.count) + + do { + try context.save() + } catch { + print("Error when saving MyEnrollments:", error) + } + } + } + // swiftlint:enable function_body_length + + func clearOldEnrollmentsData() { + let fetchRequest1: NSFetchRequest = CDDashboardCourse.fetchRequest() + let batchDeleteRequest1 = NSBatchDeleteRequest(fetchRequest: fetchRequest1) + + let fetchRequest2: NSFetchRequest = CDPrimaryCourse.fetchRequest() + let batchDeleteRequest2 = NSBatchDeleteRequest(fetchRequest: fetchRequest2) + + let fetchRequest3: NSFetchRequest = CDMyEnrollments.fetchRequest() + let batchDeleteRequest3 = NSBatchDeleteRequest(fetchRequest: fetchRequest3) + + do { + try context.execute(batchDeleteRequest1) + try context.execute(batchDeleteRequest2) + try context.execute(batchDeleteRequest3) + } catch { + print("Error when deleting old data:", error) + } + } } diff --git a/OpenEdX/Data/DiscoveryPersistence.swift b/OpenEdX/Data/DiscoveryPersistence.swift index 189264f41..2e6d443bd 100644 --- a/OpenEdX/Data/DiscoveryPersistence.swift +++ b/OpenEdX/Data/DiscoveryPersistence.swift @@ -24,14 +24,16 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { org: $0.org ?? "", shortDescription: $0.desc ?? "", imageURL: $0.imageURL ?? "", - isActive: $0.isActive, + hasAccess: $0.hasAccess, courseStart: $0.courseStart, courseEnd: $0.courseEnd, enrollmentStart: $0.enrollmentStart, enrollmentEnd: $0.enrollmentEnd, courseID: $0.courseID ?? "", numPages: Int($0.numPages), - coursesCount: Int($0.courseCount))} + coursesCount: Int($0.courseCount), + progressEarned: 0, + progressPossible: 0)} if let result, !result.isEmpty { return result } else { @@ -48,9 +50,7 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { newItem.org = item.org newItem.desc = item.shortDescription newItem.imageURL = item.imageURL - if let isActive = item.isActive { - newItem.isActive = isActive - } + newItem.hasAccess = item.hasAccess newItem.courseStart = item.courseStart newItem.courseEnd = item.courseEnd newItem.enrollmentStart = item.enrollmentStart diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift index ae33a9d64..0ae41e30a 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift @@ -107,12 +107,14 @@ extension Router: DeepLinkRouter { if courseDetails.isEnrolled { showCourseScreens( courseID: courseDetails.courseID, - isActive: nil, + hasAccess: nil, courseStart: courseDetails.courseStart, courseEnd: courseDetails.courseEnd, enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, - title: courseDetails.courseTitle + title: courseDetails.courseTitle, + showDates: false, + lastVisitedBlockID: nil ) } else { showCourseDetais( diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 636c4d101..6de11a104 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -6,6 +6,7 @@ // import Course +import Core import Combine import Discovery import SwiftUI @@ -178,15 +179,17 @@ public class PipManager: PipManagerProtocol { for holder: PlayerViewControllerHolderProtocol ) async throws -> UIHostingController { let courseDetails = try await getCourseDetails(for: holder) - let isActive: Bool? = nil + let hasAccess: Bool? = nil let controller = router.getCourseScreensController( courseID: courseDetails.courseID, - isActive: isActive, + hasAccess: hasAccess, courseStart: courseDetails.courseStart, courseEnd: courseDetails.courseEnd, enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, - title: courseDetails.courseTitle + title: courseDetails.courseTitle, + showDates: false, + lastVisitedBlockID: nil ) controller.rootView.viewModel.selection = holder.selectedCourseTab return controller diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index a6e175dff..f14818890 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -359,41 +359,49 @@ public class Router: AuthorizationRouter, public func showCourseScreens( courseID: String, - isActive: Bool?, + hasAccess: Bool?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - title: String + title: String, + showDates: Bool, + lastVisitedBlockID: String? ) { let controller = getCourseScreensController( courseID: courseID, - isActive: isActive, + hasAccess: hasAccess, courseStart: courseStart, courseEnd: courseEnd, enrollmentStart: enrollmentStart, enrollmentEnd: enrollmentEnd, - title: title + title: title, + showDates: showDates, + lastVisitedBlockID: lastVisitedBlockID ) navigationController.pushViewController(controller, animated: true) } public func getCourseScreensController( courseID: String, - isActive: Bool?, + hasAccess: Bool?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - title: String + title: String, + showDates: Bool, + lastVisitedBlockID: String? ) -> UIHostingController { let vm = Container.shared.resolve( CourseContainerViewModel.self, - arguments: isActive, + arguments: hasAccess, courseStart, courseEnd, enrollmentStart, - enrollmentEnd + enrollmentEnd, + showDates ? CourseTab.dates : CourseTab.course, + lastVisitedBlockID )! let datesVm = Container.shared.resolve( @@ -412,6 +420,13 @@ public class Router: AuthorizationRouter, return UIHostingController(rootView: screensView) } + public func showAllCourses(courses: [CourseItem]) { + let vm = Container.shared.resolve(AllCoursesViewModel.self)! + let view = AllCoursesView(viewModel: vm, router: self) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + public func showHandoutsUpdatesView( handouts: String?, announcements: [CourseUpdate]?, diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 98e349542..7e3e30d60 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -37,16 +37,77 @@ struct MainScreenView: View { var body: some View { TabView(selection: $viewModel.selection) { - let config = Container.shared.resolve(ConfigProtocol.self) - if config?.discovery.enabled ?? false { + switch viewModel.config.dashboard.type { + case .list: ZStack { - if config?.discovery.type == .native { + ListDashboardView( + viewModel: Container.shared.resolve(ListDashboardViewModel.self)!, + router: Container.shared.resolve(DashboardRouter.self)! + ) + + if updateAvailable { + UpdateNotificationView(config: viewModel.config) + } + } + .tabItem { + CoreAssets.dashboard.swiftUIImage.renderingMode(.template) + Text(CoreLocalization.Mainscreen.dashboard) + } + .tag(MainTab.dashboard) + .accessibilityIdentifier("dashboard_tabitem") + if viewModel.config.program.enabled { + ZStack { + if viewModel.config.program.type == .webview { + ProgramWebviewView( + viewModel: Container.shared.resolve(ProgramWebviewViewModel.self)!, + router: Container.shared.resolve(DiscoveryRouter.self)! + ) + } else if viewModel.config.program.type == .native { + Text(CoreLocalization.Mainscreen.inDeveloping) + } + + if updateAvailable { + UpdateNotificationView(config: viewModel.config) + } + } + .tabItem { + CoreAssets.programs.swiftUIImage.renderingMode(.template) + Text(CoreLocalization.Mainscreen.programs) + } + .tag(MainTab.programs) + } + case .gallery: + ZStack { + PrimaryCourseDashboardView( + viewModel: Container.shared.resolve(PrimaryCourseDashboardViewModel.self)!, + router: Container.shared.resolve(DashboardRouter.self)!, + programView: ProgramWebviewView( + viewModel: Container.shared.resolve(ProgramWebviewViewModel.self)!, + router: Container.shared.resolve(DiscoveryRouter.self)! + ), + openDiscoveryPage: { viewModel.selection = .discovery } + ) + if updateAvailable { + UpdateNotificationView(config: viewModel.config) + } + } + .tabItem { + CoreAssets.learn.swiftUIImage.renderingMode(.template) + Text(CoreLocalization.Mainscreen.learn) + } + .tag(MainTab.dashboard) + .accessibilityIdentifier("dashboard_tabitem") + } + + if viewModel.config.discovery.enabled { + ZStack { + if viewModel.config.discovery.type == .native { DiscoveryView( viewModel: Container.shared.resolve(DiscoveryViewModel.self)!, router: Container.shared.resolve(DiscoveryRouter.self)!, sourceScreen: viewModel.sourceScreen ) - } else if config?.discovery.type == .webview { + } else if viewModel.config.discovery.type == .webview { DiscoveryWebview( viewModel: Container.shared.resolve( DiscoveryWebviewViewModel.self, @@ -67,46 +128,6 @@ struct MainScreenView: View { .accessibilityIdentifier("discovery_tabitem") } - ZStack { - DashboardView( - viewModel: Container.shared.resolve(DashboardViewModel.self)!, - router: Container.shared.resolve(DashboardRouter.self)! - ) - if updateAvailable { - UpdateNotificationView(config: viewModel.config) - } - } - .tabItem { - CoreAssets.dashboard.swiftUIImage.renderingMode(.template) - Text(CoreLocalization.Mainscreen.dashboard) - } - .tag(MainTab.dashboard) - .accessibilityIdentifier("dashboard_tabitem") - - if config?.program.enabled ?? false { - ZStack { - if config?.program.type == .webview { - ProgramWebviewView( - viewModel: Container.shared.resolve(ProgramWebviewViewModel.self)!, - router: Container.shared.resolve(DiscoveryRouter.self)! - ) - } else if config?.program.type == .native { - Text(CoreLocalization.Mainscreen.inDeveloping) - .accessibilityIdentifier("indevelopment_program_text") - } - - if updateAvailable { - UpdateNotificationView(config: viewModel.config) - } - } - .tabItem { - CoreAssets.programs.swiftUIImage.renderingMode(.template) - Text(CoreLocalization.Mainscreen.programs) - } - .tag(MainTab.programs) - .accessibilityIdentifier("programs_tabitem") - } - VStack { ProfileView( viewModel: Container.shared.resolve(ProfileViewModel.self)! @@ -119,8 +140,8 @@ struct MainScreenView: View { .tag(MainTab.profile) .accessibilityIdentifier("profile_tabitem") } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) + .navigationBarHidden(viewModel.selection == .dashboard) + .navigationBarBackButtonHidden(viewModel.selection == .dashboard) .navigationTitle(titleBar()) .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: { @@ -171,7 +192,9 @@ struct MainScreenView: View { case .discovery: return DiscoveryLocalization.title case .dashboard: - return DashboardLocalization.title + return viewModel.config.dashboard.type == .list + ? DashboardLocalization.title + : DashboardLocalization.Learn.title case .programs: return CoreLocalization.Mainscreen.programs case .profile: diff --git a/Theme/Theme/Assets.xcassets/Colors/AccentColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/AccentColor.colorset/Contents.json index 00d59cb46..bf1a96417 100644 --- a/Theme/Theme/Assets.xcassets/Colors/AccentColor.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/AccentColor.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.976", - "green" : "0.471", - "red" : "0.329" + "blue" : "0xF8", + "green" : "0x78", + "red" : "0x53" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/AccentXColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/AccentXColor.colorset/Contents.json index 00d59cb46..bf1a96417 100644 --- a/Theme/Theme/Assets.xcassets/Colors/AccentXColor.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/AccentXColor.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.976", - "green" : "0.471", - "red" : "0.329" + "blue" : "0xF8", + "green" : "0x78", + "red" : "0x53" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewStroke.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewStroke.colorset/Contents.json index 0a5fa0807..d31f2bcff 100644 --- a/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewStroke.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewStroke.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.878", - "green" : "0.831", - "red" : "0.800" + "blue" : "0xDF", + "green" : "0xD3", + "red" : "0xCC" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/CommentCellBackground.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CommentCellBackground.colorset/Contents.json index 00cf4a827..e9a7a3504 100644 --- a/Theme/Theme/Assets.xcassets/Colors/CommentCellBackground.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/CommentCellBackground.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.961", - "green" : "0.961", - "red" : "0.961" + "blue" : "0xF5", + "green" : "0xF5", + "red" : "0xF5" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/CourseCardShadow.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseCardShadow.colorset/Contents.json new file mode 100644 index 000000000..e7c5c162d --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/CourseCardShadow.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.150", + "blue" : "0x2F", + "green" : "0x21", + "red" : "0x19" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.400", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index aa00c67f5..2daf3d16b 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -35,6 +35,7 @@ public enum ThemeAssets { public static let cardViewStroke = ColorAsset(name: "CardViewStroke") public static let certificateForeground = ColorAsset(name: "CertificateForeground") public static let commentCellBackground = ColorAsset(name: "CommentCellBackground") + public static let courseCardShadow = ColorAsset(name: "CourseCardShadow") public static let datesSectionBackground = ColorAsset(name: "DatesSectionBackground") public static let datesSectionStroke = ColorAsset(name: "DatesSectionStroke") public static let nextWeekTimelineColor = ColorAsset(name: "NextWeekTimelineColor") diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index 0e91adb2b..73a4b4939 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -70,6 +70,7 @@ public struct Theme { public private(set) static var slidingStrokeColor = ThemeAssets.slidingStrokeColor.swiftUIColor public private(set) static var primaryHeaderColor = ThemeAssets.primaryHeaderColor.swiftUIColor public private(set) static var secondaryHeaderColor = ThemeAssets.secondaryHeaderColor.swiftUIColor + public private(set) static var courseCardShadow = ThemeAssets.courseCardShadow.swiftUIColor public static func update( accentColor: Color = ThemeAssets.accentColor.swiftUIColor,