From d3805511fdcfaafee587a10a06d2e5bd189c7f53 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 7 Jun 2024 15:18:48 +0300 Subject: [PATCH] feat: [FC-0047] Course progress and collapsing sections (#446) * feat: course home navigation * fix: address feedback * fix: merge conflicts * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback --- Core/Core.xcodeproj/project.pbxproj | 3 - .../deleteDownloading.imageset/Contents.json | 2 +- .../deleteDownloading.imageset/Frame-17.svg | 13 - .../deleteDownloading.svg | 3 + .../startDownloading.imageset/Contents.json | 15 +- .../startDownloading.imageset/Frame-16.svg | 12 - .../download_dark.svg | 3 + .../download_light.svg | 3 + .../finished_sequence.imageset/Contents.json | 12 + .../finished_sequence.svg | 3 + .../Config/UIComponentsConfig.swift | 6 +- .../Data/Model/Data_PrimaryEnrollment.swift | 2 +- Core/Core/Domain/Model/CourseBlockModel.swift | 38 +- Core/Core/SwiftGen/Assets.swift | 1 + .../View/Base/CustomDisclosureGroup.swift | 49 --- Core/Core/View/Base/DownloadView.swift | 5 +- Core/Core/en.lproj/Localizable.strings | 1 + Course/Course.xcodeproj/project.pbxproj | 38 +- Course/Course/Data/CourseRepository.swift | 30 +- .../Model/Data_CourseOutlineResponse.swift | 37 +- .../Course/Data/Network/CourseEndpoint.swift | 2 +- .../CourseCoreModel.xcdatamodel/contents | 8 +- Course/Course/Domain/CourseInteractor.swift | 10 +- .../Container/CourseContainerViewModel.swift | 19 +- .../Outline/ContinueWithView.swift | 2 + .../Outline/CourseOutlineView.swift | 38 +- .../CourseStructureNestedListView.swift | 237 ----------- .../CourseStructure/CourseStructureView.swift | 134 ------ .../CourseVerticalImageView.swift | 7 +- .../CourseVertical/CourseVerticalView.swift | 9 +- .../Subviews/CourseProgressView.swift | 54 +++ .../Subviews/CustomDisclosureGroup.swift | 400 ++++++++++++++++++ .../Presentation/Unit/CourseUnitView.swift | 23 +- .../DropdownList/CourseUnitDropDownCell.swift | 1 + .../DropdownList/CourseUnitDropDownList.swift | 4 + .../CourseUnitVerticalsDropdownView.swift | 4 + Course/Course/SwiftGen/Strings.swift | 18 + Course/Course/en.lproj/Localizable.strings | 5 + .../Course/en.lproj/Localizable.stringsdict | 42 ++ Course/Course/uk.lproj/Localizable.strings | 5 + .../Course/uk.lproj/Localizable.stringsdict | 42 ++ .../CourseContainerViewModelTests.swift | 70 ++- .../Unit/CourseDateViewModelTests.swift | 3 +- .../Unit/CourseUnitViewModelTests.swift | 16 +- OpenEdX/DI/AppAssembly.swift | 5 +- OpenEdX/DI/ScreenAssembly.swift | 2 +- OpenEdX/Data/CoursePersistence.swift | 30 +- OpenEdX/Managers/PipManager.swift | 8 +- OpenEdX/Router.swift | 9 +- .../CardViewBackground.colorset/Contents.json | 6 +- .../InfoColor.colorset copy/Contents.json | 38 -- .../Contents.json | 38 -- .../Colors/TabbarColor.colorset/Contents.json | 6 +- 53 files changed, 941 insertions(+), 630 deletions(-) delete mode 100644 Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Frame-17.svg create mode 100644 Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/deleteDownloading.svg delete mode 100644 Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Frame-16.svg create mode 100644 Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_dark.svg create mode 100644 Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_light.svg create mode 100644 Core/Core/Assets.xcassets/finished_sequence.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/finished_sequence.imageset/finished_sequence.svg delete mode 100644 Core/Core/View/Base/CustomDisclosureGroup.swift delete mode 100644 Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift delete mode 100644 Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift create mode 100644 Course/Course/Presentation/Subviews/CourseProgressView.swift create mode 100644 Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift create mode 100644 Course/Course/en.lproj/Localizable.stringsdict create mode 100644 Course/Course/uk.lproj/Localizable.stringsdict delete mode 100644 Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset copy/Contents.json delete mode 100644 Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset copy/Contents.json diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 3d8d938c7..ad4325f39 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -163,7 +163,6 @@ BA8FA6702AD59EA300EA029A /* MicrosoftAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8FA66F2AD59EA300EA029A /* MicrosoftAuthProvider.swift */; }; BA981BCE2B8F5C49005707C2 /* Sequence+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA981BCD2B8F5C49005707C2 /* Sequence+Extensions.swift */; }; BA981BD02B91ED50005707C2 /* FullScreenProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA981BCF2B91ED50005707C2 /* FullScreenProgressView.swift */; }; - BAAD62C62AFCF00B000E6103 /* CustomDisclosureGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */; }; BAD9CA2F2B289B3500DE790A /* ajaxHandler.js in Resources */ = {isa = PBXBuildFile; fileRef = BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */; }; BAD9CA332B28A8F300DE790A /* AjaxProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */; }; BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */; }; @@ -359,7 +358,6 @@ BA8FA66F2AD59EA300EA029A /* MicrosoftAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosoftAuthProvider.swift; sourceTree = "<group>"; }; BA981BCD2B8F5C49005707C2 /* Sequence+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Extensions.swift"; sourceTree = "<group>"; }; BA981BCF2B91ED50005707C2 /* FullScreenProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenProgressView.swift; sourceTree = "<group>"; }; - BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisclosureGroup.swift; sourceTree = "<group>"; }; BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = ajaxHandler.js; sourceTree = "<group>"; }; BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AjaxProvider.swift; sourceTree = "<group>"; }; BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfigTests.swift; sourceTree = "<group>"; }; @@ -748,7 +746,6 @@ 023A1137291432FD00D0D354 /* FieldConfiguration.swift */, BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */, BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */, - BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */, BA8FA6672AD59A5700EA029A /* SocialAuthButton.swift */, 02E93F862AEBAED4006C4750 /* AppReview */, BA981BCF2B91ED50005707C2 /* FullScreenProgressView.swift */, diff --git a/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Contents.json b/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Contents.json index d1927cffc..41ea480fe 100644 --- a/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Frame-17.svg", + "filename" : "deleteDownloading.svg", "idiom" : "universal" } ], diff --git a/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Frame-17.svg b/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Frame-17.svg deleted file mode 100644 index 0ae948676..000000000 --- a/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Frame-17.svg +++ /dev/null @@ -1,13 +0,0 @@ -<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> -<g clip-path="url(#clip0_1657_17016)"> -<path d="M19 17.0002C19.9282 17.0002 20.8185 16.6314 21.4749 15.9751C22.1312 15.3187 22.5 14.4285 22.5 13.5002C22.5 12.5719 22.1312 11.6817 21.4749 11.0253C20.8185 10.3689 19.9282 10.0002 19 10.0002H18C18.1459 9.35015 18.1481 8.68061 18.0065 8.02979C17.8649 7.37898 17.5823 6.75963 17.1748 6.20712C16.7672 5.65461 16.2427 5.17976 15.6313 4.80968C15.0198 4.43959 14.3334 4.18152 13.6111 4.0502C12.8888 3.91887 12.1449 3.91687 11.4218 4.04431C10.6986 4.17174 10.0105 4.42612 9.39658 4.79291C8.15675 5.53368 7.29467 6.68737 6.99999 8.0002C5.93394 7.95749 4.88553 8.2706 4.03426 8.88589C3.18299 9.50119 2.58181 10.3804 2.33366 11.373C2.08551 12.3656 2.20582 13.4099 2.67399 14.327C3.14216 15.2441 3.92907 15.977 4.89999 16.4002" stroke="white" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/> -<path d="M8 14H17" stroke="white" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/> -<path d="M9 14L9.5 20C9.5 20.2652 9.60536 20.5196 9.79289 20.7071C9.98043 20.8946 10.2348 21 10.5 21H14.5C14.7652 21 15.0196 20.8946 15.2071 20.7071C15.3946 20.5196 15.5 20.2652 15.5 20L16 14" stroke="white" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/> -<path d="M11.5 11.2998H13.8" stroke="white" stroke-width="1.75" stroke-linecap="round"/> -</g> -<defs> -<clipPath id="clip0_1657_17016"> -<rect width="24" height="24" fill="white"/> -</clipPath> -</defs> -</svg> diff --git a/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/deleteDownloading.svg b/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/deleteDownloading.svg new file mode 100644 index 000000000..ed2659aab --- /dev/null +++ b/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/deleteDownloading.svg @@ -0,0 +1,3 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4C9.11 4 6.6 5.64 5.35 8.04C2.34 8.36 0 10.91 0 14C0 17.31 2.69 20 6 20H19C21.76 20 24 17.76 24 15C24 12.36 21.95 10.22 19.35 10.04ZM10.71 16.29C10.32 16.68 9.69 16.68 9.3 16.29L7.2 14.2C6.81 13.81 6.81 13.18 7.2 12.79C7.59 12.4 8.22 12.4 8.61 12.79L10 14.18L14.48 9.7C14.87 9.31 15.5 9.31 15.89 9.7C16.28 10.09 16.28 10.72 15.89 11.11L10.71 16.29Z" fill="#198571"/> +</svg> diff --git a/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Contents.json b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Contents.json index 866687bad..672c958c5 100644 --- a/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Contents.json @@ -1,12 +1,25 @@ { "images" : [ { - "filename" : "Frame-16.svg", + "filename" : "download_light.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "download_dark.svg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Frame-16.svg b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Frame-16.svg deleted file mode 100644 index 24d291489..000000000 --- a/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Frame-16.svg +++ /dev/null @@ -1,12 +0,0 @@ -<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> -<g clip-path="url(#clip0_1576_13590)"> -<path d="M19 18.0002C19.9282 18.0002 20.8185 17.6314 21.4749 16.9751C22.1312 16.3187 22.5 15.4285 22.5 14.5002C22.5 13.5719 22.1312 12.6817 21.4749 12.0253C20.8185 11.3689 19.9282 11.0002 19 11.0002H18C18.1459 10.3502 18.1481 9.68061 18.0065 9.02979C17.8649 8.37898 17.5823 7.75963 17.1748 7.20712C16.7672 6.65461 16.2427 6.17976 15.6313 5.80968C15.0198 5.43959 14.3334 5.18152 13.6111 5.0502C12.8888 4.91887 12.1449 4.91687 11.4218 5.04431C10.6986 5.17174 10.0105 5.42612 9.39658 5.79291C8.15675 6.53368 7.29467 7.68737 6.99999 9.0002C5.93394 8.95749 4.88553 9.2706 4.03426 9.88589C3.18299 10.5012 2.58181 11.3804 2.33366 12.373C2.08551 13.3656 2.20582 14.4099 2.67399 15.327C3.14216 16.2441 3.92907 16.977 4.89999 17.4002" stroke="white" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/> -<path d="M12 13V22" stroke="white" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/> -<path d="M9 19L12 22L15 19" stroke="white" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/> -</g> -<defs> -<clipPath id="clip0_1576_13590"> -<rect width="24" height="24" fill="white"/> -</clipPath> -</defs> -</svg> diff --git a/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_dark.svg b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_dark.svg new file mode 100644 index 000000000..8a29b74a2 --- /dev/null +++ b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_dark.svg @@ -0,0 +1,3 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4C9.11 4 6.6 5.64 5.35 8.04C2.34 8.36 0 10.91 0 14C0 17.31 2.69 20 6 20H19C21.76 20 24 17.76 24 15C24 12.36 21.95 10.22 19.35 10.04ZM19 18H6C3.79 18 2 16.21 2 14C2 11.95 3.53 10.24 5.56 10.03L6.63 9.92L7.13 8.97C8.08 7.14 9.94 6 12 6C14.62 6 16.88 7.86 17.39 10.43L17.69 11.93L19.22 12.04C20.78 12.14 22 13.45 22 15C22 16.65 20.65 18 19 18ZM13.45 10H10.55V13H8L12 17L16 13H13.45V10Z" fill="#879FF5"/> +</svg> diff --git a/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_light.svg b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_light.svg new file mode 100644 index 000000000..1f933d639 --- /dev/null +++ b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_light.svg @@ -0,0 +1,3 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4C9.11 4 6.6 5.64 5.35 8.04C2.34 8.36 0 10.91 0 14C0 17.31 2.69 20 6 20H19C21.76 20 24 17.76 24 15C24 12.36 21.95 10.22 19.35 10.04ZM19 18H6C3.79 18 2 16.21 2 14C2 11.95 3.53 10.24 5.56 10.03L6.63 9.92L7.13 8.97C8.08 7.14 9.94 6 12 6C14.62 6 16.88 7.86 17.39 10.43L17.69 11.93L19.22 12.04C20.78 12.14 22 13.45 22 15C22 16.65 20.65 18 19 18ZM13.45 10H10.55V13H8L12 17L16 13H13.45V10Z" fill="#3C68FF"/> +</svg> diff --git a/Core/Core/Assets.xcassets/finished_sequence.imageset/Contents.json b/Core/Core/Assets.xcassets/finished_sequence.imageset/Contents.json new file mode 100644 index 000000000..9d0a51bf3 --- /dev/null +++ b/Core/Core/Assets.xcassets/finished_sequence.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "finished_sequence.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/finished_sequence.imageset/finished_sequence.svg b/Core/Core/Assets.xcassets/finished_sequence.imageset/finished_sequence.svg new file mode 100644 index 000000000..51ed61934 --- /dev/null +++ b/Core/Core/Assets.xcassets/finished_sequence.imageset/finished_sequence.svg @@ -0,0 +1,3 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 20C7.59 20 4 16.41 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 16.41 16.41 20 12 20ZM15.88 8.29L10 14.17L8.12 12.29C7.73 11.9 7.1 11.9 6.71 12.29C6.32 12.68 6.32 13.31 6.71 13.7L9.3 16.29C9.69 16.68 10.32 16.68 10.71 16.29L17.3 9.7C17.69 9.31 17.69 8.68 17.3 8.29C16.91 7.9 16.27 7.9 15.88 8.29Z" fill="#198571"/> +</svg> diff --git a/Core/Core/Configuration/Config/UIComponentsConfig.swift b/Core/Core/Configuration/Config/UIComponentsConfig.swift index cb5ce3e68..1b8cf788e 100644 --- a/Core/Core/Configuration/Config/UIComponentsConfig.swift +++ b/Core/Core/Configuration/Config/UIComponentsConfig.swift @@ -8,16 +8,16 @@ import Foundation private enum Keys: String, RawStringExtractable { - case courseNestedListEnabled = "COURSE_NESTED_LIST_ENABLED" + case courseDropDownNavigationEnabled = "COURSE_DROPDOWN_NAVIGATION_ENABLED" case courseUnitProgressEnabled = "COURSE_UNIT_PROGRESS_ENABLED" } public class UIComponentsConfig: NSObject { - public var courseNestedListEnabled: Bool + public var courseDropDownNavigationEnabled: Bool public var courseUnitProgressEnabled: Bool init(dictionary: [String: Any]) { - courseNestedListEnabled = dictionary[Keys.courseNestedListEnabled] as? Bool ?? false + courseDropDownNavigationEnabled = dictionary[Keys.courseDropDownNavigationEnabled] as? Bool ?? false courseUnitProgressEnabled = dictionary[Keys.courseUnitProgressEnabled] as? Bool ?? false super.init() } diff --git a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift index 1102bae78..60764c78a 100644 --- a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift +++ b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift @@ -36,7 +36,7 @@ public extension DataLayer { public let certificate: DataLayer.Certificate? public let courseModes: [CourseMode]? public let courseStatus: CourseStatus? - public let progress: CourseProgress? + public let progress: DataLayer.CourseProgress? public let courseAssignments: CourseAssignments? enum CodingKeys: String, CodingKey { diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index 406bea3ed..96ef3ccde 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -24,6 +24,7 @@ public struct CourseStructure: Equatable { public let certificate: Certificate? public let org: String public let isSelfPaced: Bool + public let courseProgress: CourseProgress? public init( id: String, @@ -37,7 +38,8 @@ public struct CourseStructure: Equatable { media: DataLayer.CourseMedia, certificate: Certificate?, org: String, - isSelfPaced: Bool + isSelfPaced: Bool, + courseProgress: CourseProgress? ) { self.id = id self.graded = graded @@ -51,6 +53,7 @@ public struct CourseStructure: Equatable { self.certificate = certificate self.org = org self.isSelfPaced = isSelfPaced + self.courseProgress = courseProgress } public func totalVideosSizeInBytes(downloadQuality: DownloadQuality) -> Int { @@ -78,6 +81,16 @@ public struct CourseStructure: Equatable { } } +public struct CourseProgress { + public let totalAssignmentsCount: Int? + public let assignmentsCompleted: Int? + + public init(totalAssignmentsCount: Int, assignmentsCompleted: Int) { + self.totalAssignmentsCount = totalAssignmentsCount + self.assignmentsCompleted = assignmentsCompleted + } +} + public struct CourseChapter: Identifiable { public let blockId: String @@ -109,6 +122,8 @@ public struct CourseSequential: Identifiable { public let type: BlockType public let completion: Double public var childs: [CourseVertical] + public let sequentialProgress: SequentialProgress? + public let due: Date? public var isDownloadable: Bool { return childs.first(where: { $0.isDownloadable }) != nil @@ -120,7 +135,9 @@ public struct CourseSequential: Identifiable { displayName: String, type: BlockType, completion: Double, - childs: [CourseVertical] + childs: [CourseVertical], + sequentialProgress: SequentialProgress?, + due: Date? ) { self.blockId = blockId self.id = id @@ -128,6 +145,8 @@ public struct CourseSequential: Identifiable { self.type = type self.completion = completion self.childs = childs + self.sequentialProgress = sequentialProgress + self.due = due } } @@ -177,6 +196,18 @@ public struct SubtitleUrl: Equatable { } } +public struct SequentialProgress { + public let assignmentType: String? + public let numPointsEarned: Int? + public let numPointsPossible: Int? + + public init(assignmentType: String?, numPointsEarned: Int?, numPointsPossible: Int?) { + self.assignmentType = assignmentType + self.numPointsEarned = numPointsEarned + self.numPointsPossible = numPointsPossible + } +} + public struct CourseBlock: Hashable, Identifiable { public static func == (lhs: CourseBlock, rhs: CourseBlock) -> Bool { lhs.id == rhs.id && @@ -193,6 +224,7 @@ public struct CourseBlock: Hashable, Identifiable { public let courseId: String public let topicId: String? public let graded: Bool + public let due: Date? public var completion: Double public let type: BlockType public let displayName: String @@ -212,6 +244,7 @@ public struct CourseBlock: Hashable, Identifiable { courseId: String, topicId: String? = nil, graded: Bool, + due: Date?, completion: Double, type: BlockType, displayName: String, @@ -226,6 +259,7 @@ public struct CourseBlock: Hashable, Identifiable { self.courseId = courseId self.topicId = topicId self.graded = graded + self.due = due self.completion = completion self.type = type self.displayName = displayName diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index ea47e8ee4..6f38ed569 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -107,6 +107,7 @@ public enum CoreAssets { public static let clearInput = ImageAsset(name: "clearInput") public static let edit = ImageAsset(name: "edit") public static let favorite = ImageAsset(name: "favorite") + public static let finishedSequence = ImageAsset(name: "finished_sequence") public static let goodWork = ImageAsset(name: "goodWork") public static let learnEmpty = ImageAsset(name: "learn_empty") public static let airmail = ImageAsset(name: "airmail") diff --git a/Core/Core/View/Base/CustomDisclosureGroup.swift b/Core/Core/View/Base/CustomDisclosureGroup.swift deleted file mode 100644 index c4a023ed3..000000000 --- a/Core/Core/View/Base/CustomDisclosureGroup.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// CustomDisclosureGroup.swift -// Core -// -// Created by Eugene Yatsenko on 09.11.2023. -// - -import SwiftUI - -public struct CustomDisclosureGroup<Header: View, Content: View>: View { - - @Binding var isExpanded: Bool - - private var onClick: () -> Void - private var animation: Animation? - private let header: Header - private let content: Content - - public init( - animation: Animation?, - isExpanded: Binding<Bool>, - onClick: @escaping () -> Void, - header: (_ isExpanded: Bool) -> Header, - content: () -> Content - ) { - self.onClick = onClick - self._isExpanded = isExpanded - self.animation = animation - self.header = header(isExpanded.wrappedValue) - self.content = content() - } - - public var body: some View { - VStack(spacing: 0) { - Button { - withAnimation(animation) { - onClick() - } - } label: { - header - .contentShape(Rectangle()) - } - if isExpanded { - content - } - } - .clipped() - } -} diff --git a/Core/Core/View/Base/DownloadView.swift b/Core/Core/View/Base/DownloadView.swift index 37f63e41d..ede7c2912 100644 --- a/Core/Core/View/Base/DownloadView.swift +++ b/Core/Core/View/Base/DownloadView.swift @@ -24,7 +24,6 @@ public struct DownloadAvailableView: View { .resizable() .scaledToFit() .frame(width: 24, height: 24) - .foregroundColor(Theme.Colors.textPrimary) } .frame(width: 30, height: 30) } @@ -41,6 +40,7 @@ public struct DownloadProgressView: View { .resizable() .scaledToFit() .frame(width: 20, height: 20) + .foregroundStyle(Theme.Colors.snackbarErrorColor) .foregroundColor(Theme.Colors.textPrimary) } } @@ -52,11 +52,10 @@ public struct DownloadFinishedView: View { public var body: some View { VStack(spacing: 0) { - CoreAssets.deleteDownloading.swiftUIImage.renderingMode(.template) + CoreAssets.deleteDownloading.swiftUIImage .resizable() .scaledToFit() .frame(width: 24, height: 24) - .foregroundColor(Theme.Colors.textPrimary) } .frame(width: 30, height: 30) } diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 44186571a..b1fda17c5 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -48,6 +48,7 @@ "DATE.COURSE_STARTS" = "Course Starts"; "DATE.COURSE_ENDS" = "Course Ends"; "DATE.COURSE_ENDED" = "Course Ended"; + "DATE.ENDED" = "Ended"; "DATE.START" = "Start"; "DATE.STARTED" = "Started"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index ed692cdd8..efc45a860 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -45,7 +45,10 @@ 02B6B3BC28E1D14F00232911 /* CourseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B6B3BB28E1D14F00232911 /* CourseRepository.swift */; }; 02B6B3BE28E1D15C00232911 /* CourseEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B6B3BD28E1D15C00232911 /* CourseEndpoint.swift */; }; 02B6B3C928E1E68100232911 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02B6B3C828E1E68100232911 /* Core.framework */; }; + 02BB20182BFCE7B200364948 /* CustomDisclosureGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02BB20172BFCE7B200364948 /* CustomDisclosureGroup.swift */; }; + 02C355392C08DCD700501342 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 02C355372C08DCD700501342 /* Localizable.stringsdict */; }; 02D4FC2E2BBD7C9C00C47748 /* MessageSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */; }; + 02E3803E2BFF9F0A00815AFA /* CourseProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */; }; 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0144E28F46474002E513D /* CourseContainerView.swift */; }; 02F0145728F4A2FF002E513D /* CourseContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */; }; 02F3BFDD29252E900051930C /* CourseRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFDC29252E900051930C /* CourseRouter.swift */; }; @@ -84,12 +87,10 @@ BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF5C2B3D804D005B102E /* CourseStorage.swift */; }; BA58CF612B471041005B102E /* VideoDownloadQualityBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF602B471041005B102E /* VideoDownloadQualityBarView.swift */; }; BA58CF642B471363005B102E /* VideoDownloadQualityContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF632B471363005B102E /* VideoDownloadQualityContainerView.swift */; }; - BAAD62C82AFD00EE000E6103 /* CourseStructureNestedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAAD62C72AFD00EE000E6103 /* CourseStructureNestedListView.swift */; }; BAC0E0D82B32EF03006B68A9 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC0E0D72B32EF03006B68A9 /* DownloadsView.swift */; }; BAC0E0DB2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC0E0DA2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift */; }; BAC0E0DE2B32F0F3006B68A9 /* DownloadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC0E0DD2B32F0F3006B68A9 /* DownloadsViewModel.swift */; }; BAD9CA2D2B2736BB00DE790A /* LessonLineProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA2C2B2736BB00DE790A /* LessonLineProgressView.swift */; }; - BAD9CA442B2C87A200DE790A /* CourseStructureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA432B2C87A200DE790A /* CourseStructureView.swift */; }; BAD9CA4A2B2C88E000DE790A /* CourseVideoDownloadBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA492B2C88E000DE790A /* CourseVideoDownloadBarView.swift */; }; DB205BFB2AE81B1200136EC2 /* CourseDateViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */; }; DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */; }; @@ -149,7 +150,11 @@ 02B6B3BD28E1D15C00232911 /* CourseEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseEndpoint.swift; sourceTree = "<group>"; }; 02B6B3C228E1DCD100232911 /* CourseDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDetails.swift; sourceTree = "<group>"; }; 02B6B3C828E1E68100232911 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 02BB20172BFCE7B200364948 /* CustomDisclosureGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisclosureGroup.swift; sourceTree = "<group>"; }; + 02C355382C08DCD700501342 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; + 02C3553A2C08DCE000501342 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSectionView.swift; sourceTree = "<group>"; }; + 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseProgressView.swift; sourceTree = "<group>"; }; 02ED50CF29A64BB6008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; }; 02F0144E28F46474002E513D /* CourseContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerView.swift; sourceTree = "<group>"; }; 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerViewModel.swift; sourceTree = "<group>"; }; @@ -203,12 +208,10 @@ BA58CF5C2B3D804D005B102E /* CourseStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStorage.swift; sourceTree = "<group>"; }; BA58CF602B471041005B102E /* VideoDownloadQualityBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityBarView.swift; sourceTree = "<group>"; }; BA58CF632B471363005B102E /* VideoDownloadQualityContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityContainerView.swift; sourceTree = "<group>"; }; - BAAD62C72AFD00EE000E6103 /* CourseStructureNestedListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStructureNestedListView.swift; sourceTree = "<group>"; }; BAC0E0D72B32EF03006B68A9 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = "<group>"; }; BAC0E0DA2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseVideoDownloadBarViewModel.swift; sourceTree = "<group>"; }; BAC0E0DD2B32F0F3006B68A9 /* DownloadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsViewModel.swift; sourceTree = "<group>"; }; BAD9CA2C2B2736BB00DE790A /* LessonLineProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LessonLineProgressView.swift; sourceTree = "<group>"; }; - BAD9CA432B2C87A200DE790A /* CourseStructureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStructureView.swift; sourceTree = "<group>"; }; BAD9CA492B2C88E000DE790A /* CourseVideoDownloadBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseVideoDownloadBarView.swift; sourceTree = "<group>"; }; DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDateViewModelTests.swift; sourceTree = "<group>"; }; DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseDatesView.swift; sourceTree = "<group>"; }; @@ -313,6 +316,7 @@ 97EA4D822B84EFA900663F58 /* Managers */, 97CA95212B875EA200A9EDEA /* Views */, 02B6B3B428E1C49400232911 /* Localizable.strings */, + 02C355372C08DCD700501342 /* Localizable.stringsdict */, ); path = Course; sourceTree = "<group>"; @@ -440,7 +444,6 @@ isa = PBXGroup; children = ( BAD9CA462B2C888600DE790A /* CourseVertical */, - BAD9CA472B2C88AA00DE790A /* CourseStructure */, 02635AC62A24F181008062F2 /* ContinueWithView.swift */, 0270210128E736E700F54332 /* CourseOutlineView.swift */, ); @@ -580,15 +583,6 @@ path = CourseVertical; sourceTree = "<group>"; }; - BAD9CA472B2C88AA00DE790A /* CourseStructure */ = { - isa = PBXGroup; - children = ( - BAD9CA432B2C87A200DE790A /* CourseStructureView.swift */, - BAAD62C72AFD00EE000E6103 /* CourseStructureNestedListView.swift */, - ); - path = CourseStructure; - sourceTree = "<group>"; - }; BAD9CA482B2C88D500DE790A /* Subviews */ = { isa = PBXGroup; children = ( @@ -596,6 +590,8 @@ BAC0E0D92B32F0A2006B68A9 /* CourseVideoDownloadBarView */, BA58CF622B471047005B102E /* VideoDownloadQualityBarView */, 02FCB2B22BBEB36600373180 /* CourseHeaderView.swift */, + 02BB20172BFCE7B200364948 /* CustomDisclosureGroup.swift */, + 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */, ); path = Subviews; sourceTree = "<group>"; @@ -746,6 +742,7 @@ files = ( 0289F90228E1C3E10064F8F3 /* swiftgen.yml in Resources */, 02B6B3B228E1C49400232911 /* Localizable.strings in Resources */, + 02C355392C08DCD700501342 /* Localizable.stringsdict in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -885,13 +882,11 @@ 068DDA5F2B1E198700FF8CCB /* CourseUnitDropDownList.swift in Sources */, 022F8E182A1E2642008EFAB9 /* EncodedVideoPlayerViewModel.swift in Sources */, 0248C92729C097EB00DC8402 /* CourseVerticalViewModel.swift in Sources */, - BAD9CA442B2C87A200DE790A /* CourseStructureView.swift in Sources */, 02F0145728F4A2FF002E513D /* CourseContainerViewModel.swift in Sources */, 02B6B3B728E1D11E00232911 /* CourseInteractor.swift in Sources */, 073512E229C0E400005CFA41 /* BaseCourseViewModel.swift in Sources */, 0231124F28EDA811002588FB /* CourseUnitViewModel.swift in Sources */, 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */, - BAAD62C82AFD00EE000E6103 /* CourseStructureNestedListView.swift in Sources */, 02A8076829474831007F53AB /* CourseVerticalView.swift in Sources */, 97E7DF0F2B7C852A00A2A09B /* DatesStatusInfoView.swift in Sources */, 067B7B4F2BED339200D1768F /* PlayerDelegateProtocol.swift in Sources */, @@ -907,6 +902,7 @@ DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */, 067B7B522BED339200D1768F /* SubtitlesView.swift in Sources */, 07DE59862BECB868001CBFBC /* CourseAnalytics.swift in Sources */, + 02BB20182BFCE7B200364948 /* CustomDisclosureGroup.swift in Sources */, 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */, BA58CF642B471363005B102E /* VideoDownloadQualityContainerView.swift in Sources */, BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */, @@ -924,6 +920,7 @@ 02FCB2B32BBEB36600373180 /* CourseHeaderView.swift in Sources */, DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */, 0766DFCC299AA7A600EBEF6A /* YouTubeVideoPlayer.swift in Sources */, + 02E3803E2BFF9F0A00815AFA /* CourseProgressView.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02B6B3BE28E1D15C00232911 /* CourseEndpoint.swift in Sources */, 97EA4D862B85034D00663F58 /* CalendarManager.swift in Sources */, @@ -951,6 +948,15 @@ name = Localizable.strings; sourceTree = "<group>"; }; + 02C355372C08DCD700501342 /* Localizable.stringsdict */ = { + isa = PBXVariantGroup; + children = ( + 02C355382C08DCD700501342 /* en */, + 02C3553A2C08DCE000501342 /* uk */, + ); + name = Localizable.stringsdict; + sourceTree = "<group>"; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 19073d2de..612273fe5 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -139,7 +139,11 @@ public class CourseRepository: CourseRepositoryProtocol { media: course.media, certificate: course.certificate?.domain, org: course.org ?? "", - isSelfPaced: course.isSelfPaced + isSelfPaced: course.isSelfPaced, + courseProgress: course.courseProgress == nil ? nil : CourseProgress( + totalAssignmentsCount: course.courseProgress?.totalAssignmentsCount ?? 0, + assignmentsCompleted: course.courseProgress?.assignmentsCompleted ?? 0 + ) ) } @@ -173,7 +177,13 @@ public class CourseRepository: CourseRepositoryProtocol { displayName: sequential.displayName, type: BlockType(rawValue: sequential.type) ?? .unknown, completion: sequential.completion ?? 0, - childs: childs + childs: childs, + sequentialProgress: SequentialProgress( + assignmentType: sequential.assignmentProgress?.assignmentType, + numPointsEarned: Int(sequential.assignmentProgress?.numPointsEarned ?? 0), + numPointsPossible: Int(sequential.assignmentProgress?.numPointsPossible ?? 0) + ), + due: sequential.due == nil ? nil : Date(iso8601: sequential.due!) ) } @@ -211,6 +221,7 @@ public class CourseRepository: CourseRepositoryProtocol { courseId: courseId, topicId: block.userViewData?.topicID, graded: block.graded, + due: block.due == nil ? nil : Date(iso8601: block.due!), completion: block.completion ?? 0, type: BlockType(rawValue: block.type) ?? .unknown, displayName: block.displayName, @@ -350,7 +361,11 @@ And there are various ways of describing it-- call it oral poetry or media: course.media, certificate: course.certificate?.domain, org: course.org ?? "", - isSelfPaced: course.isSelfPaced + isSelfPaced: course.isSelfPaced, + courseProgress: course.courseProgress == nil ? nil : CourseProgress( + totalAssignmentsCount: course.courseProgress?.totalAssignmentsCount ?? 0, + assignmentsCompleted: course.courseProgress?.assignmentsCompleted ?? 0 + ) ) } @@ -385,7 +400,13 @@ And there are various ways of describing it-- call it oral poetry or displayName: sequential.displayName, type: BlockType(rawValue: sequential.type) ?? .unknown, completion: sequential.completion ?? 0, - childs: childs + childs: childs, + sequentialProgress: SequentialProgress( + assignmentType: sequential.assignmentProgress?.assignmentType, + numPointsEarned: Int(sequential.assignmentProgress?.numPointsEarned ?? 0), + numPointsPossible: Int(sequential.assignmentProgress?.numPointsPossible ?? 0) + ), + due: sequential.due == nil ? nil : Date(iso8601: sequential.due!) ) } @@ -421,6 +442,7 @@ And there are various ways of describing it-- call it oral poetry or courseId: courseId, topicId: block.userViewData?.topicID, graded: block.graded, + due: block.due == nil ? nil : Date(iso8601: block.due!), completion: block.completion ?? 0, type: BlockType(rawValue: block.type) ?? .unknown, displayName: block.displayName, diff --git a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift index f2a060e13..5cee8c3e0 100644 --- a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift +++ b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift @@ -21,6 +21,7 @@ public extension DataLayer { public let certificate: Certificate? public let org: String? public let isSelfPaced: Bool + public let courseProgress: CourseProgress? enum CodingKeys: String, CodingKey { case blocks @@ -30,6 +31,7 @@ public extension DataLayer { case certificate case org case isSelfPaced = "is_self_paced" + case courseProgress = "course_progress" } public init( @@ -39,7 +41,8 @@ public extension DataLayer { media: DataLayer.CourseMedia, certificate: Certificate?, org: String?, - isSelfPaced: Bool + isSelfPaced: Bool, + courseProgress: CourseProgress? ) { self.rootItem = rootItem self.dict = dict @@ -48,6 +51,7 @@ public extension DataLayer { self.certificate = certificate self.org = org self.isSelfPaced = isSelfPaced + self.courseProgress = courseProgress } public init(from decoder: Decoder) throws { @@ -60,6 +64,7 @@ public extension DataLayer { certificate = try values.decode(Certificate.self, forKey: .certificate) org = try values.decode(String.self, forKey: .org) isSelfPaced = try values.decode(Bool.self, forKey: .isSelfPaced) + courseProgress = try? values.decode(DataLayer.CourseProgress.self, forKey: .courseProgress) } } } @@ -68,6 +73,7 @@ public extension DataLayer { public let blockId: String public let id: String public let graded: Bool + public let due: String? public let completion: Double? public let studentUrl: String public let webUrl: String @@ -77,11 +83,13 @@ public extension DataLayer { public let allSources: [String]? public let userViewData: CourseDetailUserViewData? public let multiDevice: Bool? + public let assignmentProgress: AssignmentProgress? public init( blockId: String, id: String, graded: Bool, + due: String?, completion: Double?, studentUrl: String, webUrl: String, @@ -90,11 +98,13 @@ public extension DataLayer { descendants: [String]?, allSources: [String]?, userViewData: CourseDetailUserViewData?, - multiDevice: Bool? + multiDevice: Bool?, + assignmentProgress: AssignmentProgress? ) { self.blockId = blockId self.id = id self.graded = graded + self.due = due self.completion = completion self.studentUrl = studentUrl self.webUrl = webUrl @@ -104,10 +114,11 @@ public extension DataLayer { self.allSources = allSources self.userViewData = userViewData self.multiDevice = multiDevice + self.assignmentProgress = assignmentProgress } public enum CodingKeys: String, CodingKey { - case id, type, descendants, graded, completion + case id, type, descendants, graded, completion, due case blockId = "block_id" case studentUrl = "student_view_url" case webUrl = "lms_web_url" @@ -115,9 +126,28 @@ public extension DataLayer { case userViewData = "student_view_data" case allSources = "all_sources" case multiDevice = "student_view_multi_device" + case assignmentProgress = "assignment_progress" } } + + struct AssignmentProgress: Codable { + public let assignmentType: String? + public let numPointsEarned: Double? + public let numPointsPossible: Double? + + public enum CodingKeys: String, CodingKey { + case assignmentType = "assignment_type" + case numPointsEarned = "num_points_earned" + case numPointsPossible = "num_points_possible" + } + public init(assignmentType: String?, numPointsEarned: Double?, numPointsPossible: Double?) { + self.assignmentType = assignmentType + self.numPointsEarned = numPointsEarned + self.numPointsPossible = numPointsPossible + } + } + struct Transcripts: Codable { public let en: String? @@ -202,6 +232,5 @@ public extension DataLayer { case fileSize = "file_size" case streamPriority = "stream_priority" } - } } diff --git a/Course/Course/Data/Network/CourseEndpoint.swift b/Course/Course/Data/Network/CourseEndpoint.swift index 2abafa14a..6ce7a048a 100644 --- a/Course/Course/Data/Network/CourseEndpoint.swift +++ b/Course/Course/Data/Network/CourseEndpoint.swift @@ -86,7 +86,7 @@ enum CourseEndpoint: EndPointType { "nav_depth": "4", "requested_fields": """ contains_gated_content,show_gated_sections,special_exam_info,graded, - format,student_view_multi_device,due,completion + format,student_view_multi_device,due,completion,assignment_progress """, "block_counts": "video" ] diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index d25cbc5ad..d8e99bd3e 100644 --- a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents +++ b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents @@ -2,14 +2,18 @@ <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22757" systemVersion="23E224" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <entity name="CDCourseBlock" representedClassName="CDCourseBlock" syncable="YES" codeGenerationType="class"> <attribute name="allSources" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/> + <attribute name="assignmentType" optional="YES" attributeType="String"/> <attribute name="blockId" optional="YES" attributeType="String"/> <attribute name="completion" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> <attribute name="courseID" optional="YES" attributeType="String"/> <attribute name="descendants" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" customClassName="[String]"/> <attribute name="displayName" optional="YES" attributeType="String"/> + <attribute name="due" optional="YES" attributeType="String"/> <attribute name="graded" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="id" optional="YES" attributeType="String"/> <attribute name="multiDevice" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> + <attribute name="numPointsEarned" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> + <attribute name="numPointsPossible" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> <attribute name="studentUrl" optional="YES" attributeType="String"/> <attribute name="transcripts" optional="YES" attributeType="String"/> <attribute name="type" optional="YES" attributeType="String"/> @@ -77,6 +81,7 @@ </uniquenessConstraints> </entity> <entity name="CDCourseStructure" representedClassName="CDCourseStructure" syncable="YES" codeGenerationType="class"> + <attribute name="assignmentsCompleted" optional="YES" attributeType="Integer 32" defaultValueString="0.0" usesScalarValueType="YES"/> <attribute name="certificate" optional="YES" attributeType="String"/> <attribute name="id" optional="YES" attributeType="String"/> <attribute name="isSelfPaced" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> @@ -85,6 +90,7 @@ <attribute name="mediaSmall" optional="YES" attributeType="String"/> <attribute name="org" optional="YES" attributeType="String"/> <attribute name="rootItem" optional="YES" attributeType="String"/> + <attribute name="totalAssignmentsCount" optional="YES" attributeType="Integer 32" defaultValueString="0.0" usesScalarValueType="YES"/> <uniquenessConstraints> <uniquenessConstraint> <constraint value="id"/> @@ -101,4 +107,4 @@ </uniquenessConstraint> </uniquenessConstraints> </entity> -</model> \ No newline at end of file +</model> diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index dfec16d70..f76d5ed2e 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -55,7 +55,11 @@ public class CourseInteractor: CourseInteractorProtocol { media: course.media, certificate: course.certificate, org: course.org, - isSelfPaced: course.isSelfPaced + isSelfPaced: course.isSelfPaced, + courseProgress: course.courseProgress == nil ? nil : CourseProgress( + totalAssignmentsCount: course.courseProgress?.totalAssignmentsCount ?? 0, + assignmentsCompleted: course.courseProgress?.assignmentsCompleted ?? 0 + ) ) } @@ -128,7 +132,9 @@ public class CourseInteractor: CourseInteractorProtocol { displayName: sequential.displayName, type: sequential.type, completion: sequential.completion, - childs: newChilds + childs: newChilds, + sequentialProgress: sequential.sequentialProgress, + due: sequential.due ) } diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index f2678a915..51e687f3e 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -16,8 +16,8 @@ public enum CourseTab: Int, CaseIterable, Identifiable { } case course case videos - case discussion case dates + case discussion case handounds } @@ -68,6 +68,9 @@ public class CourseContainerViewModel: BaseCourseViewModel { @Published var userSettings: UserSettings? @Published var isInternetAvaliable: Bool = true @Published var dueDatesShifted: Bool = false + @Published var updateCourseProgress: Bool = false + + let completionPublisher = NotificationCenter.default.publisher(for: .onblockCompletionRequested) var errorMessage: String? { didSet { @@ -137,6 +140,13 @@ public class CourseContainerViewModel: BaseCourseViewModel { addObservers() } + func updateCourseIfNeeded(courseID: String) async { + if updateCourseProgress { + await getCourseBlocks(courseID: courseID, withProgress: false) + updateCourseProgress = false + } + } + func openLastVisitedBlock() { guard let continueWith = continueWith, let courseStructure = courseStructure else { return } @@ -607,6 +617,13 @@ public class CourseContainerViewModel: BaseCourseViewModel { selector: #selector(handleShiftDueDates), name: .shiftCourseDates, object: nil ) + + completionPublisher + .sink { [weak self] _ in + guard let self = self else { return } + updateCourseProgress = true + } + .store(in: &cancellables) } deinit { diff --git a/Course/Course/Presentation/Outline/ContinueWithView.swift b/Course/Course/Presentation/Outline/ContinueWithView.swift index 1c05c575b..45271c4ae 100644 --- a/Course/Course/Presentation/Outline/ContinueWithView.swift +++ b/Course/Course/Presentation/Outline/ContinueWithView.swift @@ -83,6 +83,7 @@ struct ContinueWithView_Previews: PreviewProvider { id: "1", courseId: "123", graded: true, + due: Date(), completion: 0, type: .html, displayName: "Continue lesson", @@ -96,6 +97,7 @@ struct ContinueWithView_Previews: PreviewProvider { id: "2", courseId: "123", graded: true, + due: Date(), completion: 0, type: .html, displayName: "Continue lesson", diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 936ba835e..cb7e1bec2 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -13,7 +13,7 @@ import SwiftUIIntrospect public struct CourseOutlineView: View { - @ObservedObject private var viewModel: CourseContainerViewModel + @StateObject private var viewModel: CourseContainerViewModel private let title: String private let courseID: String private let isVideo: Bool @@ -30,6 +30,8 @@ public struct CourseOutlineView: View { @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @State private var expandedChapters: [String: Bool] = [:] + public init( viewModel: CourseContainerViewModel, title: String, @@ -41,7 +43,7 @@ public struct CourseOutlineView: View { dateTabIndex: Int ) { self.title = title - self.viewModel = viewModel//StateObject(wrappedValue: { viewModel }()) + self._viewModel = StateObject(wrappedValue: { viewModel }()) self.courseID = courseID self.isVideo = isVideo self._selection = selection @@ -52,10 +54,8 @@ public struct CourseOutlineView: View { public var body: some View { ZStack(alignment: .top) { - // MARK: - Page name GeometryReader { proxy in VStack(alignment: .center) { - // MARK: - Page Body RefreshableScrollViewCompat(action: { await withTaskGroup(of: Void.self) { group in group.addTask { @@ -95,7 +95,6 @@ public struct CourseOutlineView: View { let sequential = chapter.childs[continueWith.sequentialIndex] let continueUnit = sequential.childs[continueWith.verticalIndex] - // MARK: - ContinueWith button ContinueWithView( data: continueWith, courseContinueUnit: continueUnit @@ -108,21 +107,19 @@ public struct CourseOutlineView: View { ? viewModel.courseVideosStructure : viewModel.courseStructure { - // MARK: - Sections - if viewModel.config.uiComponents.courseNestedListEnabled { - CourseStructureNestedListView( - proxy: proxy, - course: course, - viewModel: viewModel - ) - } else { - CourseStructureView( - proxy: proxy, - course: course, - viewModel: viewModel - ) + if !isVideo, let progress = course.courseProgress, progress.totalAssignmentsCount != 0 { + CourseProgressView(progress: progress) + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 8) } + // MARK: - Sections + CustomDisclosureGroup( + course: course, + proxy: proxy, + viewModel: viewModel + ) } else { if let courseStart = viewModel.courseStart { Text(courseStart > Date() ? CourseLocalization.Outline.courseHasntStarted : "") @@ -190,6 +187,11 @@ public struct CourseOutlineView: View { } } } + .onAppear { + Task { + await viewModel.updateCourseIfNeeded(courseID: courseID) + } + } .background( Theme.Colors.background .ignoresSafeArea() diff --git a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift deleted file mode 100644 index 6e8ad3927..000000000 --- a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift +++ /dev/null @@ -1,237 +0,0 @@ -// -// CourseStructureNestedListView.swift -// Course -// -// Created by Eugene Yatsenko on 09.11.2023. -// - -import SwiftUI -import Core -import Kingfisher -import Theme - -struct CourseStructureNestedListView: View { - - private let proxy: GeometryProxy - private let course: CourseStructure - private let viewModel: CourseContainerViewModel - private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - - @State private var isExpandedIds: [String] = [] - - init(proxy: GeometryProxy, course: CourseStructure, viewModel: CourseContainerViewModel) { - self.proxy = proxy - self.course = course - self.viewModel = viewModel - } - - var body: some View { - ForEach(course.childs, content: disclosureGroup) - } - - private func disclosureGroup(chapter: CourseChapter) -> some View { - CustomDisclosureGroup( - animation: .easeInOut(duration: 0.2), - isExpanded: .constant(isExpandedIds.contains(where: { $0 == chapter.id })), - onClick: { onHeaderClick(chapter: chapter) }, - header: { isExpanded in header(chapter: chapter, isExpanded: isExpanded) }, - content: { section(chapter: chapter) } - ) - } - - private func header( - chapter: CourseChapter, - isExpanded: Bool - ) -> some View { - HStack { - Text(chapter.displayName) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - .lineLimit(1) - .foregroundColor(Theme.Colors.textPrimary) - Spacer() - Image(systemName: "chevron.down").renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - .dropdownArrowRotationAnimation(value: isExpanded) - } - .padding(.horizontal, 30) - .padding(.vertical, 15) - } - - private func section(chapter: CourseChapter) -> some View { - ForEach(chapter.childs) { sequential in - VStack(spacing: 0) { - sequentialLabel( - sequential: sequential, - chapter: chapter, - isExpanded: false - ) - } - } - } - - @ViewBuilder - private func sequentialLabel( - sequential: CourseSequential, - chapter: CourseChapter, - isExpanded: Bool - ) -> some View { - HStack { - Button { - onLabelClick(sequential: sequential, chapter: chapter) - } label: { - HStack(spacing: 0) { - Group { - if sequential.completion == 1 { - CoreAssets.finished.swiftUIImage - .renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - } else { - sequential.type.image - } - Text(sequential.displayName) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - .lineLimit(1) - } - .foregroundColor(Theme.Colors.textPrimary) - Spacer() - } - } - downloadButton( - sequential: sequential, - chapter: chapter - ) - - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(sequential.displayName) - .padding(.leading, 40) - .padding(.trailing, 28) - .padding(.vertical, 14) - } - - @ViewBuilder - private func downloadButton( - sequential: CourseSequential, - chapter: CourseChapter - ) -> some View { - if let state = viewModel.sequentialsDownloadState[sequential.id] { - switch state { - case .available: - if viewModel.isInternetAvaliable { - Button { - Task { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: sequential.id, - state: state - ) - } - } label: { - DownloadAvailableView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.download) - } - } - case .downloading: - if viewModel.isInternetAvaliable { - Button { - viewModel.router.showDownloads( - downloads: viewModel.getTasks(sequential: sequential), - manager: viewModel.manager - ) - } label: { - ProgressBar(size: 30, lineWidth: 1.75) - } - } - case .finished: - Button { - viewModel.router.presentAlert( - alertTitle: "Warning", - alertMessage: "\(CourseLocalization.Alert.deleteVideos) \"\(sequential.displayName)\"?", - positiveAction: CoreLocalization.Alert.delete, - onCloseTapped: { - viewModel.router.dismiss(animated: true) - }, - okTapped: { - Task { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: sequential.id, - state: state - ) - } - viewModel.router.dismiss(animated: true) - }, - type: .deleteVideo - ) - } label: { - DownloadFinishedView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.deleteDownload) - } - } - downloadCount(sequential: sequential) - } - } - - @ViewBuilder - private func downloadCount(sequential: CourseSequential) -> some View { - let downloadable = viewModel.verticalsBlocksDownloadable(by: sequential) - if !downloadable.isEmpty { - Text(String(downloadable.count)) - .foregroundColor(Color(UIColor.label)) - } - } - - private func onHeaderClick(chapter: CourseChapter) { - if let index = isExpandedIds.firstIndex(where: {$0 == chapter.id}) { - isExpandedIds.remove(at: index) - } else { - isExpandedIds.append(chapter.id) - } - } - - private func onLabelClick( - sequential: CourseSequential, - chapter: CourseChapter - ) { - guard let chapterIndex = course.childs.firstIndex( - where: { $0.id == chapter.id } - ) else { - return - } - - guard let sequentialIndex = chapter.childs.firstIndex( - where: { $0.id == sequential.id } - ) else { - return - } - - guard let courseVertical = sequential.childs.first else { - return - } - - guard let block = courseVertical.childs.first else { - return - } - - viewModel.trackVerticalClicked( - courseId: viewModel.courseStructure?.id ?? "", - courseName: viewModel.courseStructure?.displayName ?? "", - vertical: courseVertical - ) - viewModel.router.showCourseUnit( - courseName: viewModel.courseStructure?.displayName ?? "", - blockId: block.id, - courseID: viewModel.courseStructure?.id ?? "", - verticalIndex: 0, - chapters: course.childs, - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex - ) - - } - -} diff --git a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift deleted file mode 100644 index e2dd8646d..000000000 --- a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// CourseStructureView.swift -// Course -// -// Created by Eugene Yatsenko on 15.12.2023. -// - -import SwiftUI -import Core -import Theme - -struct CourseStructureView: View { - - private let proxy: GeometryProxy - private let course: CourseStructure - private let viewModel: CourseContainerViewModel - private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - - init(proxy: GeometryProxy, course: CourseStructure, viewModel: CourseContainerViewModel) { - self.proxy = proxy - self.course = course - self.viewModel = viewModel - } - - var body: some View { - let chapters = course.childs - ForEach(chapters, id: \.id) { chapter in - let chapterIndex = chapters.firstIndex(where: { $0.id == chapter.id }) - Text(chapter.displayName) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - .foregroundColor(Theme.Colors.textSecondary) - .padding(.horizontal, 24) - .padding(.top, 40) - ForEach(chapter.childs, id: \.id) { child in - let sequentialIndex = chapter.childs.firstIndex(where: { $0.id == child.id }) - VStack(alignment: .leading) { - HStack { - Button { - if let chapterIndex, let sequentialIndex { - viewModel.trackSequentialClicked(child) - viewModel.router.showCourseVerticalView( - courseID: viewModel.courseStructure?.id ?? "", - courseName: viewModel.courseStructure?.displayName ?? "", - title: child.displayName, - chapters: chapters, - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex - ) - } - } label: { - Group { - if child.completion == 1 { - CoreAssets.finished.swiftUIImage - .renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - } else { - child.type.image - } - Text(child.displayName) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - .lineLimit(1) - .frame( - maxWidth: idiom == .pad - ? proxy.size.width * 0.5 - : proxy.size.width * 0.6, - alignment: .leading - ) - } - .foregroundColor(Theme.Colors.textPrimary) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(child.displayName) - Spacer() - if let state = viewModel.sequentialsDownloadState[child.id] { - switch state { - case .available: - DownloadAvailableView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.download) - .onTapGesture { - Task { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - } - case .downloading: - DownloadProgressView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) - .onTapGesture { - Task { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - } - case .finished: - DownloadFinishedView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.deleteDownload) - .onTapGesture { - Task { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - } - } - } - Image(systemName: "chevron.right") - .foregroundColor(Theme.Colors.accentColor) - } - .padding(.horizontal, 36) - .padding(.vertical, 20) - if chapterIndex != chapters.count - 1 { - Divider() - .frame(height: 1) - .overlay(Theme.Colors.cardViewStroke) - .padding(.horizontal, 24) - } - } - } - } - } -} diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift index 5d33fbd69..bc3722da1 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift @@ -34,7 +34,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { id: "1", courseId: "123", topicId: "1", - graded: false, + graded: false, + due: Date(), completion: 1, type: .video, displayName: "Block 1", @@ -52,6 +53,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 1, type: .problem, displayName: "Block 1", @@ -68,6 +70,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 1, type: .discussion, displayName: "Block 1", @@ -84,6 +87,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 1, type: .html, displayName: "Block 1", @@ -100,6 +104,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 1, type: .unknown, displayName: "Block 1", diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index d16095762..ccfc6f9b7 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -203,7 +203,14 @@ struct CourseVerticalView_Previews: PreviewProvider { type: .vertical, completion: 0, childs: []) - ]) + ], + sequentialProgress: SequentialProgress( + assignmentType: "Advanced Assessment Tools", + numPointsEarned: 1, + numPointsPossible: 3 + ), + due: Date() + ) ]) ] diff --git a/Course/Course/Presentation/Subviews/CourseProgressView.swift b/Course/Course/Presentation/Subviews/CourseProgressView.swift new file mode 100644 index 000000000..70ee1c2d8 --- /dev/null +++ b/Course/Course/Presentation/Subviews/CourseProgressView.swift @@ -0,0 +1,54 @@ +// +// CourseProgressView.swift +// Course +// +// Created by Stepanok Ivan on 23.05.2024. +// + +import SwiftUI +import Theme +import Core + +public struct CourseProgressView: View { + private var progress: CourseProgress + + public init(progress: CourseProgress) { + self.progress = progress + } + + public var body: some View { + VStack(alignment: .leading) { + ZStack(alignment: .leading) { + GeometryReader { geometry in + RoundedRectangle(cornerRadius: 10) + .fill(Theme.Colors.textSecondary.opacity(0.5)) + .frame(width: geometry.size.width, height: 10) + + if let total = progress.totalAssignmentsCount, + let completed = progress.assignmentsCompleted { + RoundedCorners(tl: 5, tr: 0, bl: 5, br: 0) + .fill(Theme.Colors.accentColor) + .frame(width: geometry.size.width * CGFloat(completed) / CGFloat(total), height: 10) + } + } + .frame(height: 10) + } + .cornerRadius(10) + + if let total = progress.totalAssignmentsCount, + let completed = progress.assignmentsCompleted { + Text(CourseLocalization.Course.progressCompleted(completed, total)) + .foregroundColor(Theme.Colors.textPrimary) + .font(Theme.Fonts.labelSmall) + .padding(.top, 4) + } + } + } +} + +struct CourseProgressView_Previews: PreviewProvider { + static var previews: some View { + CourseProgressView(progress: CourseProgress(totalAssignmentsCount: 20, assignmentsCompleted: 12)) + .padding() + } +} diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift new file mode 100644 index 000000000..75186dd91 --- /dev/null +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -0,0 +1,400 @@ +// +// CustomDisclosureGroup.swift +// Course +// +// Created by Stepanok Ivan on 21.05.2024. +// + +import SwiftUI +import Core +import Theme + +struct CustomDisclosureGroup: View { + @State private var expandedSections: [String: Bool] = [:] + + private let proxy: GeometryProxy + private let course: CourseStructure + private let viewModel: CourseContainerViewModel + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + init(course: CourseStructure, proxy: GeometryProxy, viewModel: CourseContainerViewModel) { + self.course = course + self.proxy = proxy + self.viewModel = viewModel + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(course.childs) { chapter in + let chapterIndex = course.childs.firstIndex(where: { $0.id == chapter.id }) + VStack(alignment: .leading) { + Button( + action: { + withAnimation(.linear(duration: course.childs.count > 1 ? 0.2 : 0.05)) { + expandedSections[chapter.id, default: false].toggle() + } + }, label: { + HStack { + CoreAssets.chevronRight.swiftUIImage + .rotationEffect(.degrees(expandedSections[chapter.id] ?? false ? -90 : 90)) + .foregroundColor(Theme.Colors.textPrimary) + if chapter.childs.allSatisfy({ $0.completion == 1 }) { + CoreAssets.finishedSequence.swiftUIImage + } + Text(chapter.displayName) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + .lineLimit(1) + Spacer() + if canDownloadAllSections(in: chapter), + let state = downloadAllButtonState(for: chapter) { + Button( + action: { + downloadAllSubsections(in: chapter, state: state) + }, label: { + switch state { + case .available: + DownloadAvailableView() + case .downloading: + DownloadProgressView() + case .finished: + DownloadFinishedView() + } + + } + ) + } + } + } + ) + if expandedSections[chapter.id] ?? false { + VStack(alignment: .leading) { + ForEach(chapter.childs) { sequential in + let sequentialIndex = chapter.childs.firstIndex(where: { $0.id == sequential.id }) + VStack(alignment: .leading) { + HStack { + Button( + action: { + guard let chapterIndex = chapterIndex else { return } + guard let sequentialIndex else { return } + guard let courseVertical = sequential.childs.first else { return } + guard let block = courseVertical.childs.first else { return } + + viewModel.trackSequentialClicked(sequential) + if viewModel.config.uiComponents.courseDropDownNavigationEnabled { + viewModel.router.showCourseUnit( + courseName: viewModel.courseStructure?.displayName ?? "", + blockId: block.id, + courseID: viewModel.courseStructure?.id ?? "", + verticalIndex: 0, + chapters: course.childs, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + } else { + viewModel.router.showCourseVerticalView( + courseID: viewModel.courseStructure?.id ?? "", + courseName: viewModel.courseStructure?.displayName ?? "", + title: sequential.displayName, + chapters: course.childs, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + } + }, + label: { + VStack(alignment: .leading) { + HStack { + if sequential.completion == 1 { + CoreAssets.finishedSequence.swiftUIImage + .resizable() + .frame(width: 20, height: 20) + } else { + sequential.type.image + } + Text(sequential.displayName) + .font(Theme.Fonts.titleSmall) + .multilineTextAlignment(.leading) + .lineLimit(1) + .frame( + maxWidth: idiom == .pad + ? proxy.size.width * 0.5 + : proxy.size.width * 0.6, + alignment: .leading + ) + } + if let sequentialProgress = sequential.sequentialProgress, + let assignmentType = sequentialProgress.assignmentType, + let numPointsEarned = sequentialProgress.numPointsEarned, + let numPointsPossible = sequentialProgress.numPointsPossible, + let due = sequential.due { + let daysRemaining = getAssignmentStatus(for: due) + Text("\(assignmentType) - \(daysRemaining) - \(numPointsEarned) / \(numPointsPossible)") + .font(Theme.Fonts.bodySmall) + .multilineTextAlignment(.leading) + .lineLimit(2) + } + } + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityElement(children: .ignore) + .accessibilityLabel(sequential.displayName) + } + ) + Spacer() + if sequential.due != nil { + CoreAssets.chevronRight.swiftUIImage + .foregroundColor(Theme.Colors.textPrimary) + } + } + .padding(.vertical, 4) + } + } + } + + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Theme.Colors.tabbarColor) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) + .foregroundColor(Theme.Colors.cardViewStroke) + ) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 8) + .onFirstAppear { + for chapter in course.childs { + expandedSections[chapter.id] = false + } + } + } + + func getAssignmentStatus(for date: Date) -> String { + let calendar = Calendar.current + let today = Date() + + if calendar.isDateInToday(date) { + return CourseLocalization.Course.dueToday + } else if calendar.isDateInTomorrow(date) { + return CourseLocalization.Course.dueTomorrow + } else if let daysUntil = calendar.dateComponents([.day], from: today, to: date).day, daysUntil > 0 { + return CourseLocalization.dueIn(daysUntil) + } else if let daysAgo = calendar.dateComponents([.day], from: date, to: today).day, daysAgo > 0 { + return CourseLocalization.pastDue(daysAgo) + } else { + return "" + } + } + + private func canDownloadAllSections(in chapter: CourseChapter) -> Bool { + for sequential in chapter.childs { + if let state = viewModel.sequentialsDownloadState[sequential.id] { + return true + } + } + return false + } + + private func downloadAllSubsections(in chapter: CourseChapter, state: DownloadViewState) { + Task { + for sequential in chapter.childs { + await viewModel.onDownloadViewTap( + chapter: chapter, + blockId: sequential.id, + state: state + ) + } + } + } + + private func downloadAllButtonState(for chapter: CourseChapter) -> DownloadViewState? { + if canDownloadAllSections(in: chapter) { + let downloads = chapter.childs.filter({ viewModel.sequentialsDownloadState[$0.id] != nil }) + + if downloads.contains(where: { viewModel.sequentialsDownloadState[$0.id] == .downloading }) { + return .downloading + } else if downloads.allSatisfy({ viewModel.sequentialsDownloadState[$0.id] == .finished }) { + return .finished + } else { + return .available + } + } + return nil + } + +} + +#if DEBUG +struct CustomDisclosureGroup_Previews: PreviewProvider { + + static var previews: some View { + + // Sample data models + let sampleCourseChapters: [CourseChapter] = [ + CourseChapter( + blockId: "1", + id: "1", + displayName: "Chapter 1", + type: .chapter, + childs: [ + CourseSequential( + blockId: "1-1", + id: "1-1", + displayName: "Sequential 1", + type: .sequential, + completion: 0, + childs: [ + CourseVertical( + blockId: "1-1-1", + id: "1-1-1", + courseId: "1", + displayName: "Vertical 1", + type: .vertical, + completion: 0, + childs: [] + ), + CourseVertical( + blockId: "1-1-2", + id: "1-1-2", + courseId: "1", + displayName: "Vertical 2", + type: .vertical, + completion: 1.0, + childs: [] + ) + ], + sequentialProgress: SequentialProgress( + assignmentType: "Advanced Assessment Tools", + numPointsEarned: 1, + numPointsPossible: 3 + ), + due: Date() + ), + CourseSequential( + blockId: "1-2", + id: "1-2", + displayName: "Sequential 2", + type: .sequential, + completion: 1.0, + childs: [ + CourseVertical( + blockId: "1-2-1", + id: "1-2-1", + courseId: "1", + displayName: "Vertical 3", + type: .vertical, + completion: 1.0, + childs: [] + ) + ], + sequentialProgress: SequentialProgress( + assignmentType: "Basic Assessment Tools", + numPointsEarned: 1, + numPointsPossible: 3 + ), + due: Date() + ) + ] + ), + CourseChapter( + blockId: "2", + id: "2", + displayName: "Chapter 2", + type: .chapter, + childs: [ + CourseSequential( + blockId: "2-1", + id: "2-1", + displayName: "Sequential 3", + type: .sequential, + completion: 1.0, + childs: [ + CourseVertical( + blockId: "2-1-1", + id: "2-1-1", + courseId: "2", + displayName: "Vertical 4", + type: .vertical, + completion: 1.0, + childs: [] + ), + CourseVertical( + blockId: "2-1-2", + id: "2-1-2", + courseId: "2", + displayName: "Vertical 5", + type: .vertical, + completion: 1.0, + childs: [] + ) + ], + sequentialProgress: SequentialProgress( + assignmentType: "Advanced Assessment Tools", + numPointsEarned: 1, + numPointsPossible: 3 + ), + due: Date() + ) + ] + ) + ] + + let viewModel = CourseContainerViewModel( + interactor: CourseInteractor.mock, + authInteractor: AuthInteractor.mock, + router: CourseRouterMock(), + analytics: CourseAnalyticsMock(), + config: ConfigMock(), + connectivity: Connectivity(), + manager: DownloadManagerMock(), + storage: CourseStorageMock(), + isActive: true, + courseStart: Date(), + courseEnd: nil, + enrollmentStart: Date(), + enrollmentEnd: nil, + lastVisitedBlockID: nil, + coreAnalytics: CoreAnalyticsMock() + ) + Task { + await withTaskGroup(of: Void.self) { group in + group.addTask { + await viewModel.getCourseBlocks(courseID: "courseId") + } + group.addTask { + await viewModel.getCourseDeadlineInfo(courseID: "courseId") + } + } + } + + return GeometryReader { proxy in + ScrollView { + CustomDisclosureGroup( + course: CourseStructure( + id: "Id", + graded: false, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "Course", + childs: sampleCourseChapters, + media: DataLayer.CourseMedia.init(image: DataLayer.Image(raw: "", small: "", large: "")), + certificate: nil, + org: "org", + isSelfPaced: false, + courseProgress: nil + ), + proxy: proxy, + viewModel: viewModel + ) + } + } + } +} +#endif diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index dff0a0ea9..44f1e4a1b 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -442,6 +442,7 @@ struct CourseUnitView_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 1", @@ -456,6 +457,7 @@ struct CourseUnitView_Previews: PreviewProvider { courseId: "123", topicId: "2", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 2", @@ -470,6 +472,7 @@ struct CourseUnitView_Previews: PreviewProvider { courseId: "123", topicId: "3", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "Lesson 3", @@ -484,6 +487,7 @@ struct CourseUnitView_Previews: PreviewProvider { courseId: "123", topicId: "4", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "4", @@ -517,10 +521,17 @@ struct CourseUnitView_Previews: PreviewProvider { completion: 0, childs: blocks ) - ] + ], + sequentialProgress: SequentialProgress( + assignmentType: "Advanced Assessment Tools", + numPointsEarned: 1, + numPointsPossible: 3 + ), + due: Date() ) - ]), + ] + ), CourseChapter( blockId: "2", id: "2", @@ -543,7 +554,13 @@ struct CourseUnitView_Previews: PreviewProvider { completion: 0, childs: blocks ) - ] + ], + sequentialProgress: SequentialProgress( + assignmentType: "Basic Assessment Tools", + numPointsEarned: 1, + numPointsPossible: 3 + ), + due: Date() ) ]) diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift index 397bf332a..7b7310fb4 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift @@ -77,6 +77,7 @@ struct CourseUnitDropDownCell_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 1, type: .video, displayName: "Lesson 1", diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift index f338a202f..fea9801f3 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift @@ -51,6 +51,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 1", @@ -65,6 +66,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { courseId: "123", topicId: "2", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 2", @@ -79,6 +81,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { courseId: "123", topicId: "3", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "Lesson 3", @@ -93,6 +96,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { courseId: "123", topicId: "4", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "4", diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift index 805c709a1..dd7ddbc75 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift @@ -64,6 +64,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 1", @@ -79,6 +80,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { courseId: "123", topicId: "2", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 2", @@ -94,6 +96,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { courseId: "123", topicId: "3", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "Lesson 3", @@ -109,6 +112,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { courseId: "123", topicId: "4", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "4", diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index a66bfae07..1cada7b12 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -10,6 +10,14 @@ 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 CourseLocalization { + /// Plural format key: "%#@due_in@" + public static func dueIn(_ p1: Int) -> String { + return CourseLocalization.tr("Localizable", "due_in", p1, fallback: "Plural format key: \"%#@due_in@\"") + } + /// Plural format key: "%#@past_due@" + public static func pastDue(_ p1: Int) -> String { + return CourseLocalization.tr("Localizable", "past_due", p1, fallback: "Plural format key: \"%#@past_due@\"") + } public enum Accessibility { /// Cancel download public static let cancelDownload = CourseLocalization.tr("Localizable", "ACCESSIBILITY.CANCEL_DOWNLOAD", fallback: "Cancel download") @@ -30,6 +38,16 @@ public enum CourseLocalization { /// Turning off the switch will stop downloading and delete all downloaded videos for public static let stopDownloading = CourseLocalization.tr("Localizable", "ALERT.STOP_DOWNLOADING", fallback: "Turning off the switch will stop downloading and delete all downloaded videos for") } + public enum Course { + /// Due Today + public static let dueToday = CourseLocalization.tr("Localizable", "COURSE.DUE_TODAY", fallback: "Due Today") + /// Due Tomorrow + public static let dueTomorrow = CourseLocalization.tr("Localizable", "COURSE.DUE_TOMORROW", fallback: "Due Tomorrow") + /// %@ of %@ assignments complete + public static func progressCompleted(_ p1: Any, _ p2: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.PROGRESS_COMPLETED", String(describing: p1), String(describing: p2), fallback: "%@ of %@ assignments complete") + } + } public enum Courseware { /// Back to outline public static let backToOutline = CourseLocalization.tr("Localizable", "COURSEWARE.BACK_TO_OUTLINE", fallback: "Back to outline") diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index 90c6472f3..40a4d8157 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -120,3 +120,8 @@ "COURSE_DATES.RESET_DATE.ERROR_MESSAGE" = "Your dates could not be shifted. Please try again."; "COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE" = "Your dates have been successfully shifted."; "COURSE_DATES.RESET_DATE.TITLE" = "Course Dates"; + +"COURSE.DUE_TODAY" = "Due Today"; +"COURSE.DUE_TOMORROW" = "Due Tomorrow"; + +"COURSE.PROGRESS_COMPLETED" = "%@ of %@ assignments complete"; diff --git a/Course/Course/en.lproj/Localizable.stringsdict b/Course/Course/en.lproj/Localizable.stringsdict new file mode 100644 index 000000000..ccfae8233 --- /dev/null +++ b/Course/Course/en.lproj/Localizable.stringsdict @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>due_in</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@due_in@</string> + <key>due_in</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>d</string> + <key>zero</key> + <string>Today</string> + <key>one</key> + <string>Due in %d day</string> + <key>other</key> + <string>Due in %d days</string> + </dict> + </dict> + <key>past_due</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@past_due@</string> + <key>past_due</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>d</string> + <key>zero</key> + <string>Today</string> + <key>one</key> + <string>Due %d day ago</string> + <key>other</key> + <string>Due %d days ago</string> + </dict> + </dict> +</dict> +</plist> diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index 59a57991a..abb9dc970 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -119,3 +119,8 @@ "COURSE_DATES.RESET_DATE.ERROR_MESSAGE" = "Your dates could not be shifted. Please try again."; "COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE" = "Your dates have been successfully shifted."; "COURSE_DATES.RESET_DATE.TITLE" = "Course Dates"; + +"COURSE.DUE_TODAY" = "Закінчується сьогодні"; +"COURSE.DUE_TOMORROW" = "Закінчується завтра"; + +"COURSE.PROGRESS_COMPLETED" = "%@ з %@ завдань виконано"; diff --git a/Course/Course/uk.lproj/Localizable.stringsdict b/Course/Course/uk.lproj/Localizable.stringsdict new file mode 100644 index 000000000..0b7ac9460 --- /dev/null +++ b/Course/Course/uk.lproj/Localizable.stringsdict @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>due_in</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@due_in@</string> + <key>due_in</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>d</string> + <key>zero</key> + <string>Today</string> + <key>one</key> + <string>Прострочено на %d день</string> + <key>other</key> + <string>Прострочено на %d днів</string> + </dict> + </dict> + <key>past_due</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@past_due@</string> + <key>past_due</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>d</string> + <key>zero</key> + <string>Today</string> + <key>one</key> + <string>Залишився %d день</string> + <key>other</key> + <string>Залишилося %d днів</string> + </dict> + </dict> +</dict> +</plist> diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index cd69ebbb3..144614179 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -49,7 +49,8 @@ final class CourseContainerViewModelTests: XCTestCase { id: "", courseId: "123", topicId: "", - graded: true, + graded: true, + due: Date(), completion: 0, type: .problem, displayName: "", @@ -73,7 +74,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( blockId: "", @@ -99,7 +102,8 @@ final class CourseContainerViewModelTests: XCTestCase { large: "")), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) let resumeBlock = ResumeBlock(blockID: "123") @@ -167,7 +171,8 @@ final class CourseContainerViewModelTests: XCTestCase { large: "")), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) Given(interactor, .getLoadedCourseBlocks(courseID: .any, willReturn: courseStructure)) @@ -369,6 +374,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -402,7 +408,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -429,7 +437,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) let downloadData = DownloadDataTask( @@ -507,6 +516,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -539,7 +549,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -566,7 +578,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) Given(connectivity, .isInternetAvaliable(getter: true)) @@ -629,6 +642,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -661,7 +675,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -688,7 +704,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) Given(connectivity, .isInternetAvaliable(getter: true)) @@ -752,6 +769,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -784,7 +802,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -811,7 +831,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) Given(connectivity, .isInternetAvaliable(getter: true)) @@ -868,6 +889,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -900,7 +922,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -927,7 +951,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) let downloadData = DownloadDataTask( @@ -999,6 +1024,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -1031,7 +1057,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -1058,7 +1086,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) let downloadData = DownloadDataTask( @@ -1129,6 +1158,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -1150,6 +1180,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -1182,7 +1213,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -1209,7 +1242,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) let downloadData = DownloadDataTask( diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 74d006cbc..19ae08286 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -46,7 +46,8 @@ final class CourseDateViewModelTests: XCTestCase { large: "")), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) Given(interactor, .getCourseDates(courseID: .any, willReturn: courseDates)) diff --git a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift index 1e206e81e..abf7d2000 100644 --- a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift @@ -20,7 +20,8 @@ final class CourseUnitViewModelTests: XCTestCase { id: "1", courseId: "123", topicId: "1", - graded: false, + graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 1", @@ -34,6 +35,7 @@ final class CourseUnitViewModelTests: XCTestCase { courseId: "123", topicId: "2", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 2", @@ -47,6 +49,7 @@ final class CourseUnitViewModelTests: XCTestCase { courseId: "123", topicId: "3", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "Lesson 3", @@ -60,6 +63,7 @@ final class CourseUnitViewModelTests: XCTestCase { courseId: "123", topicId: "4", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "4", @@ -90,7 +94,10 @@ final class CourseUnitViewModelTests: XCTestCase { type: .vertical, completion: 0, childs: blocks) - ]) + ], + sequentialProgress: nil, + due: Date() + ) ]), CourseChapter( @@ -112,7 +119,10 @@ final class CourseUnitViewModelTests: XCTestCase { type: .vertical, completion: 0, childs: blocks) - ]) + ], + sequentialProgress: nil, + due: Date() + ) ]) ] diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 12ee2d514..6b722f8c2 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -203,11 +203,12 @@ class AppAssembly: Assembly { }.inObjectScope(.container) container.register(PipManagerProtocol.self) { r in - PipManager( + let config = r.resolve(ConfigProtocol.self)! + return PipManager( router: r.resolve(Router.self)!, discoveryInteractor: r.resolve(DiscoveryInteractorProtocol.self)!, courseInteractor: r.resolve(CourseInteractorProtocol.self)!, - isNestedListEnabled: r.resolve(ConfigProtocol.self)?.uiComponents.courseNestedListEnabled ?? false + courseDropDownNavigationEnabled: config.uiComponents.courseDropDownNavigationEnabled ) }.inObjectScope(.container) } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 44b421731..04ddc0514 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -352,7 +352,7 @@ class ScreenAssembly: Assembly { container.register( YouTubeVideoPlayerViewModel.self - ) { (r, url: URL?, blockID: String, courseID: String, languages, playerStateSubject) in + ) { (r, url: URL?, blockID: String, courseID: String, languages: [SubtitleUrl], playerStateSubject: CurrentValueSubject<VideoPlayerState?, Never>) in let router: Router = r.resolve(Router.self)! return YouTubeVideoPlayerViewModel( languages: languages, diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index 74005ca53..94ded4e9b 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -75,7 +75,7 @@ public class CoursePersistence: CoursePersistenceProtocol { let requestBlocks = CDCourseBlock.fetchRequest() requestBlocks.predicate = NSPredicate(format: "courseID = %@", courseID) - + let blocks = try? context.fetch(requestBlocks).map { let userViewData = DataLayer.CourseDetailUserViewData( transcripts: $0.transcripts?.jsonStringToDictionary() as? [String: String], @@ -111,6 +111,7 @@ public class CoursePersistence: CoursePersistenceProtocol { blockId: $0.blockId ?? "", id: $0.id ?? "", graded: $0.graded, + due: $0.due, completion: $0.completion, studentUrl: $0.studentUrl ?? "", webUrl: $0.webUrl ?? "", @@ -119,7 +120,12 @@ public class CoursePersistence: CoursePersistenceProtocol { descendants: $0.descendants, allSources: $0.allSources, userViewData: userViewData, - multiDevice: $0.multiDevice + multiDevice: $0.multiDevice, + assignmentProgress: DataLayer.AssignmentProgress( + assignmentType: $0.assignmentType, + numPointsEarned: $0.numPointsEarned, + numPointsPossible: $0.numPointsPossible + ) ) } @@ -140,7 +146,11 @@ public class CoursePersistence: CoursePersistenceProtocol { ), certificate: DataLayer.Certificate(url: structure.certificate), org: structure.org ?? "", - isSelfPaced: structure.isSelfPaced + isSelfPaced: structure.isSelfPaced, + courseProgress: DataLayer.CourseProgress( + assignmentsCompleted: Int(structure.assignmentsCompleted), + totalAssignmentsCount: Int(structure.totalAssignmentsCount) + ) ) } @@ -155,6 +165,8 @@ public class CoursePersistence: CoursePersistenceProtocol { newStructure.id = structure.id newStructure.rootItem = structure.rootItem newStructure.isSelfPaced = structure.isSelfPaced + newStructure.totalAssignmentsCount = Int32(structure.courseProgress?.totalAssignmentsCount ?? 0) + newStructure.assignmentsCompleted = Int32(structure.courseProgress?.assignmentsCompleted ?? 0) for block in Array(structure.dict.values) { let courseDetail = CDCourseBlock(context: self.context) @@ -169,6 +181,18 @@ public class CoursePersistence: CoursePersistenceProtocol { courseDetail.type = block.type courseDetail.completion = block.completion ?? 0 courseDetail.multiDevice = block.multiDevice ?? false + if let numPointsEarned = block.assignmentProgress?.numPointsEarned { + courseDetail.numPointsEarned = numPointsEarned + } + if let numPointsPossible = block.assignmentProgress?.numPointsPossible { + courseDetail.numPointsPossible = numPointsPossible + } + if let assignmentType = block.assignmentProgress?.assignmentType { + courseDetail.assignmentType = assignmentType + } + if let due = block.due { + courseDetail.due = due + } if block.userViewData?.encodedVideo?.youTube != nil { let youTube = CDCourseBlockVideo(context: self.context) diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 6de11a104..94895077d 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -16,7 +16,7 @@ public class PipManager: PipManagerProtocol { let discoveryInteractor: DiscoveryInteractorProtocol let courseInteractor: CourseInteractorProtocol let router: Router - let isNestedListEnabled: Bool + let courseDropDownNavigationEnabled: Bool public var isPipActive: Bool { controllerHolder != nil } @@ -28,12 +28,12 @@ public class PipManager: PipManagerProtocol { router: Router, discoveryInteractor: DiscoveryInteractorProtocol, courseInteractor: CourseInteractorProtocol, - isNestedListEnabled: Bool + courseDropDownNavigationEnabled: Bool ) { self.discoveryInteractor = discoveryInteractor self.courseInteractor = courseInteractor self.router = router - self.isNestedListEnabled = isNestedListEnabled + self.courseDropDownNavigationEnabled = courseDropDownNavigationEnabled } public func holder( @@ -114,7 +114,7 @@ public class PipManager: PipManagerProtocol { viewControllers.append(try await containerController(for: holder)) } - if !isNestedListEnabled && holder.selectedCourseTab != CourseTab.dates.rawValue { + if !courseDropDownNavigationEnabled && holder.selectedCourseTab != CourseTab.dates.rawValue { viewControllers.append(try await courseVerticalController(for: holder)) } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index f14818890..8dbbafa20 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -485,7 +485,7 @@ public class Router: AuthorizationRouter, )! let config = Container.shared.resolve(ConfigProtocol.self) - let isDropdownActive = config?.uiComponents.courseNestedListEnabled ?? false + let isDropdownActive = config?.uiComponents.courseDropDownNavigationEnabled ?? false let view = CourseUnitView(viewModel: viewModel, isDropdownActive: isDropdownActive) return UIHostingController(rootView: view) @@ -590,13 +590,12 @@ public class Router: AuthorizationRouter, chapterIndex: chapterIndex, sequentialIndex: sequentialIndex ) - - let config = Container.shared.resolve(ConfigProtocol.self) - let isCourseNestedListEnabled = config?.uiComponents.courseNestedListEnabled ?? false var controllers = navigationController.viewControllers + let config = Container.shared.resolve(ConfigProtocol.self)! + let courseDropDownNavigationEnabled = config.uiComponents.courseDropDownNavigationEnabled - if isCourseNestedListEnabled || currentCourseTabSelection == CourseTab.dates.rawValue { + if courseDropDownNavigationEnabled || currentCourseTabSelection == CourseTab.dates.rawValue { controllers.removeLast(1) controllers.append(contentsOf: [controllerUnit]) } else { diff --git a/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewBackground.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewBackground.colorset/Contents.json index 164b36790..44092279d 100644 --- a/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewBackground.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewBackground.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset copy/Contents.json b/Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset copy/Contents.json deleted file mode 100644 index 00d59cb46..000000000 --- a/Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset copy/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "0.408", - "red" : "0.235" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.976", - "green" : "0.471", - "red" : "0.329" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset copy/Contents.json b/Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset copy/Contents.json deleted file mode 100644 index 14e0c379b..000000000 --- a/Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset copy/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.443", - "green" : "0.239", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.443", - "green" : "0.239", - "red" : "1.000" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Theme/Theme/Assets.xcassets/Colors/TabbarColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/TabbarColor.colorset/Contents.json index 2d9b9cd70..7e4772ec9 100644 --- a/Theme/Theme/Assets.xcassets/Colors/TabbarColor.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/TabbarColor.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "0.984", - "green" : "0.980", - "red" : "0.976" + "blue" : "0xFA", + "green" : "0xF9", + "red" : "0xF8" } }, "idiom" : "universal"