From 7647134e18a5f3324d8bde2c0252a9a2ca4adfc1 Mon Sep 17 00:00:00 2001 From: Gene <76485998+eyatsenkoperpetio@users.noreply.github.com> Date: Tue, 12 Dec 2023 12:35:22 +0100 Subject: [PATCH] Sequence Units level to Course home view as nested list (#192) * feat: add expand section in detail course * refactor: add click animation and gray arrow * refactor: add new paddings * fix: new ids for expand sections * chore: add counter and remove banner * fix: top offset * chore: add course expandable sections enabled and course banner enabled * fix: tests * chore: move flags * chore: add new flag course nested list * fix: animation * chore: remove extra code * chore: change key name and merge with develop * chore: add arrow to header section * chore: add flags --- Core/Core.xcodeproj/project.pbxproj | 4 + .../Config/UIComponentsConfig.swift | 11 +- Core/Core/Domain/Model/CourseBlockModel.swift | 8 +- Core/Core/SwiftGen/Strings.swift | 2 + .../View/Base/CustomDisclosureGroup.swift | 49 ++++ Core/Core/View/Base/UnitButtonView.swift | 2 +- Core/Core/en.lproj/Localizable.strings | 1 + Core/Core/uk.lproj/Localizable.strings | 1 + Course/Course.xcodeproj/project.pbxproj | 4 + .../Container/CourseContainerViewModel.swift | 75 ++++-- .../Outline/CourseExpandableContentView.swift | 215 +++++++++++++++++ .../Outline/CourseOutlineView.swift | 227 ++++++++++-------- .../Outline/CourseVerticalViewModel.swift | 2 +- .../CourseContainerViewModelTests.swift | 14 +- OpenEdX/Router.swift | 20 +- default_config/dev/config.yaml | 6 + default_config/prod/config.yaml | 6 + default_config/stage/config.yaml | 6 + 18 files changed, 510 insertions(+), 143 deletions(-) create mode 100644 Core/Core/View/Base/CustomDisclosureGroup.swift create mode 100644 Course/Course/Presentation/Outline/CourseExpandableContentView.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 5bdd59a8e..80bc8fd5d 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -118,6 +118,7 @@ 0770DE5F28D0B22C006D8A5D /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE5E28D0B22C006D8A5D /* Strings.swift */; }; 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE6028D0B2CB006D8A5D /* Assets.swift */; }; 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; + BAAD62C62AFCF00B000E6103 /* CustomDisclosureGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */; }; BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30427D2B20B299009B64B7 /* SocialAuthError.swift */; }; BA76135C2B21BC7300B599B7 /* SocialAuthResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */; }; BA8B3A2F2AD546A700D25EF5 /* DebugLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8B3A2E2AD546A700D25EF5 /* DebugLog.swift */; }; @@ -288,6 +289,7 @@ 3B74C6685E416657F3C5F5A8 /* Pods-App-Core.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.releaseprod.xcconfig"; sourceTree = ""; }; 60153262DBC2F9E660D7E11B /* Pods-App-Core.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.release.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.release.xcconfig"; sourceTree = ""; }; 9D5B06CAA99EA5CD49CBE2BB /* Pods-App-Core.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugdev.xcconfig"; sourceTree = ""; }; + BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisclosureGroup.swift; sourceTree = ""; }; BA30427D2B20B299009B64B7 /* SocialAuthError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthResponse.swift; sourceTree = ""; }; BA8B3A2E2AD546A700D25EF5 /* DebugLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugLog.swift; sourceTree = ""; }; @@ -628,6 +630,7 @@ 027BD3C42909707700392132 /* Shake.swift */, 023A1135291432B200D0D354 /* RegistrationTextField.swift */, 023A1137291432FD00D0D354 /* FieldConfiguration.swift */, + BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */, BA8FA6672AD59A5700EA029A /* SocialAuthButton.swift */, 02E93F862AEBAED4006C4750 /* AppReview */, ); @@ -1015,6 +1018,7 @@ DBF6F24A2B0380E00098414B /* FeaturesConfig.swift in Sources */, 02F164372902A9EB0090DDEF /* StringExtension.swift in Sources */, 0231CDBE2922422D00032416 /* CSSInjector.swift in Sources */, + BAAD62C62AFCF00B000E6103 /* CustomDisclosureGroup.swift in Sources */, BADB3F5B2AD6EC56004D5CFA /* ResultExtension.swift in Sources */, 0236961928F9A26900EEF206 /* AuthRepository.swift in Sources */, 023A1136291432B200D0D354 /* RegistrationTextField.swift in Sources */, diff --git a/Core/Core/Configuration/Config/UIComponentsConfig.swift b/Core/Core/Configuration/Config/UIComponentsConfig.swift index 1c2e48913..da05749e5 100644 --- a/Core/Core/Configuration/Config/UIComponentsConfig.swift +++ b/Core/Core/Configuration/Config/UIComponentsConfig.swift @@ -8,14 +8,17 @@ import Foundation private enum Keys: String { - case isVerticalsMenuEnabled = "VERTICALS_MENU_ENABLED" + case courseNestedListEnabled = "COURSE_NESTED_LIST_ENABLED" + case courseBannerEnabled = "COURSE_BANNER_ENABLED" } public class UIComponentsConfig: NSObject { - public var isVerticalsMenuEnabled: Bool = false - + public var courseNestedListEnabled: Bool = false + public var courseBannerEnabled: Bool + init(dictionary: [String: Any]) { - isVerticalsMenuEnabled = dictionary[Keys.isVerticalsMenuEnabled.rawValue] as? Bool ?? false + courseNestedListEnabled = dictionary[Keys.courseNestedListEnabled.rawValue] as? Bool ?? false + courseBannerEnabled = dictionary[Keys.courseBannerEnabled.rawValue] as? Bool ?? false super.init() } } diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index c22907af4..aeba9df44 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -49,8 +49,8 @@ public struct CourseStructure: Equatable { } -public struct CourseChapter { - +public struct CourseChapter: Identifiable { + public let blockId: String public let id: String public let displayName: String @@ -72,8 +72,8 @@ public struct CourseChapter { } } -public struct CourseSequential { - +public struct CourseSequential: Identifiable { + public let blockId: String public let id: String public let displayName: String diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index 8485925d2..a7de0a666 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -53,6 +53,8 @@ public enum CoreLocalization { public static let nextSectionDescriptionLast = CoreLocalization.tr("Localizable", "COURSEWARE.NEXT_SECTION_DESCRIPTION_LAST", fallback: "” press “Next section”.") /// Prev public static let previous = CoreLocalization.tr("Localizable", "COURSEWARE.PREVIOUS", fallback: "Prev") + /// Resume + public static let resume = CoreLocalization.tr("Localizable", "COURSEWARE.RESUME", fallback: "Resume") /// Section “ public static let section = CoreLocalization.tr("Localizable", "COURSEWARE.SECTION", fallback: "Section “") } diff --git a/Core/Core/View/Base/CustomDisclosureGroup.swift b/Core/Core/View/Base/CustomDisclosureGroup.swift new file mode 100644 index 000000000..c4a023ed3 --- /dev/null +++ b/Core/Core/View/Base/CustomDisclosureGroup.swift @@ -0,0 +1,49 @@ +// +// CustomDisclosureGroup.swift +// Core +// +// Created by Eugene Yatsenko on 09.11.2023. +// + +import SwiftUI + +public struct CustomDisclosureGroup: 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, + 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/UnitButtonView.swift b/Core/Core/View/Base/UnitButtonView.swift index c6dc39069..3f50e3279 100644 --- a/Core/Core/View/Base/UnitButtonView.swift +++ b/Core/Core/View/Base/UnitButtonView.swift @@ -35,7 +35,7 @@ public enum UnitButtonType: Equatable { case .reload: return CoreLocalization.Error.reload case .continueLesson: - return CoreLocalization.Courseware.continue + return CoreLocalization.Courseware.resume case .nextSection: return CoreLocalization.Courseware.nextSection case let .custom(text): diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 555ad1c6a..27cbbfaad 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -32,6 +32,7 @@ "COURSEWARE.SECTION" = "Section “"; "COURSEWARE.IS_FINISHED" = "“ is finished."; "COURSEWARE.CONTINUE" = "Continue"; +"COURSEWARE.RESUME" = "Resume"; "COURSEWARE.CONTINUE_WITH" = "Continue with:"; "COURSEWARE.NEXT_SECTION" = "Next section"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index 2d56ca83c..5dbf95db3 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -31,6 +31,7 @@ "COURSEWARE.SECTION" = "Секція “"; "COURSEWARE.IS_FINISHED" = "“ завершена."; "COURSEWARE.CONTINUE" = "Продовжити"; +"COURSEWARE.RESUME" = "Resume"; "COURSEWARE.CONTINUE_WITH" = "Продовжити далі:"; "COURSEWARE.NEXT_SECTION" = "Наступний розділ"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 05edc33a3..61b4587a4 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -70,6 +70,7 @@ 0766DFD0299AB29000EBEF6A /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0766DFCF299AB29000EBEF6A /* PlayerViewController.swift */; }; 197FB8EA8F92F00A8F383D82 /* Pods_App_Course.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E795BD160CDA7D9C120DE6 /* Pods_App_Course.framework */; }; B8F50317B6B830A0E520C954 /* Pods_App_Course_CourseTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50E59D2B81E12610964282C5 /* Pods_App_Course_CourseTests.framework */; }; + BAAD62C82AFD00EE000E6103 /* CourseExpandableContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAAD62C72AFD00EE000E6103 /* CourseExpandableContentView.swift */; }; DB205BFB2AE81B1200136EC2 /* CourseDateViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */; }; DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */; }; DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */; }; @@ -167,6 +168,7 @@ A47C63D9EB0D866F303D4588 /* Pods-App-Course.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Course/Pods-App-Course.releasestage.xcconfig"; sourceTree = ""; }; ADC2A1B8183A674705F5F7E2 /* Pods-App-Course.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course.debug.xcconfig"; path = "Target Support Files/Pods-App-Course/Pods-App-Course.debug.xcconfig"; sourceTree = ""; }; B196A14555D0E006995A5683 /* Pods-App-CourseDetails.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-CourseDetails.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-CourseDetails/Pods-App-CourseDetails.releaseprod.xcconfig"; sourceTree = ""; }; + BAAD62C72AFD00EE000E6103 /* CourseExpandableContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseExpandableContentView.swift; sourceTree = ""; }; DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDateViewModelTests.swift; sourceTree = ""; }; DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseDatesView.swift; sourceTree = ""; }; DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDatesViewModel.swift; sourceTree = ""; }; @@ -395,6 +397,7 @@ 0270210128E736E700F54332 /* CourseOutlineView.swift */, 02A8076729474831007F53AB /* CourseVerticalView.swift */, 0248C92629C097EB00DC8402 /* CourseVerticalViewModel.swift */, + BAAD62C72AFD00EE000E6103 /* CourseExpandableContentView.swift */, 06FD7EDE2B1F29F3008D632B /* CourseVerticalImageView.swift */, ); path = Outline; @@ -753,6 +756,7 @@ 073512E229C0E400005CFA41 /* BaseCourseViewModel.swift in Sources */, 0231124F28EDA811002588FB /* CourseUnitViewModel.swift in Sources */, 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */, + BAAD62C82AFD00EE000E6103 /* CourseExpandableContentView.swift in Sources */, 02A8076829474831007F53AB /* CourseVerticalView.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 861a87d93..e7589f9ad 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -16,7 +16,8 @@ public class CourseContainerViewModel: BaseCourseViewModel { @Published var courseVideosStructure: CourseStructure? @Published private(set) var isShowProgress = false @Published var showError: Bool = false - @Published var downloadState: [String: DownloadViewState] = [:] + @Published var sequentialsDownloadState: [String: DownloadViewState] = [:] + @Published var verticalsDownloadState: [String: DownloadViewState] = [:] @Published var continueWith: ContinueWith? var errorMessage: String? { @@ -129,7 +130,16 @@ public class CourseContainerViewModel: BaseCourseViewModel { courseStructure: courseStructure ) } - + + func verticalsBlocksDownloadable(by courseSequential: CourseSequential) -> [String: DownloadViewState] { + verticalsDownloadState.filter { dict in + courseSequential.childs.contains(where: { item in + let state = verticalsDownloadState[dict.key] + return (state == .available || state == .downloading) && dict.key == item.id + }) + } + } + func onDownloadViewTap(chapter: CourseChapter, blockId: String, state: DownloadViewState) { let blocks = chapter.childs .first(where: { $0.id == blockId })?.childs @@ -140,13 +150,13 @@ public class CourseContainerViewModel: BaseCourseViewModel { switch state { case .available: try manager.addToDownloadQueue(blocks: blocks) - downloadState[blockId] = .downloading + sequentialsDownloadState[blockId] = .downloading case .downloading: try manager.cancelDownloading(courseId: courseStructure?.id ?? "", blocks: blocks) - downloadState[blockId] = .available + sequentialsDownloadState[blockId] = .available case .finished: manager.deleteFile(blocks: blocks) - downloadState[blockId] = .available + sequentialsDownloadState[blockId] = .available } } catch let error { if error is NoWiFiError { @@ -154,7 +164,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } } - + func trackSelectedTab( selection: CourseContainerView.CourseTab, courseId: String, @@ -173,7 +183,20 @@ public class CourseContainerViewModel: BaseCourseViewModel { analytics.courseOutlineHandoutsTabClicked(courseId: courseId, courseName: courseName) } } - + + func trackVerticalClicked( + courseId: String, + courseName: String, + vertical: CourseVertical + ) { + analytics.verticalClicked( + courseId: courseId, + courseName: courseName, + blockId: vertical.blockId, + blockName: vertical.displayName + ) + } + func trackSequentialClicked(_ sequential: CourseSequential) { guard let course = courseStructure else { return } analytics.sequentialClicked( @@ -197,35 +220,49 @@ public class CourseContainerViewModel: BaseCourseViewModel { private func setDownloadsStates() { guard let course = courseStructure else { return } let downloads = manager.getDownloadsForCourse(course.id) - var states: [String: DownloadViewState] = [:] + var sequentialsStates: [String: DownloadViewState] = [:] + var verticalsStates: [String: DownloadViewState] = [:] for chapter in course.childs { for sequential in chapter.childs where sequential.isDownloadable { - var childs: [DownloadViewState] = [] + var sequentialsChilds: [DownloadViewState] = [] for vertical in sequential.childs where vertical.isDownloadable { + var verticalsChilds: [DownloadViewState] = [] for block in vertical.childs where block.isDownloadable { if let download = downloads.first(where: { $0.id == block.id }) { switch download.state { case .waiting, .inProgress: - childs.append(.downloading) + sequentialsChilds.append(.downloading) + verticalsChilds.append(.downloading) case .paused: - childs.append(.available) + sequentialsChilds.append(.available) + verticalsChilds.append(.available) case .finished: - childs.append(.finished) + sequentialsChilds.append(.finished) + verticalsChilds.append(.finished) } } else { - childs.append(.available) + sequentialsChilds.append(.available) + verticalsChilds.append(.available) } } + if verticalsChilds.first(where: { $0 == .downloading }) != nil { + verticalsStates[vertical.id] = .downloading + } else if verticalsChilds.allSatisfy({ $0 == .finished }) { + verticalsStates[vertical.id] = .finished + } else { + verticalsStates[vertical.id] = .available + } } - if childs.first(where: { $0 == .downloading }) != nil { - states[sequential.id] = .downloading - } else if childs.allSatisfy({ $0 == .finished }) { - states[sequential.id] = .finished + if sequentialsChilds.first(where: { $0 == .downloading }) != nil { + sequentialsStates[sequential.id] = .downloading + } else if sequentialsChilds.allSatisfy({ $0 == .finished }) { + sequentialsStates[sequential.id] = .finished } else { - states[sequential.id] = .available + sequentialsStates[sequential.id] = .available } } - self.downloadState = states + self.sequentialsDownloadState = sequentialsStates + self.verticalsDownloadState = verticalsStates } } diff --git a/Course/Course/Presentation/Outline/CourseExpandableContentView.swift b/Course/Course/Presentation/Outline/CourseExpandableContentView.swift new file mode 100644 index 000000000..ee742d727 --- /dev/null +++ b/Course/Course/Presentation/Outline/CourseExpandableContentView.swift @@ -0,0 +1,215 @@ +// +// CourseStructureView.swift +// Course +// +// Created by Eugene Yatsenko on 09.11.2023. +// + +import SwiftUI +import Core +import Kingfisher +import Theme + +struct CourseExpandableContentView: 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() + if isExpanded { + Image(systemName: "chevron.right") + .foregroundColor(Theme.Colors.accentColor) + .rotationEffect(.degrees(90)) + } else { + Image(systemName: "chevron.right") + .foregroundColor(Theme.Colors.accentColor) + } + } + .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 { + Button { + onLabelClick(sequential: sequential, chapter: chapter) + } label: { + HStack { + Group { + if sequential.completion == 1 { + CoreAssets.finished.swiftUIImage + .renderingMode(.template) + .foregroundColor(.accentColor) + } 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 + ) + let downloadable = viewModel.verticalsBlocksDownloadable(by: sequential) + if !downloadable.isEmpty { + Text(String(downloadable.count)) + .foregroundColor(Color(UIColor.label)) + } + } + .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: + DownloadAvailableView() + .onTapGesture { + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: chapter.id, + state: state + ) + } + .onForeground { + viewModel.onForeground() + } + case .downloading: + DownloadProgressView() + .onTapGesture { + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: chapter.id, + state: state + ) + } + .onBackground { + viewModel.onBackground() + } + case .finished: + DownloadFinishedView() + .onTapGesture { + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: chapter.id, + state: state + ) + } + } + } + } + + 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 ?? "", + sectionName: block.displayName, + verticalIndex: 0, + chapters: course.childs, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + + } + +} diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index f13597e8a..6db7740c1 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -42,57 +42,10 @@ public struct CourseOutlineView: View { await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) }) { VStack(alignment: .leading) { - ZStack { - // MARK: - Course Banner - if let banner = viewModel.courseStructure?.media.image.raw - .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { - KFImage(URL(string: viewModel.config.baseURL.absoluteString + banner)) - .onFailureImage(CoreAssets.noCourseImage.image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(maxWidth: proxy.size.width - 12, maxHeight: .infinity) - } - - // MARK: - Course Certificate - if let certificate = viewModel.courseStructure?.certificate { - if let url = certificate.url, url.count > 0 { - Theme.Colors.certificateForeground - VStack(alignment: .center, spacing: 8) { - CoreAssets.certificate.swiftUIImage - Text(CourseLocalization.Outline.congratulations) - .multilineTextAlignment(.center) - .font(Theme.Fonts.headlineMedium) - Text(CourseLocalization.Outline.passedTheCourse) - .font(Theme.Fonts.bodyMedium) - .multilineTextAlignment(.center) - StyledButton( - CourseLocalization.Outline.viewCertificate, - action: { openCertificateView = true }, - isTransparent: true - ) - .frame(width: 141) - .padding(.top, 8) - - .fullScreenCover( - isPresented: $openCertificateView, - content: { - WebBrowser( - url: url, - pageTitle: CourseLocalization.Outline.certificate - ) - }) - }.padding(.horizontal, 24) - .padding(.top, 8) - .foregroundColor(Theme.Colors.white) - } - } + if viewModel.config.uiComponents.courseBannerEnabled { + courseBanner(proxy: proxy) } - .frame(maxHeight: 250) - .cornerRadius(12) - .padding(.horizontal, 6) - .padding(.top, 7) - .fixedSize(horizontal: false, vertical: true) - + if let continueWith = viewModel.continueWith, let courseStructure = viewModel.courseStructure, !isVideo { @@ -124,11 +77,20 @@ public struct CourseOutlineView: View { : viewModel.courseStructure { // MARK: - Sections - CourseStructureView( - proxy: proxy, - course: course, - viewModel: viewModel - ) + if viewModel.config.uiComponents.courseNestedListEnabled { + CourseExpandableContentView( + proxy: proxy, + course: course, + viewModel: viewModel + ) + } else { + CourseStructureView( + proxy: proxy, + course: course, + viewModel: viewModel + ) + } + } else { if let courseStart = viewModel.courseStart { Text(courseStart > Date() ? CourseLocalization.Outline.courseHasntStarted : "") @@ -183,6 +145,59 @@ public struct CourseOutlineView: View { .ignoresSafeArea() ) } + + private func courseBanner(proxy: GeometryProxy) -> some View { + ZStack { + // MARK: - Course Banner + if let banner = viewModel.courseStructure?.media.image.raw + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { + KFImage(URL(string: viewModel.config.baseURL.absoluteString + banner)) + .onFailureImage(CoreAssets.noCourseImage.image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: proxy.size.width - 12, maxHeight: .infinity) + } + + // MARK: - Course Certificate + if let certificate = viewModel.courseStructure?.certificate { + if let url = certificate.url, url.count > 0 { + Theme.Colors.certificateForeground + VStack(alignment: .center, spacing: 8) { + CoreAssets.certificate.swiftUIImage + Text(CourseLocalization.Outline.congratulations) + .multilineTextAlignment(.center) + .font(Theme.Fonts.headlineMedium) + Text(CourseLocalization.Outline.passedTheCourse) + .font(Theme.Fonts.bodyMedium) + .multilineTextAlignment(.center) + StyledButton( + CourseLocalization.Outline.viewCertificate, + action: { openCertificateView = true }, + isTransparent: true + ) + .frame(width: 141) + .padding(.top, 8) + + .fullScreenCover( + isPresented: $openCertificateView, + content: { + WebBrowser( + url: url, + pageTitle: CourseLocalization.Outline.certificate + ) + }) + }.padding(.horizontal, 24) + .padding(.top, 8) + .foregroundColor(.white) + } + } + } + .frame(maxHeight: 250) + .cornerRadius(12) + .padding(.horizontal, 6) + .padding(.top, 7) + .fixedSize(horizontal: false, vertical: true) + } } struct CourseStructureView: View { @@ -211,8 +226,8 @@ struct CourseStructureView: View { ForEach(chapter.childs, id: \.id) { child in let sequentialIndex = chapter.childs.firstIndex(where: { $0.id == child.id }) VStack(alignment: .leading) { - Button( - action: { + HStack { + Button { if let chapterIndex, let sequentialIndex { viewModel.trackSequentialClicked(child) viewModel.router.showCourseVerticalView( @@ -224,8 +239,7 @@ struct CourseStructureView: View { sequentialIndex: sequentialIndex ) } - }, - label: { + } label: { Group { if child.completion == 1 { CoreAssets.finished.swiftUIImage @@ -244,49 +258,60 @@ struct CourseStructureView: View { : proxy.size.width * 0.6, alignment: .leading ) - }.foregroundColor(Theme.Colors.textPrimary) - Spacer() - if let state = viewModel.downloadState[child.id] { - switch state { - case .available: - DownloadAvailableView() - .onTapGesture { - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - .onForeground { - viewModel.onForeground() - } - case .downloading: - DownloadProgressView() - .onTapGesture { - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - .onBackground { - viewModel.onBackground() - } - case .finished: - DownloadFinishedView() - .onTapGesture { - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - } } - Image(systemName: "chevron.right") - .foregroundColor(Theme.Colors.accentColor) - }).padding(.horizontal, 36) - .padding(.vertical, 20) + .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 { + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: child.id, + state: state + ) + } + .onForeground { + viewModel.onForeground() + } + case .downloading: + DownloadProgressView() + .accessibilityElement(children: .ignore) + .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) + .onTapGesture { + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: child.id, + state: state + ) + } + .onBackground { + viewModel.onBackground() + } + case .finished: + DownloadFinishedView() + .accessibilityElement(children: .ignore) + .accessibilityLabel(CourseLocalization.Accessibility.deleteDownload) + .onTapGesture { + 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) diff --git a/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift b/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift index 9f2ae30b7..bf113389e 100644 --- a/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift +++ b/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift @@ -80,7 +80,7 @@ public class CourseVerticalViewModel: BaseCourseViewModel { } } } - + func trackVerticalClicked( courseId: String, courseName: String, diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 86c12aa7a..ef8f08708 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -367,7 +367,7 @@ final class CourseContainerViewModelTests: XCTestCase { state: .available ) - XCTAssertEqual(viewModel.downloadState[blockId], .downloading) + XCTAssertEqual(viewModel.sequentialsDownloadState[blockId], .downloading) } func testOnDownloadViewDownloadingTap() { @@ -414,7 +414,7 @@ final class CourseContainerViewModelTests: XCTestCase { state: .downloading ) - XCTAssertEqual(viewModel.downloadState[blockId], .available) + XCTAssertEqual(viewModel.sequentialsDownloadState[blockId], .available) } func testOnDownloadViewFinishedTap() { @@ -461,7 +461,7 @@ final class CourseContainerViewModelTests: XCTestCase { state: .finished ) - XCTAssertEqual(viewModel.downloadState[blockId], .available) + XCTAssertEqual(viewModel.sequentialsDownloadState[blockId], .available) } func testSetDownloadsStatesAvailable() { @@ -558,7 +558,7 @@ final class CourseContainerViewModelTests: XCTestCase { wait(for: [exp], timeout: 1) - XCTAssertEqual(viewModel.downloadState[sequential.id], .available) + XCTAssertEqual(viewModel.sequentialsDownloadState[sequential.id], .available) } func testSetDownloadsStatesDownloading() { @@ -666,7 +666,7 @@ final class CourseContainerViewModelTests: XCTestCase { wait(for: [exp], timeout: 1) - XCTAssertEqual(viewModel.downloadState[sequential.id], .downloading) + XCTAssertEqual(viewModel.sequentialsDownloadState[sequential.id], .downloading) } func testSetDownloadsStatesFinished() { @@ -774,7 +774,7 @@ final class CourseContainerViewModelTests: XCTestCase { wait(for: [exp], timeout: 1) - XCTAssertEqual(viewModel.downloadState[sequential.id], .finished) + XCTAssertEqual(viewModel.sequentialsDownloadState[sequential.id], .finished) } func testSetDownloadsStatesPartiallyFinished() { @@ -895,6 +895,6 @@ final class CourseContainerViewModelTests: XCTestCase { wait(for: [exp], timeout: 1) - XCTAssertEqual(viewModel.downloadState[sequential.id], .available) + XCTAssertEqual(viewModel.sequentialsDownloadState[sequential.id], .available) } } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index aac5a0fa8..a04b4c8fd 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -304,8 +304,8 @@ public class Router: AuthorizationRouter, )! let config = Container.shared.resolve(ConfigProtocol.self) - let isDropdownActive = config?.uiComponents.isVerticalsMenuEnabled ?? false - + let isDropdownActive = config?.uiComponents.courseNestedListEnabled ?? false + let view = CourseUnitView(viewModel: viewModel, sectionName: sectionName, isDropdownActive: isDropdownActive) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) @@ -378,13 +378,21 @@ public class Router: AuthorizationRouter, )! let config = Container.shared.resolve(ConfigProtocol.self) - let isDropdownActive = config?.uiComponents.isVerticalsMenuEnabled ?? false - + let isDropdownActive = config?.uiComponents.courseNestedListEnabled ?? false + let view = CourseUnitView(viewModel: viewModel, sectionName: sectionName, isDropdownActive: isDropdownActive) let controllerUnit = UIHostingController(rootView: view) var controllers = navigationController.viewControllers - controllers.removeLast(2) - controllers.append(contentsOf: [controllerVertical, controllerUnit]) + + if let config = container.resolve(ConfigProtocol.self), + config.uiComponents.courseNestedListEnabled { + controllers.removeLast(1) + controllers.append(contentsOf: [controllerUnit]) + } else { + controllers.removeLast(2) + controllers.append(contentsOf: [controllerVertical, controllerUnit]) + } + navigationController.setViewControllers(controllers, animated: animated) } diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index d7e76817e..04091f6f7 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -2,3 +2,9 @@ API_HOST_URL: 'http://localhost:8000' ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' + +UI_COMPONENTS: + COURSE_BANNER_ENABLED: true + COURSE_TOP_TAB_BAR_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_NESTED_LIST_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index d7e76817e..04091f6f7 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -2,3 +2,9 @@ API_HOST_URL: 'http://localhost:8000' ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' + +UI_COMPONENTS: + COURSE_BANNER_ENABLED: true + COURSE_TOP_TAB_BAR_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_NESTED_LIST_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index d7e76817e..04091f6f7 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -2,3 +2,9 @@ API_HOST_URL: 'http://localhost:8000' ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' + +UI_COMPONENTS: + COURSE_BANNER_ENABLED: true + COURSE_TOP_TAB_BAR_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_NESTED_LIST_ENABLED: false