diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index b8270883a..6bff06608 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -449,10 +449,10 @@ public struct CourseBlockVideo: Equatable, Sendable { } public var isVideoURL: Bool { - [".mp4", ".m3u8"].contains(where: { url?.contains($0) == true }) + [".mp4", ".m3u8", ".MP4"].contains(where: { url?.contains($0) == true }) } public var isDownloadable: Bool { - [".mp4"].contains(where: { url?.contains($0) == true }) + [".mp4", ".MP4"].contains(where: { url?.contains($0) == true }) } } diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 08b03f5fa..a2c9717ef 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -97,6 +97,7 @@ A5A510A22E66E8D600553607 /* CourseVideoCarouselSlideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A510A12E66E8D600553607 /* CourseVideoCarouselSlideView.swift */; }; A5A510A42E6728AD00553607 /* CourseAssignmentsCarouselSlideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A510A32E6728AD00553607 /* CourseAssignmentsCarouselSlideView.swift */; }; A5A510A62E672E4A00553607 /* AssignmentCarouselDetailCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A510A52E672E4A00553607 /* AssignmentCarouselDetailCardView.swift */; }; + A59016D52E69B2E900076220 /* VideoNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59016D42E69B2E900076220 /* VideoNavigationView.swift */; }; B8F50317B6B830A0E520C954 /* Pods_App_Course_CourseTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50E59D2B81E12610964282C5 /* Pods_App_Course_CourseTests.framework */; }; BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF5C2B3D804D005B102E /* CourseStorage.swift */; }; BA58CF612B471041005B102E /* VideoDownloadQualityBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF602B471041005B102E /* VideoDownloadQualityBarView.swift */; }; @@ -281,6 +282,7 @@ A5A510A12E66E8D600553607 /* CourseVideoCarouselSlideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseVideoCarouselSlideView.swift; sourceTree = ""; }; A5A510A32E6728AD00553607 /* CourseAssignmentsCarouselSlideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseAssignmentsCarouselSlideView.swift; sourceTree = ""; }; A5A510A52E672E4A00553607 /* AssignmentCarouselDetailCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignmentCarouselDetailCardView.swift; sourceTree = ""; }; + A59016D42E69B2E900076220 /* VideoNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNavigationView.swift; 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 = ""; }; BA58CF5C2B3D804D005B102E /* CourseStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStorage.swift; sourceTree = ""; }; @@ -390,6 +392,7 @@ 02454C9E2A2618D40043052A /* Subviews */ = { isa = PBXGroup; children = ( + A59016D42E69B2E900076220 /* VideoNavigationView.swift */, 068DDA5A2B1E198700FF8CCB /* DropdownList */, 02454C9F2A2618E70043052A /* YouTubeView.swift */, 02454CA12A26190A0043052A /* EncodedVideoView.swift */, @@ -1144,6 +1147,7 @@ 975F475E2B6151FD00E5B031 /* CourseDatesMock.swift in Sources */, 02FF6FAA2C20D56A00E44DD8 /* TotalDownloadedProgressView.swift in Sources */, 975F47602B615DA700E5B031 /* CourseStructureMock.swift in Sources */, + A59016D52E69B2E900076220 /* VideoNavigationView.swift in Sources */, 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */, 0766DFCE299AB26D00EBEF6A /* EncodedVideoPlayer.swift in Sources */, A5A510A42E6728AD00553607 /* CourseAssignmentsCarouselSlideView.swift in Sources */, diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index 6f3d802e8..a21a8b5bd 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -28,6 +28,7 @@ public protocol CourseInteractorProtocol: Sendable { func enrichCourseStructureWithLocalProgress(_ structure: CourseStructure) async -> CourseStructure func getCourseProgress(courseID: String) async throws -> CourseProgressDetails func getCourseProgressOffline(courseID: String) async throws -> CourseProgressDetails + func getAllVideosForNavigation(structure course: CourseStructure) async throws -> [CourseBlock] } public actor CourseInteractor: CourseInteractorProtocol, CourseStructureManagerProtocol { @@ -136,7 +137,20 @@ public actor CourseInteractor: CourseInteractorProtocol, CourseStructureManagerP } } } - + + public func getAllVideosForNavigation(structure course: CourseStructure) -> [CourseBlock] { + var newChilds = [CourseChapter]() + for chapter in course.childs { + let newChapter = filterChapter(chapter: chapter) + if !newChapter.childs.isEmpty { + newChilds.append(newChapter) + } + } + + let allVideos = getAllVideosFromStructure(childs: newChilds) + return allVideos + } + private func getAllAssignmentsFromStructure(childs: [CourseChapter]) -> [CourseBlock] { return childs.flatMap { chapter in chapter.childs.flatMap { sequential in diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 9a660d57d..f2abf08f8 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -209,7 +209,9 @@ public final class CourseContainerViewModel: BaseCourseViewModel { verticalIndex: continueWith.verticalIndex, chapters: courseStructure.childs, chapterIndex: continueWith.chapterIndex, - sequentialIndex: continueWith.sequentialIndex + sequentialIndex: continueWith.sequentialIndex, + showVideoNavigation: false, + courseVideoStructure: nil ) } @@ -1181,7 +1183,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { NotificationCenter.default.removeObserver(self) } - func handleVideoTap(video: CourseBlock, chapter: CourseChapter) { + func handleVideoTap(video: CourseBlock, chapter: CourseChapter?) { // Find indices for navigation using full course structure guard let chapterIndex = findChapterIndexInFullStructure(video: video), let sequentialIndex = findSequentialIndexInFullStructure(video: video), @@ -1205,7 +1207,9 @@ public final class CourseContainerViewModel: BaseCourseViewModel { verticalIndex: verticalIndex, chapters: courseStructure.childs, chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex + sequentialIndex: sequentialIndex, + showVideoNavigation: true, + courseVideoStructure: courseStructure ) } @@ -1700,7 +1704,9 @@ public final class CourseContainerViewModel: BaseCourseViewModel { verticalIndex: 0, chapters: courseStructure.childs, chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex + sequentialIndex: sequentialIndex, + showVideoNavigation: false, + courseVideoStructure: nil ) } else { router.showCourseVerticalView( diff --git a/Course/Course/Presentation/Content/Elements/VideoSectionView.swift b/Course/Course/Presentation/Content/Elements/VideoSectionView.swift index 3a0c2b9bf..a09bf9bf5 100644 --- a/Course/Course/Presentation/Content/Elements/VideoSectionView.swift +++ b/Course/Course/Presentation/Content/Elements/VideoSectionView.swift @@ -14,7 +14,7 @@ struct VideoSectionView: View { let sectionData: VideoSectionData let proxy: GeometryProxy @Binding var isShowingCompletedVideos: Bool - let onVideoTap: (CourseBlock, CourseChapter) -> Void + let onVideoTap: (CourseBlock, CourseChapter?) -> Void let onDownloadSectionTap: (CourseChapter, DownloadViewState) async -> Void private var chapter: CourseChapter { diff --git a/Course/Course/Presentation/Content/Elements/VideoThumbnailView.swift b/Course/Course/Presentation/Content/Elements/VideoThumbnailView.swift index 85ca421c0..9964e1920 100644 --- a/Course/Course/Presentation/Content/Elements/VideoThumbnailView.swift +++ b/Course/Course/Presentation/Content/Elements/VideoThumbnailView.swift @@ -159,6 +159,7 @@ struct VideoThumbnailView: View { if type == .continueWith { thumbnailImageView() .aspectRatio(16/9, contentMode: .fill) + .scaleEffect(y: 1.35, anchor: .center) .clipped() .cornerRadius(10) // for future update @@ -193,7 +194,7 @@ struct VideoThumbnailView: View { .multilineTextAlignment(.leading) .foregroundStyle(.white) .padding(.horizontal, 11) - .padding(.top, 11) + .padding(.top, self.type == .navigationVideo ? 4 : 11) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) // MARK: - Play Button Overlay @@ -227,7 +228,7 @@ struct VideoThumbnailView: View { .clipped() .overlay { RoundedRectangle(cornerRadius: 10) - .stroke(lineWidth: isCurrentVideo ? 3 : (video.completion >= 1.0 ? 2 : 0)) + .stroke(lineWidth: isCurrentVideo ? 4 : (video.completion >= 1.0 ? 2 : 0)) .foregroundStyle(isCurrentVideo ? Theme.Colors.accentColor : Theme.Colors.success) } } @@ -376,8 +377,12 @@ struct VideoThumbnailView: View { } private func openVideo() { - if let chapter { - thumbnailData.onVideoTap(video, chapter) + if self.type == .navigationVideo { + thumbnailData.onVideoTap(video, nil) + } else { + if let chapter { + thumbnailData.onVideoTap(video, chapter) + } } } diff --git a/Course/Course/Presentation/Content/Models/VideoContentData.swift b/Course/Course/Presentation/Content/Models/VideoContentData.swift index c389dff5c..5d21c9941 100644 --- a/Course/Course/Presentation/Content/Models/VideoContentData.swift +++ b/Course/Course/Presentation/Content/Models/VideoContentData.swift @@ -35,7 +35,7 @@ struct VideoSectionData { struct VideoThumbnailData { let video: CourseBlock - let chapter: CourseChapter + var chapter: CourseChapter? let courseStructure: CourseStructure? - let onVideoTap: (CourseBlock, CourseChapter) -> Void + let onVideoTap: (CourseBlock, CourseChapter?) -> Void? } diff --git a/Course/Course/Presentation/Content/Subviews/VideosContentView.swift b/Course/Course/Presentation/Content/Subviews/VideosContentView.swift index be9d26ff9..e3dae10fc 100644 --- a/Course/Course/Presentation/Content/Subviews/VideosContentView.swift +++ b/Course/Course/Presentation/Content/Subviews/VideosContentView.swift @@ -15,7 +15,7 @@ struct VideosContentView: View { let videoContentData: VideoContentData private let proxy: GeometryProxy - private let onVideoTap: (CourseBlock, CourseChapter) -> Void + private let onVideoTap: (CourseBlock, CourseChapter?) -> Void private let onDownloadSectionTap: (CourseChapter, DownloadViewState) async -> Void private let onTabSelection: (Int) -> Void private let onErrorDismiss: () -> Void @@ -26,7 +26,7 @@ struct VideosContentView: View { init( videoContentData: VideoContentData, proxy: GeometryProxy, - onVideoTap: @escaping (CourseBlock, CourseChapter) -> Void, + onVideoTap: @escaping (CourseBlock, CourseChapter?) -> Void, onDownloadSectionTap: @escaping (CourseChapter, DownloadViewState) async -> Void, onTabSelection: @escaping (Int) -> Void, onErrorDismiss: @escaping () -> Void, diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index ddd03f8e5..b1684874c 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -20,7 +20,9 @@ public protocol CourseRouter: BaseRouter { verticalIndex: Int, chapters: [CourseChapter], chapterIndex: Int, - sequentialIndex: Int + sequentialIndex: Int, + showVideoNavigation: Bool, + courseVideoStructure: CourseStructure? ) func replaceCourseUnit( @@ -31,7 +33,9 @@ public protocol CourseRouter: BaseRouter { chapters: [CourseChapter], chapterIndex: Int, sequentialIndex: Int, - animated: Bool + animated: Bool, + showVideoNavigation: Bool, + courseVideoStructure: CourseStructure? ) func showCourseVerticalView( @@ -77,7 +81,9 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { verticalIndex: Int, chapters: [CourseChapter], chapterIndex: Int, - sequentialIndex: Int + sequentialIndex: Int, + showVideoNavigation: Bool, + courseVideoStructure: CourseStructure? ) {} public func replaceCourseUnit( @@ -88,7 +94,9 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { chapters: [CourseChapter], chapterIndex: Int, sequentialIndex: Int, - animated: Bool + animated: Bool, + showVideoNavigation: Bool, + courseVideoStructure: CourseStructure? ) {} public func showCourseVerticalView( diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index de23e0277..01266f36a 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -57,7 +57,9 @@ public struct CourseVerticalView: View { verticalIndex: index, chapters: viewModel.chapters, chapterIndex: viewModel.chapterIndex, - sequentialIndex: viewModel.sequentialIndex + sequentialIndex: viewModel.sequentialIndex, + showVideoNavigation: false, + courseVideoStructure: nil ) } }, label: { diff --git a/Course/Course/Presentation/Progress/CourseProgressViewModel.swift b/Course/Course/Presentation/Progress/CourseProgressViewModel.swift index 98837a091..5f0068c52 100644 --- a/Course/Course/Presentation/Progress/CourseProgressViewModel.swift +++ b/Course/Course/Presentation/Progress/CourseProgressViewModel.swift @@ -148,7 +148,7 @@ public class CourseProgressViewModel: ObservableObject { for chapter in courseStructure.childs { for sequential in chapter.childs where sequential.sequentialProgress?.assignmentType == assignmentType { - total = chapter.childs.count + total += 1 if sequential.completion == 1 { completed += 1 } diff --git a/Course/Course/Presentation/Subviews/CourseCarouselView/AssignmentCarouselDetailCardView.swift b/Course/Course/Presentation/Subviews/CourseCarouselView/AssignmentCarouselDetailCardView.swift index 193d873d1..e0d1fc49f 100644 --- a/Course/Course/Presentation/Subviews/CourseCarouselView/AssignmentCarouselDetailCardView.swift +++ b/Course/Course/Presentation/Subviews/CourseCarouselView/AssignmentCarouselDetailCardView.swift @@ -28,15 +28,18 @@ struct AssignmentCarouselDetailCardView: View { }) { HStack { VStack(alignment: .leading, spacing: 8) { - if detailData.subsectionUI.status != .completed && detailData.subsectionUI.date != nil { - HStack { + HStack { + + if detailData.subsectionUI.status == .pastDue { CoreAssets.icAssignmentPastDue.swiftUIImage - Text(detailData.subsectionUI.status == .pastDue ? "Past Due" : "Due Soon") - .font(Theme.Fonts.titleMedium) - .foregroundStyle(Theme.Colors.textPrimary) } + + Text(detailData.subsectionUI.status == .pastDue ? CoreLocalization.CourseDates.pastDue + : CourseLocalization.CourseCarousel.nextAssignments) + .font(Theme.Fonts.titleMedium) + .foregroundStyle(Theme.Colors.textPrimary) } - + HStack { VStack(alignment: .leading) { if detailData.subsectionUI.date != nil { diff --git a/Course/Course/Presentation/Subviews/CourseCarouselView/CourseVideoCarouselSlideView.swift b/Course/Course/Presentation/Subviews/CourseCarouselView/CourseVideoCarouselSlideView.swift index fe260a86a..b60b2262c 100644 --- a/Course/Course/Presentation/Subviews/CourseCarouselView/CourseVideoCarouselSlideView.swift +++ b/Course/Course/Presentation/Subviews/CourseCarouselView/CourseVideoCarouselSlideView.swift @@ -83,14 +83,14 @@ struct CourseVideoCarouselSlideView: View { private var videoBlock: CourseBlock? { guard let chapter = courseChapter else { return nil } let videos = getAllVideos(from: chapter) - if let partial = videos.first( - where: { - $0.localVideoProgress > 0 && $0.localVideoProgress < 1 && $0.completion < 1 - }) { + if let partial = videos.first(where: { + $0.localVideoProgress > 0 && $0.localVideoProgress < 1 + }) { return partial } else { - return videos.first(where: { $0.localVideoProgress == 0 }) + return videos.first(where: { $0.localVideoProgress == 0 && $0.completion < 1 }) } + } // MARK: - Body @@ -129,7 +129,7 @@ struct CourseVideoCarouselSlideView: View { viewModelContainer.selectedTab = .videos viewModelContainer.trackCourseHomeViewAllVideosClicked() } - .padding(.top, -20) + .padding(.top, allVideosCompleted ? -10 : -20) .frame(maxWidth: .infinity) } } diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift index 3a0e36134..efde0257a 100644 --- a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -100,7 +100,9 @@ struct CustomDisclosureGroup: View { verticalIndex: 0, chapters: course.childs, chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex + sequentialIndex: sequentialIndex, + showVideoNavigation: false, + courseVideoStructure: nil ) } else { viewModel.router.showCourseVerticalView( diff --git a/Course/Course/Presentation/Unit/CourseNavigationView.swift b/Course/Course/Presentation/Unit/CourseNavigationView.swift index 4257f18e9..8d5837fa3 100644 --- a/Course/Course/Presentation/Unit/CourseNavigationView.swift +++ b/Course/Course/Presentation/Unit/CourseNavigationView.swift @@ -14,7 +14,7 @@ struct CourseNavigationView: View { @ObservedObject private var viewModel: CourseUnitViewModel private let playerStateSubject: CurrentValueSubject - + init( viewModel: CourseUnitViewModel, playerStateSubject: CurrentValueSubject @@ -24,11 +24,21 @@ struct CourseNavigationView: View { } var body: some View { - HStack(alignment: .top, spacing: 7) { + HStack(alignment: .top, spacing: 16) { if viewModel.selectedLesson() == viewModel.verticals[viewModel.verticalIndex].childs.first - && viewModel.verticals[viewModel.verticalIndex].childs.count != 1 { + && viewModel.verticals[viewModel.verticalIndex].childs.count != 1 && !viewModel.showVideoNavigation { nextBigButton .frame(width: 215) + } else if viewModel.showVideoNavigation { + + if let index = viewModel.currentVideoIndex, index != 0 { + previousButtonVideoNavigation + } + + if let index = viewModel.currentVideoIndex, index != viewModel.allVideosForNavigation.count - 1 { + nextButtonVideoNavigation + } + } else { if viewModel.selectedLesson() == viewModel.verticals[viewModel.verticalIndex].childs.last { if viewModel.selectedLesson() != viewModel.verticals[viewModel.verticalIndex].childs.first { @@ -45,7 +55,36 @@ struct CourseNavigationView: View { } }.padding(.horizontal, 24) } - + + private var nextButtonVideoNavigation: some View { + UnitButtonView( + type: .next, + isVerticalNavigation: false, + action: { + if let index = viewModel.currentVideoIndex, index != viewModel.allVideosForNavigation.count - 1 { + playerStateSubject.send(VideoPlayerState.kill) + let video = viewModel.allVideosForNavigation[index + 1] + viewModel.handleVideoTap(video: video) + } + } + ) + } + + private var previousButtonVideoNavigation: some View { + UnitButtonView( + type: .previous, + isVerticalNavigation: false, + action: { + if let index = viewModel.currentVideoIndex, index != 0 { + playerStateSubject.send(VideoPlayerState.kill) + let video = viewModel.allVideosForNavigation[index - 1] + viewModel.handleVideoTap(video: video) + } + } + ) + .accessibilityLabel(Text(CourseLocalization.Courseware.previousFull)) + } + private var nextBigButton: some View { UnitButtonView( type: .nextBig, @@ -129,7 +168,9 @@ struct CourseNavigationView: View { chapters: viewModel.chapters, chapterIndex: data.chapterIndex, sequentialIndex: data.sequentialIndex, - animated: true + animated: true, + showVideoNavigation: false, + courseVideoStructure: nil ) } ) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index b635922e5..ee7a9e835 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -37,7 +37,9 @@ public struct CourseUnitView: View { @State private var videoURLs: [String: URL?] = [:] @State private var webURLs: [String: URL?] = [:] - + + @State var currentBlock: CourseBlock? + let isDropdownActive: Bool var sequenceTitle: String { @@ -69,6 +71,12 @@ public struct CourseUnitView: View { self.isDropdownActive = isDropdownActive viewModel.loadIndex() viewModel.nextTitles() + + if viewModel.showVideoNavigation { + Task { + await viewModel.getCourseVideoBlocks() + } + } } public var body: some View { @@ -165,14 +173,39 @@ public struct CourseUnitView: View { private func content(reader: GeometryProxy) -> some View { let alignment = UnitAlignment(horizontalAlignment: .top, verticalAlignment: .leading) let offset = viewOffset(for: viewModel.index, with: reader.size, insets: reader.safeAreaInsets) + UnitStack(isVerticalNavigation: !isHorizontalNavigation, alignment: alignment, spacing: 0) { let data = Array(viewModel.verticals[viewModel.verticalIndex].childs.enumerated()) - ForEach(data, id: \.offset) { index, block in + ForEach(data, id: \.offset) {index, block in VStack(spacing: 0) { + if isDropdownActive { videoTitle(block: block, width: reader.size.width) } + + if viewModel.showVideoNavigation { + if !isHorizontal { + VideoNavigationView( + viewModel: viewModel, + currentBlock: $currentBlock, + block: block + ) + .onReceive(NotificationCenter.default.publisher(for: + .onVideoProgressUpdated)) { _ in + Task { + await viewModel.getCourseVideoBlocks() + } + } + } + } + contentView(for: block, index: index, reader: reader) + .onChange(of: viewModel.allVideosForNavigation) { _ in + if let currentBlock { + let index = self.viewModel.allVideosForNavigation.firstIndex(of: currentBlock) + viewModel.currentVideoIndex = index + } + } } .frame( width: isHorizontal ? reader.size.width - (isHorizontalNavigation ? 0 : 16) : reader.size.width, @@ -217,6 +250,10 @@ public struct CourseUnitView: View { index: index, reader: reader ) + .onAppear { + self.currentBlock = block + } + // MARK: Encoded Video case let .video(encodedUrl, blockID): videoView( @@ -226,6 +263,9 @@ public struct CourseUnitView: View { index: index, reader: reader ) + .onAppear { + self.currentBlock = block + } // MARK: Web case let .web(url, injections, blockId, isDownloadable): webView( @@ -519,8 +559,9 @@ public struct CourseUnitView: View { private var navigationBar: some View { VStack(spacing: 0) { ZStack(alignment: .bottom) { + let title = viewModel.showVideoNavigation ? currentBlock?.displayName ?? "" : sequenceTitle NavigationBar( - title: isDropdownActive ? sequenceTitle : "", + title: isDropdownActive || viewModel.showVideoNavigation ? title : "", leftButtonAction: { viewModel.router.back() playerStateSubject.send(VideoPlayerState.kill) @@ -539,7 +580,7 @@ public struct CourseUnitView: View { .padding(.horizontal, 48) } } - .background(Theme.Colors.background) + .background(!viewModel.showVideoNavigation ? Theme.Colors.background : Theme.Colors.courseCardBackground) .padding(.trailing, isHorizontal ? 215 : 0) if viewModel.courseUnitProgressEnabled { diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index 1074ad0ae..c832771a1 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -130,7 +130,8 @@ public final class CourseUnitViewModel: ObservableObject { var verticals: [CourseVertical] var verticalIndex: Int var courseName: String - + + @Published var courseVideosStructure: CourseStructure? @Published var index: Int = 0 var previousLesson: String = "" var nextLesson: String = "" @@ -140,7 +141,12 @@ public final class CourseUnitViewModel: ObservableObject { showError = errorMessage != nil } } - + + @Published public var allVideosForNavigation: [CourseBlock] = [] + @Published public var allVideosFetched = false + @Published public var isVideosForNavigationLoading: Bool = false + @Published var currentVideoIndex: Int? + var lessonID: String var courseID: String @@ -155,6 +161,8 @@ public final class CourseUnitViewModel: ObservableObject { let chapters: [CourseChapter] let chapterIndex: Int let sequentialIndex: Int + + var showVideoNavigation: Bool = false var streamingQuality: StreamingQuality { storage.userSettings?.streamingQuality ?? .auto @@ -182,7 +190,9 @@ public final class CourseUnitViewModel: ObservableObject { analytics: CourseAnalytics, connectivity: ConnectivityProtocol, storage: CourseStorage, - manager: DownloadManagerProtocol + manager: DownloadManagerProtocol, + showVideoNavigation: Bool = false, + courseVideosStructure: CourseStructure? = nil ) { self.lessonID = lessonID self.courseID = courseID @@ -199,6 +209,8 @@ public final class CourseUnitViewModel: ObservableObject { self.connectivity = connectivity self.manager = manager self.storage = storage + self.showVideoNavigation = showVideoNavigation + self.courseVideosStructure = courseVideosStructure } private func selectLesson() -> Int { @@ -380,7 +392,9 @@ public final class CourseUnitViewModel: ObservableObject { chapters: chapters, chapterIndex: data.chapterIndex, sequentialIndex: data.sequentialIndex, - animated: animated + animated: animated, + showVideoNavigation: false, + courseVideoStructure: nil ) } } @@ -393,4 +407,163 @@ public final class CourseUnitViewModel: ObservableObject { public var currentCourseId: String { courseID } + + @MainActor + private func getCourseStructure(courseID: String) async throws -> CourseStructure? { + if connectivity.isInternetAvaliable { + return try await interactor.getCourseBlocks(courseID: courseID) + } else { + return try await interactor.getLoadedCourseBlocks(courseID: courseID) + } + } + + @MainActor + func getCourseVideoBlocks() async { + isVideosForNavigationLoading = true + + defer { + Task { @MainActor in + try? await Task.sleep(for: .seconds(0.2)) + self.isVideosForNavigationLoading = false + } + } + + if let courseVideosStructure { + do { + let videoFromCourse = await interactor.getCourseVideoBlocks(fullStructure: courseVideosStructure) + + allVideosForNavigation = try await interactor.getAllVideosForNavigation( + structure: videoFromCourse + ) + + return + + } catch { + print("Failed to get all videos for course: \(error.localizedDescription)") + } + } + + async let structureTask = getCourseStructure(courseID: courseID) + + do { + guard let courseStructure = try await structureTask else { + throw NSError( + domain: "GetCourseBlocks", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Course structure is nil"] + ) + } + + async let videosTask = interactor.getCourseVideoBlocks(fullStructure: courseStructure) + courseVideosStructure = await videosTask + + if let courseVideosStructure { + allVideosForNavigation = try await interactor.getAllVideosForNavigation( + structure: courseVideosStructure + ) + } + + } catch { + print("Failed to load course blocks: \(error.localizedDescription)") + courseVideosStructure = nil + } + } + + func createBreadCrumpsForVideoNavigation(video: CourseBlock) -> String { + guard let courseStructure = courseVideosStructure else { + return "" + } + + let breadcrumb = courseStructure.childs + .flatMap { chapter in + chapter.childs.flatMap { sequential in + sequential.childs.compactMap { vertical -> String? in + if vertical.childs.contains(where: { $0.id == video.id }) { + return [chapter.displayName, sequential.displayName, vertical.displayName] + .joined(separator: " > ") + } + return nil + } + } + } + .first + + return breadcrumb ?? "" + } + + func handleVideoTap(video: CourseBlock) { + // Find indices for navigation using full course structure + guard let chapterIndex = findChapterIndexInFullStructure(video: video), + let sequentialIndex = findSequentialIndexInFullStructure(video: video), + let verticalIndex = findVerticalIndexInFullStructure(video: video), + let courseStructure = courseVideosStructure else { + return + } + + // Track video click analytics + analytics.courseVideoClicked( + courseId: courseStructure.id, + courseName: courseStructure.displayName, + blockId: video.id, + blockName: video.displayName + ) + + router.replaceCourseUnit( + courseName: courseName, + blockId: video.blockId, + courseID: courseID, + verticalIndex: verticalIndex, + chapters: courseStructure.childs, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex, + animated: false, + showVideoNavigation: true, + courseVideoStructure: courseStructure + ) + } + + private func findChapterIndexInFullStructure(video: CourseBlock) -> Int? { + guard let courseStructure = courseVideosStructure else { return nil } + + // Find the chapter that contains this video in the full structure + return courseStructure.childs.firstIndex { fullChapter in + fullChapter.childs.contains { sequential in + sequential.childs.contains { vertical in + vertical.childs.contains { $0.id == video.id } + } + } + } + } + + private func findSequentialIndexInFullStructure(video: CourseBlock) -> Int? { + guard let courseStructure = courseVideosStructure else { return nil } + + // Find the chapter and sequential that contains this video in the full structure + for fullChapter in courseStructure.childs { + if let sequentialIndex = fullChapter.childs.firstIndex(where: { sequential in + sequential.childs.contains { vertical in + vertical.childs.contains { $0.id == video.id } + } + }) { + return sequentialIndex + } + } + return nil + } + + private func findVerticalIndexInFullStructure(video: CourseBlock) -> Int? { + guard let courseStructure = courseVideosStructure else { return nil } + + // Find the vertical that contains this video in the full structure + for fullChapter in courseStructure.childs { + for sequential in fullChapter.childs { + if let verticalIndex = sequential.childs.firstIndex(where: { vertical in + vertical.childs.contains { $0.id == video.id } + }) { + return verticalIndex + } + } + } + return nil + } } diff --git a/Course/Course/Presentation/Unit/Subviews/VideoNavigationView.swift b/Course/Course/Presentation/Unit/Subviews/VideoNavigationView.swift new file mode 100644 index 000000000..c65a50e6b --- /dev/null +++ b/Course/Course/Presentation/Unit/Subviews/VideoNavigationView.swift @@ -0,0 +1,138 @@ +import Foundation +import SwiftUI +import Core +import OEXFoundation +import Theme + +struct VideoNavigationView: View { + @ObservedObject var viewModel: CourseUnitViewModel + @Binding var currentBlock: CourseBlock? + @State private var uiScrollView: UIScrollView? + let block: CourseBlock + + var body: some View { + if viewModel.isVideosForNavigationLoading { + HStack { + Spacer() + RefreshProgressView(isShowRefresh: $viewModel.isVideosForNavigationLoading) + Spacer() + } + .frame(height: 72) + .padding(.bottom, 17) + .background(content: { + Theme.Colors.courseCardBackground + }) + } else { + videoNavigationView(block: block) + .padding(.bottom, 17) + .background(content: { + Theme.Colors.courseCardBackground + }) + } + + Rectangle() + .frame(height: 1) + .foregroundStyle(Theme.Colors.avatarStroke.opacity(0.3)) + .padding(.bottom, 16) + + HStack { + let breadCrumps = viewModel.createBreadCrumpsForVideoNavigation(video: block) + + VStack(alignment: .leading, spacing: 8) { + Text(breadCrumps) + .font(Theme.Fonts.bodySmall) + .foregroundStyle(Theme.Colors.textPrimary) + .multilineTextAlignment(.leading) + .lineLimit(2) + + Text("\(currentBlock?.displayName ?? "")") + .font(Theme.Fonts.bodyMedium).bold() + .foregroundStyle(Theme.Colors.textPrimary) + .accessibilityLabel(currentBlock?.displayName ?? + CourseLocalization.VideoNavigation.noVideoTitle) + } + Spacer() + } + .padding(.bottom, 16) + .padding(.horizontal, 16) + } + + @ViewBuilder + private func videoNavigationView(block: CourseBlock) -> some View { + ScrollViewReader { scrollProxy in + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 0) { + ForEach(Array(viewModel.allVideosForNavigation.enumerated()), id: \.element.id) { index, video in + VideoThumbnailView( + thumbnailData: VideoThumbnailData( + video: video, + chapter: nil, + courseStructure: nil, + onVideoTap: { video, _ in + viewModel.handleVideoTap(video: video) + } + ), + type: .navigationVideo, + thumbnailWidth: 122, + thumbnailHeight: 67, + isCurrentVideo: video == currentBlock, + + ) + .padding(.leading, index == 0 ? 24 : 8) + .padding(.top, 2) + .id(video.id) + + if index == viewModel.allVideosForNavigation.count - 1 { + Spacer(minLength: 100) + } + } + } + } + .introspect(.scrollView, on: .iOS(.v16, .v17, .v18)) { scroll in + DispatchQueue.main.async { + uiScrollView = scroll + } + } + .onAppear(perform: { + scrollToCurrentVideo(block: block) { id in + scrollProxy.scrollTo(id, anchor: .leading) + } + }) + .onChange(of: viewModel.allVideosForNavigation) { _ in + scrollToCurrentVideo(block: block) { id in + scrollProxy.scrollTo(id, anchor: .leading) + } + } + .background(content: { + Theme.Colors.courseCardBackground + }) + .frame(height: 72) + .accessibilityLabel(CourseLocalization.Accessibility.videoNavigation) + .accessibilityValue({ + let all = viewModel.allVideosForNavigation + let count = all.count + let idx = indexOfCurrent(in: all, current: currentBlock) ?? 0 + return CourseLocalization.Accessibility.videoNavigationCount(idx+1, count) + }()) + } + } + + private func indexOfCurrent(in all: [CourseBlock], current: CourseBlock?) -> Int? { + guard let current else { return nil } + return all.firstIndex(of: current) + } + + private func scrollToCurrentVideo(block: CourseBlock, scrollTo: @escaping (String) -> Void) { + guard let currentVideo = viewModel.allVideosForNavigation.first(where: { $0 == block }) else { + return + } + + scrollTo(currentVideo.id) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let scroll = uiScrollView else { return } + let newX = max(scroll.contentOffset.x - 20, 0) + scroll.setContentOffset(CGPoint(x: newX, y: 0), animated: true) + } + } +} diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 1807eaeb8..48b2d50c7 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -109,6 +109,12 @@ public enum CourseLocalization { } /// Tap to show completed sections public static let showCompletedSections = CourseLocalization.tr("Localizable", "ACCESSIBILITY.SHOW_COMPLETED_SECTIONS", fallback: "Tap to show completed sections") + /// Video Navigation + public static let videoNavigation = CourseLocalization.tr("Localizable", "ACCESSIBILITY.VIDEO_NAVIGATION", fallback: "Video Navigation") + /// Video %@ from %@ + public static func videoNavigationCount(_ p1: Any, _ p2: Any) -> String { + return CourseLocalization.tr("Localizable", "ACCESSIBILITY.VIDEO_NAVIGATION_COUNT", String(describing: p1), String(describing: p2), fallback: "Video %@ from %@") + } /// %@ percent completed public static func videoPercentageCompleted(_ p1: Any) -> String { return CourseLocalization.tr("Localizable", "ACCESSIBILITY.VIDEO_PERCENTAGE_COMPLETED", String(describing: p1), fallback: "%@ percent completed") @@ -317,6 +323,8 @@ public enum CourseLocalization { public static let next = CourseLocalization.tr("Localizable", "COURSEWARE.NEXT", fallback: "Next") /// Prev public static let previous = CourseLocalization.tr("Localizable", "COURSEWARE.PREVIOUS", fallback: "Prev") + /// Previous + public static let previousFull = CourseLocalization.tr("Localizable", "COURSEWARE.PREVIOUS_FULL", fallback: "Previous") /// Resume with: public static let resumeWith = CourseLocalization.tr("Localizable", "COURSEWARE.RESUME_WITH", fallback: "Resume with:") /// You've completed “ @@ -338,6 +346,8 @@ public enum CourseLocalization { public static let grades = CourseLocalization.tr("Localizable", "COURSE_CAROUSEL.GRADES", fallback: "Grades") /// This represents your weighted grade against the grade needed to pass this course. public static let gradesDescription = CourseLocalization.tr("Localizable", "COURSE_CAROUSEL.GRADES_DESCRIPTION", fallback: "This represents your weighted grade against the grade needed to pass this course.") + /// Next Assignment + public static let nextAssignments = CourseLocalization.tr("Localizable", "COURSE_CAROUSEL.NEXT_ASSIGNMENTS", fallback: "Next Assignment") /// Next Video public static let nextVideo = CourseLocalization.tr("Localizable", "COURSE_CAROUSEL.NEXT_VIDEO", fallback: "Next Video") /// You have completed %@%% of the course progress @@ -613,6 +623,10 @@ public enum CourseLocalization { /// Subtitles public static let title = CourseLocalization.tr("Localizable", "SUBTITLES.TITLE", fallback: "Subtitles") } + public enum VideoNavigation { + /// No Video Title + public static let noVideoTitle = CourseLocalization.tr("Localizable", "VIDEO_NAVIGATION.NO_VIDEO_TITLE", fallback: "No Video Title") + } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index b4646e861..507347bee 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -16,6 +16,7 @@ "COURSEWARE.COURSE_UNITS" = "Course units"; "COURSEWARE.NEXT" = "Next"; "COURSEWARE.PREVIOUS" = "Prev"; +"COURSEWARE.PREVIOUS_FULL" = "Previous"; "COURSEWARE.FINISH" = "Finish"; "COURSEWARE.GOOD_WORK" = "Good job!"; "COURSEWARE.BACK_TO_OUTLINE" = "Back to outline"; @@ -65,6 +66,7 @@ "COURSE_CAROUSEL.NEXT_VIDEO" = "Next Video"; "COURSE_CAROUSEL.VIEW_ALL_VIDEOS" = "All Videos"; "COURSE_CAROUSEL.VIEW_ALL_ASSIGNMENTS" = "All Assignments"; +"COURSE_CAROUSEL.NEXT_ASSIGNMENTS" = "Next Assignment"; // MARK: - Course Content "COURSE_CONTENT.ALL" = "All"; @@ -273,3 +275,8 @@ "ACCESSIBILITY.VIDEO_THUMBNAIL_NOT_STARTED" = "%@, not started"; "ACCESSIBILITY.VIDEO_THUMBNAIL_HINT" = "Tap to play video"; "ACCESSIBILITY.VIDEO_PERCENTAGE_COMPLETED" = "%@ percent completed"; +"ACCESSIBILITY.VIDEO_NAVIGATION_COUNT" = "Video %@ from %@"; +"ACCESSIBILITY.VIDEO_NAVIGATION" = "Video Navigation"; + +"VIDEO_NAVIGATION.NO_VIDEO_TITLE" = "No Video Title"; + diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 14552266d..31c6dc09e 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -5352,6 +5352,22 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { return __value } + open func getAllVideosForNavigation(structure course: CourseStructure) throws -> [CourseBlock] { + addInvocation(.m_getAllVideosForNavigation__structure_course(Parameter.value(`course`))) + let perform = methodPerformValue(.m_getAllVideosForNavigation__structure_course(Parameter.value(`course`))) as? (CourseStructure) -> Void + perform?(`course`) + var __value: [CourseBlock] + do { + __value = try methodReturnValue(.m_getAllVideosForNavigation__structure_course(Parameter.value(`course`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getAllVideosForNavigation(structure course: CourseStructure). Use given") + Failure("Stub return value not specified for getAllVideosForNavigation(structure course: CourseStructure). Use given") + } catch { + throw error + } + return __value + } + fileprivate enum MethodType { case m_getCourseBlocks__courseID_courseID(Parameter) @@ -5372,6 +5388,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case m_enrichCourseStructureWithLocalProgress__structure(Parameter) case m_getCourseProgress__courseID_courseID(Parameter) case m_getCourseProgressOffline__courseID_courseID(Parameter) + case m_getAllVideosForNavigation__structure_course(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -5468,6 +5485,11 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) return Matcher.ComparisonResult(results) + + case (.m_getAllVideosForNavigation__structure_course(let lhsCourse), .m_getAllVideosForNavigation__structure_course(let rhsCourse)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourse, rhs: rhsCourse, with: matcher), lhsCourse, rhsCourse, "structure course")) + return Matcher.ComparisonResult(results) default: return .none } } @@ -5492,6 +5514,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case let .m_enrichCourseStructureWithLocalProgress__structure(p0): return p0.intValue case let .m_getCourseProgress__courseID_courseID(p0): return p0.intValue case let .m_getCourseProgressOffline__courseID_courseID(p0): return p0.intValue + case let .m_getAllVideosForNavigation__structure_course(p0): return p0.intValue } } func assertionName() -> String { @@ -5514,6 +5537,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case .m_enrichCourseStructureWithLocalProgress__structure: return ".enrichCourseStructureWithLocalProgress(_:)" case .m_getCourseProgress__courseID_courseID: return ".getCourseProgress(courseID:)" case .m_getCourseProgressOffline__courseID_courseID: return ".getCourseProgressOffline(courseID:)" + case .m_getAllVideosForNavigation__structure_course: return ".getAllVideosForNavigation(structure:)" } } } @@ -5572,6 +5596,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getCourseProgressOffline(courseID: Parameter, willReturn: CourseProgressDetails...) -> MethodStub { return Given(method: .m_getCourseProgressOffline__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func getAllVideosForNavigation(structure course: Parameter, willReturn: [CourseBlock]...) -> MethodStub { + return Given(method: .m_getAllVideosForNavigation__structure_course(`course`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func getCourseVideoBlocks(fullStructure: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [CourseStructure] = [] let given: Given = { return Given(method: .m_getCourseVideoBlocks__fullStructure_fullStructure(`fullStructure`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -5730,6 +5757,16 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { willProduce(stubber) return given } + public static func getAllVideosForNavigation(structure course: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getAllVideosForNavigation__structure_course(`course`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getAllVideosForNavigation(structure course: Parameter, willProduce: (StubberThrows<[CourseBlock]>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getAllVideosForNavigation__structure_course(`course`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: ([CourseBlock]).self) + willProduce(stubber) + return given + } } public struct Verify { @@ -5753,6 +5790,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func enrichCourseStructureWithLocalProgress(_ structure: Parameter) -> Verify { return Verify(method: .m_enrichCourseStructureWithLocalProgress__structure(`structure`))} public static func getCourseProgress(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseProgress__courseID_courseID(`courseID`))} public static func getCourseProgressOffline(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseProgressOffline__courseID_courseID(`courseID`))} + public static func getAllVideosForNavigation(structure course: Parameter) -> Verify { return Verify(method: .m_getAllVideosForNavigation__structure_course(`course`))} } public struct Perform { @@ -5813,6 +5851,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getCourseProgressOffline(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_getCourseProgressOffline__courseID_courseID(`courseID`), performs: perform) } + public static func getAllVideosForNavigation(structure course: Parameter, perform: @escaping (CourseStructure) -> Void) -> Perform { + return Perform(method: .m_getAllVideosForNavigation__structure_course(`course`), performs: perform) + } } public func given(_ method: Given) { diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index ae0ce7841..f5ad8d591 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -42,7 +42,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) -> Bool { initDI() initPlugins() - // Reset the value to false to get the actual status from the API if var storage = Container.shared.resolve(CoreStorage.self) { storage.updateAppRequired = false diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 697d238db..75dce720d 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -376,7 +376,17 @@ class ScreenAssembly: Assembly { container.register( CourseUnitViewModel.self - ) { @MainActor r, blockId, courseId, courseName, chapters, chapterIndex, sequentialIndex, verticalIndex in + ) { + @MainActor r, + blockId, + courseId, + courseName, + chapters, + chapterIndex, + sequentialIndex, + verticalIndex, + showVideoNavigation, + courseVideosStructure in CourseUnitViewModel( lessonID: blockId, courseID: courseId, @@ -391,7 +401,9 @@ class ScreenAssembly: Assembly { analytics: r.resolve(CourseAnalytics.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, storage: r.resolve(CourseStorage.self)!, - manager: r.resolve(DownloadManagerProtocol.self)! + manager: r.resolve(DownloadManagerProtocol.self)!, + showVideoNavigation: showVideoNavigation, + courseVideosStructure: courseVideosStructure ) } diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index fec8c1d01..093210c0a 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -319,6 +319,7 @@ public final class CoursePersistence: CoursePersistenceProtocol { } public func updateLocalVideoProgress(blockID: String, progress: Double) async { + let start = CFAbsoluteTimeGetCurrent() await container.performBackgroundTask { context in context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let request = CDCourseBlock.fetchRequest() diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 919eb253a..aac17b36d 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -483,7 +483,9 @@ public class Router: AuthorizationRouter, verticalIndex: Int, chapters: [CourseChapter], chapterIndex: Int, - sequentialIndex: Int + sequentialIndex: Int, + showVideoNavigation: Bool = false, + courseVideoStructure: CourseStructure? = nil ) { let controller = getUnitController( courseName: courseName, @@ -492,7 +494,9 @@ public class Router: AuthorizationRouter, verticalIndex: verticalIndex, chapters: chapters, chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex + sequentialIndex: sequentialIndex, + showVideoNavigation: showVideoNavigation, + courseVideoStructure: courseVideoStructure ) navigationController.pushViewController(controller, animated: true) } @@ -504,7 +508,9 @@ public class Router: AuthorizationRouter, verticalIndex: Int, chapters: [CourseChapter], chapterIndex: Int, - sequentialIndex: Int + sequentialIndex: Int, + showVideoNavigation: Bool = false, + courseVideoStructure: CourseStructure? = nil ) -> UIHostingController { let viewModel = Container.shared.resolve( CourseUnitViewModel.self, @@ -514,7 +520,9 @@ public class Router: AuthorizationRouter, chapters, chapterIndex, sequentialIndex, - verticalIndex + verticalIndex, + showVideoNavigation, + courseVideoStructure )! let config = Container.shared.resolve(ConfigProtocol.self) @@ -603,7 +611,9 @@ public class Router: AuthorizationRouter, chapters: [CourseChapter], chapterIndex: Int, sequentialIndex: Int, - animated: Bool + animated: Bool, + showVideoNavigation: Bool, + courseVideoStructure: CourseStructure? ) { let controllerUnit = getUnitController( @@ -613,14 +623,18 @@ public class Router: AuthorizationRouter, verticalIndex: verticalIndex, chapters: chapters, chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex + sequentialIndex: sequentialIndex, + showVideoNavigation: showVideoNavigation, + courseVideoStructure: courseVideoStructure ) var controllers = navigationController.viewControllers let config = Container.shared.resolve(ConfigProtocol.self)! let courseDropDownNavigationEnabled = config.uiComponents.courseDropDownNavigationEnabled - if courseDropDownNavigationEnabled || currentCourseTabSelection == CourseTab.dates.rawValue { + if courseDropDownNavigationEnabled + || currentCourseTabSelection == CourseTab.dates.rawValue + || showVideoNavigation { controllers.removeLast(1) controllers.append(contentsOf: [controllerUnit]) } else { diff --git a/Theme/Theme/Assets.xcassets/Colors/CourseCardBackground.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseCardBackground.colorset/Contents.json index 8fef18d07..48082378c 100644 --- a/Theme/Theme/Assets.xcassets/Colors/CourseCardBackground.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/CourseCardBackground.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" + "blue" : "0xFB", + "green" : "0xFA", + "red" : "0xF9" } }, "idiom" : "universal" diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index c75e566f5..0d4e370e5 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -1,6 +1,6 @@ API_HOST_URL: 'http://localhost:8000' SSO_URL: 'http://localhost:8000' -SSO_FINISHED_URL: 'http://localhost:8000' +SSO_FINISHED_URL: 'http://localhost:8000' ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: ''