From a050bf055e8b5cec75e6380df1ba2984d23b0ef1 Mon Sep 17 00:00:00 2001 From: Gene <76485998+eyatsenkoperpetio@users.noreply.github.com> Date: Thu, 1 Feb 2024 16:07:03 +0100 Subject: [PATCH] Course bars: Download videos to device and Select download quality bars (#239) * chore: add videos downloading bar view * chore: add view and logic * chore: add progress to download data * chore: downloads view * chore: add strings and fix delete video notif * chore: add total progress for bar * chore: changes for no nested list flow * fix: showing all downloaded item if open from current course * fix: bar progress * chore: add large file alert and show all downloads in download view * chore: change logic and remove extra code * refactor: remove extra code * chore: remove extra code * chore: add course storage, refactor * chore: add new video formats * chore: and video to course block * chore: add new core data models * chore: download manager to async await * chore: add calculate total size download bar * chore: hide total if zero * chore: clean up and fix transparent downloads bar when scroll * chore: add new logic check is downloadable video * chore: add download quality view * chore: add select quality for download * chore: show alert about change download quality when downloading all videos * chore: remove extra and add strings * chore: remove empty line * chore: add cancel all download when move to background * chore: add resume downloading * fix: remaining count in bar * chore: add new logic get quality video * chore: remove extra code * chore: improve download quality names * chore: new logic * chore: update logic * chore: remove extra code * chore: remove extra * chore: change strings * chore: add string * chore: show remaining files in download bar * chore: verticals blocks downloadable count nested list * chore: count of files * chore: add disable download when offline * chore: remove empty line * chore: show delete file when offline * chore: add Untitled title to download cell view * fix: tests * chore: PR issues and add accessibility labels * chore: add accessibility identifiers * chore: move video download quality view and refactor * chore: add cancel all for course * chore: show large alert * chore: resolve PR comments * chore: resolve PR comments * chore: resolve PR comments * fix: tests * chore: resolve PR comments * chore: resolve PR commnets * chore: rename DownloadData to DownloadDataTask * chore: use app alert and tests * chore: add confirmation alert when deleting video * chore: remove empty line * chore: add strings * fix: line length * fix: repeat download * chore: add alert when disable downloading * chore: resolve PR commnets * chore: update pod version --------- Co-authored-by: Anton Yarmolenko --- .../AuthorizationMock.generated.swift | 289 +++-- Core/Core.xcodeproj/project.pbxproj | 4 + Core/Core/Data/Model/UserSettings.swift | 19 +- .../CoreDataModel.xcdatamodel/contents | 4 +- .../Persistence/CorePersistenceProtocol.swift | 15 +- Core/Core/Domain/Model/CourseBlockModel.swift | 163 ++- Core/Core/Network/DownloadManager.swift | 547 ++++++--- Core/Core/SwiftGen/Strings.swift | 20 + Core/Core/View/Base/AlertView.swift | 8 +- .../ScrollSlidingTabBar.swift | 3 +- .../View/Base/VideoDownloadQualityView.swift | 166 +++ Core/Core/en.lproj/Localizable.strings | 10 + Core/Core/uk.lproj/Localizable.strings | 9 + Course/Course.xcodeproj/project.pbxproj | 95 +- Course/Course/Data/CourseRepository.swift | 1054 ++++++++++++++++- Course/Course/Data/CourseStorage.swift | 25 + .../Model/Data_CourseOutlineResponse.swift | 44 +- .../CourseCoreModel.xcdatamodel/contents | 15 +- .../Container/BaseCourseViewModel.swift | 9 - .../Container/CourseContainerView.swift | 5 +- .../Container/CourseContainerViewModel.swift | 207 +++- .../Downloads/DownloadsView.swift | 108 ++ .../Downloads/DownloadsViewModel.swift | 86 ++ .../Outline/ContinueWithView.swift | 26 +- .../Outline/CourseOutlineView.swift | 184 +-- .../CourseStructureNestedListView.swift} | 131 +- .../CourseStructure/CourseStructureView.swift | 134 +++ .../CourseVerticalImageView.swift | 15 +- .../CourseVerticalView.swift | 38 +- .../CourseVerticalViewModel.swift | 24 +- .../CourseVideoDownloadBarView.swift | 145 +++ .../CourseVideoDownloadBarViewModel.swift | 249 ++++ .../VideoDownloadQualityBarView.swift | 71 ++ .../VideoDownloadQualityContainerView.swift | 45 + .../Unit/CourseNavigationView.swift | 1 + .../Presentation/Unit/CourseUnitView.swift | 22 +- .../Unit/CourseUnitViewModel.swift | 21 +- .../DropdownList/CourseUnitDropDownCell.swift | 3 +- .../DropdownList/CourseUnitDropDownList.swift | 12 +- .../CourseUnitVerticalsDropdownView.swift | 15 +- Course/Course/SwiftGen/Strings.swift | 39 +- Course/Course/en.lproj/Localizable.strings | 20 +- Course/Course/uk.lproj/Localizable.strings | 22 +- Course/CourseTests/CourseMock.generated.swift | 289 +++-- .../CourseContainerViewModelTests.swift | 622 +++++++--- .../Unit/CourseUnitViewModelTests.swift | 23 +- .../DashboardMock.generated.swift | 289 +++-- .../DiscoveryWebviewViewModel.swift | 2 +- .../DiscoveryMock.generated.swift | 289 +++-- .../DiscussionMock.generated.swift | 289 +++-- OpenEdX/AppDelegate.swift | 4 +- OpenEdX/DI/AppAssembly.swift | 6 +- OpenEdX/DI/ScreenAssembly.swift | 2 + OpenEdX/Data/AppStorage.swift | 19 +- OpenEdX/Data/CorePersistence.swift | 183 +-- OpenEdX/Data/CoursePersistence.swift | 106 +- OpenEdX/Router.swift | 14 +- Podfile.lock | 2 +- Profile/Profile/Data/ProfileRepository.swift | 6 +- .../Profile/Presentation/ProfileRouter.swift | 14 +- .../Presentation/Settings/SettingsView.swift | 31 +- .../Settings/SettingsViewModel.swift | 27 +- Profile/Profile/en.lproj/Localizable.strings | 1 + .../ProfileTests/ProfileMock.generated.swift | 308 +++-- 64 files changed, 5432 insertions(+), 1216 deletions(-) create mode 100644 Core/Core/View/Base/VideoDownloadQualityView.swift create mode 100644 Course/Course/Data/CourseStorage.swift create mode 100644 Course/Course/Presentation/Downloads/DownloadsView.swift create mode 100644 Course/Course/Presentation/Downloads/DownloadsViewModel.swift rename Course/Course/Presentation/Outline/{CourseExpandableContentView.swift => CourseStructure/CourseStructureNestedListView.swift} (57%) create mode 100644 Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift rename Course/Course/Presentation/Outline/{ => CourseVertical}/CourseVerticalImageView.swift (90%) rename Course/Course/Presentation/Outline/{ => CourseVertical}/CourseVerticalView.swift (88%) rename Course/Course/Presentation/Outline/{ => CourseVertical}/CourseVerticalViewModel.swift (87%) create mode 100644 Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift create mode 100644 Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift create mode 100644 Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityBarView.swift create mode 100644 Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index c2bca8452..1ad5af6d4 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -1856,6 +1856,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { if scopes.contains(.perform) { methodPerformValues = [] } } + public var currentDownloadTask: DownloadDataTask? { + get { invocations.append(.p_currentDownloadTask_get); return __p_currentDownloadTask ?? optionalGivenGetterValue(.p_currentDownloadTask_get, "DownloadManagerProtocolMock - stub value for currentDownloadTask was not defined") } + } + private var __p_currentDownloadTask: (DownloadDataTask)? + @@ -1874,29 +1879,44 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func addToDownloadQueue(blocks: [CourseBlock]) throws { - addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) + open func eventPublisher() -> AnyPublisher { + addInvocation(.m_eventPublisher) + let perform = methodPerformValue(.m_eventPublisher) as? () -> Void + perform?() + var __value: AnyPublisher do { - _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void - } catch MockError.notStubed { - // do nothing + __value = try methodReturnValue(.m_eventPublisher).casted() } catch { - throw error + onFatalFailure("Stub return value not specified for eventPublisher(). Use given") + Failure("Stub return value not specified for eventPublisher(). Use given") + } + return __value + } + + open func getDownloadTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasks) + let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadTasks(). Use given") + Failure("Stub return value not specified for getDownloadTasks(). Use given") } + return __value } - open func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { - addInvocation(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) - let perform = methodPerformValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + open func getDownloadTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void perform?(`courseId`) - var __value: [DownloadData] + var __value: [DownloadDataTask] do { - __value = try methodReturnValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))).casted() + __value = try methodReturnValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))).casted() } catch { - onFatalFailure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") - Failure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + onFatalFailure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") } return __value } @@ -1914,12 +1934,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } - open func resumeDownloading() throws { - addInvocation(.m_resumeDownloading) - let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void - perform?() + open func cancelDownloading(task: DownloadDataTask) throws { + addInvocation(.m_cancelDownloading__task_task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_cancelDownloading__task_task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) do { - _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + _ = try methodReturnValue(.m_cancelDownloading__task_task(Parameter.value(`task`))).casted() as Void } catch MockError.notStubed { // do nothing } catch { @@ -1927,10 +1947,10 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } - open func pauseDownloading() { - addInvocation(.m_pauseDownloading) - let perform = methodPerformValue(.m_pauseDownloading) as? () -> Void - perform?() + open func cancelDownloading(courseId: String) { + addInvocation(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) } open func deleteFile(blocks: [CourseBlock]) { @@ -1958,28 +1978,72 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func isLargeVideosSize(blocks: [CourseBlock]) -> Bool { + addInvocation(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + Failure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + } + return __value + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + fileprivate enum MethodType { case m_publisher - case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) - case m_getDownloadsForCourse__courseId(Parameter) + case m_eventPublisher + case m_getDownloadTasks + case m_getDownloadTasksForCourse__courseId(Parameter) case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) - case m_resumeDownloading - case m_pauseDownloading + case m_cancelDownloading__task_task(Parameter) + case m_cancelDownloading__courseId_courseId(Parameter) case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_resumeDownloading + case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.m_publisher, .m_publisher): return .match - case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + case (.m_eventPublisher, .m_eventPublisher): return .match - case (.m_getDownloadsForCourse__courseId(let lhsCourseid), .m_getDownloadsForCourse__courseId(let rhsCourseid)): + case (.m_getDownloadTasks, .m_getDownloadTasks): return .match + + case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) return Matcher.ComparisonResult(results) @@ -1990,9 +2054,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) - case (.m_resumeDownloading, .m_resumeDownloading): return .match + case (.m_cancelDownloading__task_task(let lhsTask), .m_cancelDownloading__task_task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "task")) + return Matcher.ComparisonResult(results) - case (.m_pauseDownloading, .m_pauseDownloading): return .match + case (.m_cancelDownloading__courseId_courseId(let lhsCourseid), .m_cancelDownloading__courseId_courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + return Matcher.ComparisonResult(results) case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] @@ -2005,6 +2075,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) + + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_resumeDownloading, .m_resumeDownloading): return .match + case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } } @@ -2012,27 +2095,37 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { func intValue() -> Int { switch self { case .m_publisher: return 0 - case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue - case let .m_getDownloadsForCourse__courseId(p0): return p0.intValue + case .m_eventPublisher: return 0 + case .m_getDownloadTasks: return 0 + case let .m_getDownloadTasksForCourse__courseId(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue - case .m_resumeDownloading: return 0 - case .m_pauseDownloading: return 0 + case let .m_cancelDownloading__task_task(p0): return p0.intValue + case let .m_cancelDownloading__courseId_courseId(p0): return p0.intValue case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_resumeDownloading: return 0 + case .p_currentDownloadTask_get: return 0 } } func assertionName() -> String { switch self { case .m_publisher: return ".publisher()" - case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" - case .m_getDownloadsForCourse__courseId: return ".getDownloadsForCourse(_:)" + case .m_eventPublisher: return ".eventPublisher()" + case .m_getDownloadTasks: return ".getDownloadTasks()" + case .m_getDownloadTasksForCourse__courseId: return ".getDownloadTasksForCourse(_:)" case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" - case .m_resumeDownloading: return ".resumeDownloading()" - case .m_pauseDownloading: return ".pauseDownloading()" + case .m_cancelDownloading__task_task: return ".cancelDownloading(task:)" + case .m_cancelDownloading__courseId_courseId: return ".cancelDownloading(courseId:)" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } } @@ -2045,16 +2138,28 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { super.init(products) } + public static func currentDownloadTask(getter defaultValue: DownloadDataTask?...) -> PropertyStub { + return Given(method: .p_currentDownloadTask_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } public static func publisher(willReturn: AnyPublisher...) -> MethodStub { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getDownloadsForCourse(_ courseId: Parameter, willReturn: [DownloadData]...) -> MethodStub { - return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func eventPublisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { let willReturn: [AnyPublisher] = [] let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2062,10 +2167,24 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func getDownloadsForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadData]>) -> Void) -> MethodStub { - let willReturn: [[DownloadData]] = [] - let given: Given = { return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([DownloadData]).self) + public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) willProduce(stubber) return given } @@ -2076,13 +2195,10 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) willProduce(stubber) return given } @@ -2096,6 +2212,26 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func cancelDownloading(task: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(task: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } public static func resumeDownloading(willThrow: Error...) -> MethodStub { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) } @@ -2112,14 +2248,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { fileprivate var method: MethodType public static func publisher() -> Verify { return Verify(method: .m_publisher)} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} - public static func getDownloadsForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadsForCourse__courseId(`courseId`))} + public static func eventPublisher() -> Verify { return Verify(method: .m_eventPublisher)} + public static func getDownloadTasks() -> Verify { return Verify(method: .m_getDownloadTasks)} + public static func getDownloadTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadTasksForCourse__courseId(`courseId`))} public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} - public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} - public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} + public static func cancelDownloading(task: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__task_task(`task`))} + public static func cancelDownloading(courseId: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseId(`courseId`))} public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} + public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } public struct Perform { @@ -2129,20 +2270,23 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func publisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_publisher, performs: perform) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + public static func eventPublisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_eventPublisher, performs: perform) } - public static func getDownloadsForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_getDownloadsForCourse__courseId(`courseId`), performs: perform) + public static func getDownloadTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadTasks, performs: perform) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadTasksForCourse__courseId(`courseId`), performs: perform) } public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) } - public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_resumeDownloading, performs: perform) + public static func cancelDownloading(task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__task_task(`task`), performs: perform) } - public static func pauseDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_pauseDownloading, performs: perform) + public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) } public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) @@ -2153,6 +2297,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) + } + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) + } } public func given(_ method: Given) { diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 8b9dae2f0..1e7973e1c 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -126,6 +126,7 @@ 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30427D2B20B299009B64B7 /* SocialAuthError.swift */; }; + BA4AFB422B5A7A0900A21367 /* VideoDownloadQualityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */; }; BA593F1C2AF8E498009ADB51 /* ScrollSlidingTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */; }; BA593F1E2AF8E4A0009ADB51 /* FrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */; }; BA76135C2B21BC7300B599B7 /* SocialAuthResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */; }; @@ -295,6 +296,7 @@ 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 = ""; }; A53A32342B233DEC005FE38A /* ThemeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeConfig.swift; sourceTree = ""; }; BA30427D2B20B299009B64B7 /* SocialAuthError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; + BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityView.swift; sourceTree = ""; }; BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollSlidingTabBar.swift; sourceTree = ""; }; BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameReader.swift; sourceTree = ""; }; BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthResponse.swift; sourceTree = ""; }; @@ -673,6 +675,7 @@ 027BD3C42909707700392132 /* Shake.swift */, 023A1135291432B200D0D354 /* RegistrationTextField.swift */, 023A1137291432FD00D0D354 /* FieldConfiguration.swift */, + BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */, BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */, BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */, BA8FA6672AD59A5700EA029A /* SocialAuthButton.swift */, @@ -978,6 +981,7 @@ 0241666B28F5A78B00082765 /* HTMLFormattedText.swift in Sources */, 02D800CC29348F460099CF16 /* ImagePicker.swift in Sources */, 027BD3B92909476200392132 /* KeyboardAvoidingModifier.swift in Sources */, + BA4AFB422B5A7A0900A21367 /* VideoDownloadQualityView.swift in Sources */, 0770DE2C28D092B3006D8A5D /* NetworkLogger.swift in Sources */, 064987972B4D69FF0071642A /* WebView.swift in Sources */, 0770DE2A28D0929E006D8A5D /* HTTPTask.swift in Sources */, diff --git a/Core/Core/Data/Model/UserSettings.swift b/Core/Core/Data/Model/UserSettings.swift index b44c30449..ab113a74e 100644 --- a/Core/Core/Data/Model/UserSettings.swift +++ b/Core/Core/Data/Model/UserSettings.swift @@ -7,13 +7,19 @@ import Foundation -public struct UserSettings: Codable { +public struct UserSettings: Codable, Hashable { public var wifiOnly: Bool public var streamingQuality: StreamingQuality - - public init(wifiOnly: Bool, streamingQuality: StreamingQuality) { + public var downloadQuality: DownloadQuality + + public init( + wifiOnly: Bool, + streamingQuality: StreamingQuality, + downloadQuality: DownloadQuality + ) { self.wifiOnly = wifiOnly self.streamingQuality = streamingQuality + self.downloadQuality = downloadQuality } } @@ -23,3 +29,10 @@ public enum StreamingQuality: Codable { case medium case high } + +public enum DownloadQuality: Codable, CaseIterable { + case auto + case low_360 + case medium_540 + case high_720 +} diff --git a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 3dc8a8e03..cd49e4370 100644 --- a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,8 +1,10 @@ - + + + diff --git a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift index f250fc49c..3a577ec50 100644 --- a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift +++ b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift @@ -10,14 +10,15 @@ import Combine public protocol CorePersistenceProtocol { func publisher() -> AnyPublisher - func getAllDownloadData() -> [DownloadData] - func addToDownloadQueue(blocks: [CourseBlock]) - func getNextBlockForDownloading() -> DownloadData? - func getDownloadsForCourse(_ courseId: String) -> [DownloadData] - func downloadData(by blockId: String) -> DownloadData? + func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) + func getNextBlockForDownloading() -> DownloadDataTask? func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) - func deleteDownloadData(id: String) throws - func saveDownloadData(data: DownloadData) + func deleteDownloadDataTask(id: String) throws + func saveDownloadDataTask(data: DownloadDataTask) + func downloadDataTask(for blockId: String) -> DownloadDataTask? + func downloadDataTask(for blockId: String, completion: @escaping (DownloadDataTask?) -> Void) + func getDownloadDataTasks(completion: @escaping ([DownloadDataTask]) -> Void) + func getDownloadDataTasksForCourse(_ courseId: String, completion: @escaping ([DownloadDataTask]) -> Void) } public final class CoreBundle { diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index aeba9df44..20982d4af 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -8,6 +8,10 @@ import Foundation public struct CourseStructure: Equatable { + public static func == (lhs: CourseStructure, rhs: CourseStructure) -> Bool { + return lhs.id == rhs.id + } + public let id: String public let graded: Bool public let completion: Double @@ -42,11 +46,24 @@ public struct CourseStructure: Equatable { self.media = media self.certificate = certificate } - - public static func == (lhs: CourseStructure, rhs: CourseStructure) -> Bool { - return lhs.id == rhs.id + + public func totalVideosSizeInBytes(downloadQuality: DownloadQuality) -> Int { + childs.flatMap { + $0.childs.flatMap { $0.childs.flatMap { $0.childs.compactMap { $0 } } } + } + .filter { $0.isDownloadable } + .compactMap { $0.encodedVideo?.video(downloadQuality: downloadQuality)?.fileSize } + .reduce(.zero) { $0 + $1 } } - + + public func totalVideosSizeInMb(downloadQuality: DownloadQuality) -> Double { + Double(totalVideosSizeInBytes(downloadQuality: downloadQuality)) / 1024.0 / 1024.0 + } + + public func totalVideosSizeInGb(downloadQuality: DownloadQuality) -> Double { + Double(totalVideosSizeInBytes(downloadQuality: downloadQuality)) / 1024.0 / 1024.0 / 1024.0 + } + } public struct CourseChapter: Identifiable { @@ -102,7 +119,11 @@ public struct CourseSequential: Identifiable { } } -public struct CourseVertical { +public struct CourseVertical: Identifiable, Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + public let blockId: String public let id: String public let courseId: String @@ -114,7 +135,7 @@ public struct CourseVertical { public var isDownloadable: Bool { return childs.first(where: { $0.isDownloadable }) != nil } - + public init( blockId: String, id: String, @@ -144,7 +165,17 @@ public struct SubtitleUrl: Equatable { } } -public struct CourseBlock: Equatable { +public struct CourseBlock: Hashable { + public static func == (lhs: CourseBlock, rhs: CourseBlock) -> Bool { + lhs.id == rhs.id && + lhs.blockId == rhs.blockId && + lhs.completion == rhs.completion + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + public let blockId: String public let id: String public let courseId: String @@ -155,13 +186,12 @@ public struct CourseBlock: Equatable { public let displayName: String public let studentUrl: String public let subtitles: [SubtitleUrl]? - public let videoUrl: String? - public let youTubeUrl: String? - + public let encodedVideo: CourseBlockEncodedVideo? + public var isDownloadable: Bool { - return videoUrl != nil + encodedVideo?.isDownloadable ?? false } - + public init( blockId: String, id: String, @@ -173,8 +203,7 @@ public struct CourseBlock: Equatable { displayName: String, studentUrl: String, subtitles: [SubtitleUrl]? = nil, - videoUrl: String? = nil, - youTubeUrl: String? = nil + encodedVideo: CourseBlockEncodedVideo? ) { self.blockId = blockId self.id = id @@ -186,7 +215,109 @@ public struct CourseBlock: Equatable { self.displayName = displayName self.studentUrl = studentUrl self.subtitles = subtitles - self.videoUrl = videoUrl - self.youTubeUrl = youTubeUrl + self.encodedVideo = encodedVideo + } +} + +public struct CourseBlockEncodedVideo { + + public let fallback: CourseBlockVideo? + public let desktopMP4: CourseBlockVideo? + public let mobileHigh: CourseBlockVideo? + public let mobileLow: CourseBlockVideo? + public let hls: CourseBlockVideo? + public let youtube: CourseBlockVideo? + + public init( + fallback: CourseBlockVideo?, + youtube: CourseBlockVideo?, + desktopMP4: CourseBlockVideo?, + mobileHigh: CourseBlockVideo?, + mobileLow: CourseBlockVideo?, + hls: CourseBlockVideo? + ) { + self.fallback = fallback + self.youtube = youtube + self.desktopMP4 = desktopMP4 + self.mobileHigh = mobileHigh + self.mobileLow = mobileLow + self.hls = hls + } + + public var isDownloadable: Bool { + [hls, desktopMP4, mobileHigh, mobileLow, fallback] + .contains { $0?.isDownloadable == true } + } + + public func video(downloadQuality: DownloadQuality) -> CourseBlockVideo? { + switch downloadQuality { + case .auto: + [mobileLow, mobileHigh, desktopMP4, fallback, hls] + .first(where: { $0?.isDownloadable == true })? + .flatMap { $0 } + case .high_720: + [desktopMP4, mobileHigh, mobileLow, fallback, hls] + .first(where: { $0?.isDownloadable == true })? + .flatMap { $0 } + case .medium_540: + [mobileHigh, mobileLow, desktopMP4, fallback, hls] + .first(where: { $0?.isDownloadable == true })? + .flatMap { $0 } + case .low_360: + [mobileLow, mobileHigh, desktopMP4, fallback, hls] + .first(where: { $0?.isDownloadable == true })? + .flatMap { $0 } + } + } + + public func video(streamingQuality: StreamingQuality) -> CourseBlockVideo? { + switch streamingQuality { + case .auto: + [mobileLow, mobileHigh, desktopMP4, fallback, hls] + .compactMap { $0 } + .sorted(by: { ($0?.streamPriority ?? 0) < ($1?.streamPriority ?? 0) }) + .first? + .flatMap { $0 } + case .high: + [desktopMP4, mobileHigh, mobileLow, fallback, hls] + .compactMap { $0 } + .first? + .flatMap { $0 } + case .medium: + [mobileHigh, mobileLow, desktopMP4, fallback, hls] + .compactMap { $0 } + .first? + .flatMap { $0 } + case .low: + [mobileLow, mobileHigh, desktopMP4, fallback, hls] + .compactMap { $0 } + .first(where: { $0?.isDownloadable == true })? + .flatMap { $0 } + } + } + + public var youtubeVideoUrl: String? { + youtube?.url + } + +} + +public struct CourseBlockVideo: Equatable { + public let url: String? + public let fileSize: Int? + public let streamPriority: Int? + + public init(url: String?, fileSize: Int?, streamPriority: Int?) { + self.url = url + self.fileSize = fileSize + self.streamPriority = streamPriority + } + + public var isVideoURL: Bool { + [".mp4", ".m3u8"].contains(where: { url?.contains($0) == true }) + } + + public var isDownloadable: Bool { + [".mp4"].contains(where: { url?.contains($0) == true }) } } diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 774c767f0..4c2e6879f 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -12,42 +12,66 @@ import Combine public enum DownloadState: String { case waiting case inProgress - case paused case finished + + public var order: Int { + switch self { + case .inProgress: + 1 + case .waiting: + 2 + case .finished: + 3 + } + } } public enum DownloadType: String { case video } -public struct DownloadData { +public struct DownloadDataTask: Identifiable, Hashable { public let id: String public let courseId: String public let url: String public let fileName: String - public let progress: Double + public let displayName: String + public var progress: Double public let resumeData: Data? - public let state: DownloadState + public var state: DownloadState public let type: DownloadType - + public let fileSize: Int + + public var fileSizeInMb: Double { + Double(fileSize) / 1024.0 / 1024.0 + } + + public var fileSizeInMbText: String { + String(format: "%.2fMB", fileSizeInMb) + } + public init( id: String, courseId: String, url: String, fileName: String, + displayName: String, progress: Double, resumeData: Data?, state: DownloadState, - type: DownloadType + type: DownloadType, + fileSize: Int ) { self.id = id self.courseId = courseId self.url = url self.fileName = fileName + self.displayName = displayName self.progress = progress self.resumeData = resumeData self.state = state self.type = type + self.fileSize = fileSize } } @@ -57,26 +81,57 @@ public class NoWiFiError: LocalizedError { //sourcery: AutoMockable public protocol DownloadManagerProtocol { + var currentDownloadTask: DownloadDataTask? { get } func publisher() -> AnyPublisher + func eventPublisher() -> AnyPublisher + + func getDownloadTasks() async -> [DownloadDataTask] + func getDownloadTasksForCourse(_ courseId: String) async -> [DownloadDataTask] + func cancelDownloading(courseId: String, blocks: [CourseBlock]) async throws + func cancelDownloading(task: DownloadDataTask) async throws + func cancelDownloading(courseId: String) async throws + func deleteFile(blocks: [CourseBlock]) async + func deleteAllFiles() async + func fileUrl(for blockId: String) async -> URL? + func addToDownloadQueue(blocks: [CourseBlock]) throws - func getDownloadsForCourse(_ courseId: String) -> [DownloadData] - func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws + func isLargeVideosSize(blocks: [CourseBlock]) -> Bool func resumeDownloading() throws - func pauseDownloading() - func deleteFile(blocks: [CourseBlock]) - func deleteAllFiles() func fileUrl(for blockId: String) -> URL? } +public enum DownloadManagerEvent { + case added + case started(DownloadDataTask) + case progress(Double, DownloadDataTask) + case paused(DownloadDataTask) + case canceled(DownloadDataTask) + case finished(DownloadDataTask) + case courseCanceled(String) + case deletedFile(String) + case clearedAll +} + public class DownloadManager: DownloadManagerProtocol { - + + // MARK: - Properties + + public var currentDownloadTask: DownloadDataTask? private let persistence: CorePersistenceProtocol private let appStorage: CoreStorage private let connectivity: ConnectivityProtocol private var downloadRequest: DownloadRequest? - private var currentDownload: DownloadData? private var isDownloadingInProgress: Bool = false - + private var currentDownloadEventPublisher: PassthroughSubject = .init() + private let backgroundTaskProvider = BackgroundTaskProvider() + private var cancellables = Set() + + private var downloadQuality: DownloadQuality { + appStorage.userSettings?.downloadQuality ?? .auto + } + + // MARK: - Init + public init( persistence: CorePersistenceProtocol, appStorage: CoreStorage, @@ -85,146 +140,259 @@ public class DownloadManager: DownloadManagerProtocol { self.persistence = persistence self.appStorage = appStorage self.connectivity = connectivity + self.backgroundTask() + try? self.resumeDownloading() } - + + // MARK: - Publishers + public func publisher() -> AnyPublisher { - return persistence.publisher() + persistence.publisher() } - - public func addToDownloadQueue(blocks: [CourseBlock]) throws { - if userCanDownload() { - persistence.addToDownloadQueue(blocks: blocks) - guard !isDownloadingInProgress else { return } - try newDownload() - } else { - throw NoWiFiError() - } + + public func eventPublisher() -> AnyPublisher { + currentDownloadEventPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() } - - private func newDownload() throws { - if userCanDownload() { - guard let download = persistence.getNextBlockForDownloading() else { - isDownloadingInProgress = false - return + + // MARK: - Intents + + public func isLargeVideosSize(blocks: [CourseBlock]) -> Bool { + (blocks.reduce(0) { + $0 + Double($1.encodedVideo?.video(downloadQuality: downloadQuality)?.fileSize ?? 0) + } / 1024 / 1024 / 1024) > 1 + } + + public func getDownloadTasks() async -> [DownloadDataTask] { + await withCheckedContinuation { continuation in + persistence.getDownloadDataTasks { downloads in + continuation.resume(returning: downloads) } - currentDownload = download - try downloadFileWithProgress(download) - } else { - throw NoWiFiError() } } - - private func userCanDownload() -> Bool { - if appStorage.userSettings?.wifiOnly ?? true { - if !connectivity.isMobileData { - return true - } else { - return false + + public func getDownloadTasksForCourse(_ courseId: String) async -> [DownloadDataTask] { + await withCheckedContinuation { continuation in + persistence.getDownloadDataTasksForCourse(courseId) { downloads in + continuation.resume(returning: downloads) } + } + } + + public func addToDownloadQueue(blocks: [CourseBlock]) throws { + if userCanDownload() { + persistence.addToDownloadQueue( + blocks: blocks, + downloadQuality: downloadQuality + ) + currentDownloadEventPublisher.send(.added) + guard !isDownloadingInProgress else { return } + try newDownload() } else { - return true + throw NoWiFiError() } } - - public func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { - return persistence.getDownloadsForCourse(courseId) + + public func resumeDownloading() throws { + try newDownload() } - - public func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws { + + public func cancelDownloading(courseId: String, blocks: [CourseBlock]) async throws { downloadRequest?.cancel() - - let downloaded = getDownloadsForCourse(courseId).filter { $0.state == .finished } + + let downloaded = await getDownloadTasksForCourse(courseId).filter { $0.state == .finished } let blocksForDelete = blocks.filter { block in downloaded.first(where: { $0.id == block.id }) == nil } - - deleteFile(blocks: blocksForDelete) + + await deleteFile(blocks: blocksForDelete) + downloaded.forEach { + currentDownloadEventPublisher.send(.canceled($0)) + } try newDownload() } - - private func downloadFileWithProgress(_ download: DownloadData) throws { - if let url = URL(string: download.url) { - persistence.updateDownloadState( - id: download.id, - state: .inProgress, - resumeData: download.resumeData - ) - self.isDownloadingInProgress = true - if let resumeData = download.resumeData { - downloadRequest = AF.download(resumingWith: resumeData) - } else { - downloadRequest = AF.download(url) - } - #if DEBUG - downloadRequest?.downloadProgress { prog in - let completed = Double(prog.fractionCompleted * 100) - print(">>>>> Downloading", download.url, completed, "%") + + public func cancelDownloading(task: DownloadDataTask) async throws { + downloadRequest?.cancel() + do { + try persistence.deleteDownloadDataTask(id: task.id) + if let fileUrl = await fileUrl(for: task.id) { + try FileManager.default.removeItem(at: fileUrl) } - #endif - downloadRequest?.responseData(completionHandler: { [weak self] data in - guard let self else { return } - if let data = data.value, let url = self.videosFolderUrl() { - self.saveFile(fileName: download.fileName, data: data, folderURL: url) - self.persistence.updateDownloadState( - id: download.id, - state: .finished, - resumeData: nil - ) - try? self.newDownload() - } - }) + currentDownloadEventPublisher.send(.canceled(task)) + } catch { + NSLog("Error deleting file: \(error.localizedDescription)") } - } - - public func resumeDownloading() throws { try newDownload() } - - public func pauseDownloading() { - guard let currentDownload else { return } - downloadRequest?.cancel(byProducingResumeData: { resumeData in - self.persistence.updateDownloadState( - id: currentDownload.id, - state: .paused, - resumeData: resumeData - ) - }) + + public func cancelDownloading(courseId: String) async throws { + let downloads = await getDownloadTasksForCourse(courseId) + for downloadData in downloads { + do { + try persistence.deleteDownloadDataTask(id: downloadData.id) + if let fileUrl = await fileUrl(for: downloadData.id) { + try FileManager.default.removeItem(at: fileUrl) + } + } catch { + debugLog("Error deleting file: \(error.localizedDescription)") + } + } + currentDownloadEventPublisher.send(.courseCanceled(courseId)) + downloadRequest?.cancel() + try newDownload() } - - public func deleteFile(blocks: [CourseBlock]) { + + public func deleteFile(blocks: [CourseBlock]) async { for block in blocks { do { - try persistence.deleteDownloadData(id: block.id) - if let fileUrl = fileUrl(for: block.id) { - try FileManager.default.removeItem(at: fileUrl) + try persistence.deleteDownloadDataTask(id: block.id) + currentDownloadEventPublisher.send(.deletedFile(block.id)) + if let fileURL = await fileUrl(for: block.id) { + try FileManager.default.removeItem(at: fileURL) } } catch { - NSLog("Error deleting file: \(error.localizedDescription)") + debugLog("Error deleting file: \(error.localizedDescription)") } } } - - public func deleteAllFiles() { - let downloadData = persistence.getAllDownloadData() - downloadData.forEach { - if let fileURL = fileUrl(for: $0.id) { + + public func deleteAllFiles() async { + let downloadsData = await getDownloadTasks() + for downloadData in downloadsData { + if let fileURL = await fileUrl(for: downloadData.id) { do { try FileManager.default.removeItem(at: fileURL) } catch { - NSLog("Error deleting All files: \(error.localizedDescription)") + debugLog("Error deleting All files: \(error.localizedDescription)") } } } + currentDownloadEventPublisher.send(.clearedAll) } - + + public func fileUrl(for blockId: String) async -> URL? { + await withCheckedContinuation { continuation in + persistence.downloadDataTask(for: blockId) { [weak self] data in + guard let data = data, data.url.count > 0, data.state == .finished else { + continuation.resume(returning: nil) + return + } + let path = self?.videosFolderUrl + let fileName = data.fileName + continuation.resume(returning: path?.appendingPathComponent(fileName)) + } + } + } + public func fileUrl(for blockId: String) -> URL? { - guard let data = persistence.downloadData(by: blockId), + guard let data = persistence.downloadDataTask(for: blockId), data.url.count > 0, data.state == .finished else { return nil } - let path = videosFolderUrl() + let path = videosFolderUrl let fileName = data.fileName return path?.appendingPathComponent(fileName) } - - private func videosFolderUrl() -> URL? { + + private func newDownload() throws { + guard userCanDownload() else { + throw NoWiFiError() + } + guard let downloadTask = persistence.getNextBlockForDownloading() else { + isDownloadingInProgress = false + return + } + currentDownloadTask = downloadTask + try downloadFileWithProgress(downloadTask) + currentDownloadEventPublisher.send(.started(downloadTask)) + } + + private func userCanDownload() -> Bool { + if appStorage.userSettings?.wifiOnly ?? true { + if !connectivity.isMobileData { + return true + } else { + return false + } + } else { + return true + } + } + + private func downloadFileWithProgress(_ download: DownloadDataTask) throws { + guard let url = URL(string: download.url) else { + return + } + + persistence.updateDownloadState( + id: download.id, + state: .inProgress, + resumeData: download.resumeData + ) + self.isDownloadingInProgress = true + if let resumeData = download.resumeData { + downloadRequest = AF.download(resumingWith: resumeData) + } else { + downloadRequest = AF.download(url) + } + + downloadRequest?.downloadProgress { [weak self] prog in + guard let self else { return } + let fractionCompleted = prog.fractionCompleted + self.currentDownloadTask?.progress = fractionCompleted + self.currentDownloadTask?.state = .inProgress + self.currentDownloadEventPublisher.send(.progress(fractionCompleted, download)) + let completed = Double(fractionCompleted * 100) + debugLog(">>>>> Downloading", download.url, completed, "%") + } + + downloadRequest?.responseData { [weak self] data in + guard let self else { return } + if let data = data.value, let url = self.videosFolderUrl { + self.saveFile(fileName: download.fileName, data: data, folderURL: url) + self.persistence.updateDownloadState( + id: download.id, + state: .finished, + resumeData: nil + ) + self.currentDownloadTask?.state = .finished + self.currentDownloadEventPublisher.send(.finished(download)) + try? self.newDownload() + } + } + } + + private func waitingAll() { + persistence.getDownloadDataTasks { [weak self] tasks in + guard let self else { return } + Task { + for task in tasks.filter({ $0.state == .inProgress }) { + self.persistence.updateDownloadState( + id: task.id, + state: .waiting, + resumeData: nil + ) + self.currentDownloadEventPublisher.send(.added) + } + self.downloadRequest?.cancel() + } + } + } + + // MARK: - Private Intents + + private func backgroundTask() { + backgroundTaskProvider.eventPublisher() + .sink { [weak self] state in + guard let self else { return } + switch state { + case.didBecomeActive: try? self.resumeDownloading() + case .didEnterBackground: self.waitingAll() + } + } + .store(in: &cancellables) + } + + lazy var videosFolderUrl: URL? = { let documentDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let directoryURL = documentDirectoryURL.appendingPathComponent("Files", isDirectory: true) @@ -239,18 +407,96 @@ public class DownloadManager: DownloadManagerProtocol { ) return URL(fileURLWithPath: directoryURL.path) } catch { - print(error.localizedDescription) + debugLog(error.localizedDescription) return nil } } - } - + }() + private func saveFile(fileName: String, data: Data, folderURL: URL) { let fileURL = folderURL.appendingPathComponent(fileName) do { try data.write(to: fileURL) } catch { - NSLog("SaveFile Error", error.localizedDescription) + debugLog("SaveFile Error", error.localizedDescription) + } + } +} + +@available(iOSApplicationExtension, unavailable) +public final class BackgroundTaskProvider { + + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + private var currentEventPublisher: PassthroughSubject = .init() + + public enum Events { + case didBecomeActive + case didEnterBackground + } + + public func eventPublisher() -> AnyPublisher { + currentEventPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + // MARK: - Init - + + deinit { + NotificationCenter.default.removeObserver( + self, + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + NotificationCenter.default.removeObserver( + self, + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + + public init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(didEnterBackgroundNotification), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(didBecomeActiveNotification), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + + @objc + func didEnterBackgroundNotification() { + registerBackgroundTask() + currentEventPublisher.send(.didEnterBackground) + } + + @objc + func didBecomeActiveNotification() { + endBackgroundTaskIfActive() + currentEventPublisher.send(.didBecomeActive) + } + + // MARK: - Background Task - + + private func registerBackgroundTask() { + backgroundTask = UIApplication.shared.beginBackgroundTask { [weak self] in + debugLog("iOS has signaled time has expired") + self?.endBackgroundTaskIfActive() + } + } + + private func endBackgroundTaskIfActive() { + let isBackgroundTaskActive = backgroundTask != .invalid + if isBackgroundTaskActive { + debugLog("Background task ended.") + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid } } } @@ -258,32 +504,65 @@ public class DownloadManager: DownloadManagerProtocol { // Mark - For testing and SwiftUI preview #if DEBUG public class DownloadManagerMock: DownloadManagerProtocol { - + public init() { } - + + public var currentDownloadTask: DownloadDataTask? { + return nil + } + public func publisher() -> AnyPublisher { return Just(1).eraseToAnyPublisher() } - + + public func eventPublisher() -> AnyPublisher { + return Just( + .canceled( + .init( + id: "", + courseId: "", + url: "", + fileName: "", + displayName: "", + progress: 1, + resumeData: nil, + state: .inProgress, + type: .video, + fileSize: 0 + ) + ) + ).eraseToAnyPublisher() + } + public func addToDownloadQueue(blocks: [CourseBlock]) { } - - public func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { - return [] + + public func getDownloadTasks() -> [DownloadDataTask] { + [] } - - public func cancelDownloading(courseId: String, blocks: [CourseBlock]) { - + + public func getDownloadTasksForCourse(_ courseId: String) async -> [DownloadDataTask] { + await withCheckedContinuation { continuation in + continuation.resume(returning: []) + } } - - public func resumeDownloading() { - + + public func cancelDownloading(courseId: String, blocks: [CourseBlock]) async throws { + } - - public func pauseDownloading() { + + public func cancelDownloading(task: DownloadDataTask) { + + } + + public func cancelDownloading(courseId: String) async { + + } + + public func resumeDownloading() { } @@ -298,6 +577,10 @@ public class DownloadManagerMock: DownloadManagerProtocol { public func fileUrl(for blockId: String) -> URL? { return nil } - + + public func isLargeVideosSize(blocks: [CourseBlock]) -> Bool { + false + } + } #endif diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index dad529b53..8a4236511 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -19,6 +19,8 @@ public enum CoreLocalization { public static let accept = CoreLocalization.tr("Localizable", "ALERT.ACCEPT", fallback: "ACCEPT") /// CANCEL public static let cancel = CoreLocalization.tr("Localizable", "ALERT.CANCEL", fallback: "CANCEL") + /// DELETE + public static let delete = CoreLocalization.tr("Localizable", "ALERT.DELETE", fallback: "DELETE") /// Keep editing public static let keepEditing = CoreLocalization.tr("Localizable", "ALERT.KEEP_EDITING", fallback: "Keep editing") /// Leave @@ -185,6 +187,24 @@ public enum CoreLocalization { public static let title = CoreLocalization.tr("Localizable", "REVIEW.EMAIL.TITLE", fallback: "Select email client:") } } + public enum Settings { + /// Lower data usage + public static let downloadQuality360Description = CoreLocalization.tr("Localizable", "SETTINGS.DOWNLOAD_QUALITY_360_DESCRIPTION", fallback: "Lower data usage") + /// 360p + public static let downloadQuality360Title = CoreLocalization.tr("Localizable", "SETTINGS.DOWNLOAD_QUALITY_360_TITLE", fallback: "360p") + /// 540p + public static let downloadQuality540Title = CoreLocalization.tr("Localizable", "SETTINGS.DOWNLOAD_QUALITY_540_TITLE", fallback: "540p") + /// Best quality + public static let downloadQuality720Description = CoreLocalization.tr("Localizable", "SETTINGS.DOWNLOAD_QUALITY_720_DESCRIPTION", fallback: "Best quality") + /// 720p + public static let downloadQuality720Title = CoreLocalization.tr("Localizable", "SETTINGS.DOWNLOAD_QUALITY_720_TITLE", fallback: "720p") + /// Recommended + public static let downloadQualityAutoDescription = CoreLocalization.tr("Localizable", "SETTINGS.DOWNLOAD_QUALITY_AUTO_DESCRIPTION", fallback: "Recommended") + /// Auto + public static let downloadQualityAutoTitle = CoreLocalization.tr("Localizable", "SETTINGS.DOWNLOAD_QUALITY_AUTO_TITLE", fallback: "Auto") + /// Video download quality + public static let videoDownloadQualityTitle = CoreLocalization.tr("Localizable", "SETTINGS.VIDEO_DOWNLOAD_QUALITY_TITLE", fallback: "Video download quality") + } public enum SignIn { /// Sign in public static let logInBtn = CoreLocalization.tr("Localizable", "SIGN_IN.LOG_IN_BTN", fallback: "Sign in") diff --git a/Core/Core/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index a578eefc7..ad501bdb9 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -9,7 +9,7 @@ import SwiftUI import Theme public enum AlertViewType: Equatable { - case `default`(positiveAction: String) + case `default`(positiveAction: String, image: SwiftUI.Image?) case action(String, SwiftUI.Image) case logOut case leaveProfile @@ -110,6 +110,9 @@ public struct AlertView: View { if case let .action(_, image) = type { image.padding(.top, 48) } + if case let .default(_, image) = type { + image.flatMap { $0.padding(.top, 48) } + } Text(alertTitle) .font(Theme.Fonts.titleLarge) .padding(.horizontal, 40) @@ -151,7 +154,7 @@ public struct AlertView: View { } HStack { switch type { - case let .`default`(positiveAction): + case let .`default`(positiveAction, _): HStack { StyledButton(positiveAction, action: { okTapped() }) .frame(maxWidth: 135) @@ -161,6 +164,7 @@ public struct AlertView: View { } .padding(.leading, 10) .padding(.trailing, 10) + .padding(.bottom, 10) case let .action(action, _): if !isHorizontal { VStack(spacing: 20) { diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift index 994975cfa..0c89f0811 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift @@ -25,8 +25,7 @@ public struct ScrollSlidingTabBar: View { selection: Binding, tabs: [String], style: Style = .default, - onTap: ((Int) -> Void)? = nil) - { + onTap: ((Int) -> Void)? = nil) { self._selection = selection self.tabs = tabs self.style = style diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift new file mode 100644 index 000000000..c6e4a6148 --- /dev/null +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -0,0 +1,166 @@ +// +// VideoDownloadQualityView.swift +// Core +// +// Created by Eugene Yatsenko on 19.01.2024. +// + +import SwiftUI +import Kingfisher +import Theme + +public final class VideoDownloadQualityViewModel: ObservableObject { + + var didSelect: ((DownloadQuality) -> Void)? + let downloadQuality = DownloadQuality.allCases + + @Published var selectedDownloadQuality: DownloadQuality { + willSet { + if newValue != selectedDownloadQuality { + didSelect?(newValue) + } + } + } + + public init(downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?) { + self.selectedDownloadQuality = downloadQuality + self.didSelect = didSelect + } +} + +public struct VideoDownloadQualityView: View { + + @StateObject + private var viewModel: VideoDownloadQualityViewModel + + public init( + downloadQuality: DownloadQuality, + didSelect: ((DownloadQuality) -> Void)? + ) { + self._viewModel = StateObject( + wrappedValue: .init( + downloadQuality: downloadQuality, + didSelect: didSelect + ) + ) + } + + public var body: some View { + ZStack(alignment: .top) { + // MARK: - Page Body + ScrollView { + VStack(alignment: .leading, spacing: 24) { + ForEach(viewModel.downloadQuality, id: \.self) { quality in + Button { + viewModel.selectedDownloadQuality = quality + } label: { + HStack { + SettingsCell( + title: quality.title, + description: quality.description + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(quality.title) \(quality.description ?? "")") + Spacer() + CoreAssets.checkmark.swiftUIImage + .renderingMode(.template) + .foregroundColor(.accentColor) + .opacity(quality == viewModel.selectedDownloadQuality ? 1 : 0) + .accessibilityIdentifier("checkmark_image") + + } + .foregroundColor(Theme.Colors.textPrimary) + } + .accessibilityIdentifier("quality_button_cell") + Divider() + } + } + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading + ) + .padding(.horizontal, 24) + } + .frameLimit(sizePortrait: 420) + .padding(.top, 8) + } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(CoreLocalization.Settings.videoDownloadQualityTitle) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + } +} + +public struct SettingsCell: View { + + private var title: String + private var description: String? + + public init(title: String, description: String?) { + self.title = title + self.description = description + } + + public var body: some View { + VStack(alignment: .leading) { + Text(title) + .font(Theme.Fonts.titleMedium) + if let description { + Text(description) + .font(Theme.Fonts.labelMedium) + .foregroundColor(Theme.Colors.textSecondary) + } + }.foregroundColor(Theme.Colors.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +public extension DownloadQuality { + + var title: String { + switch self { + case .auto: + return CoreLocalization.Settings.downloadQualityAutoTitle + case .low_360: + return CoreLocalization.Settings.downloadQuality360Title + case .medium_540: + return CoreLocalization.Settings.downloadQuality540Title + case .high_720: + return CoreLocalization.Settings.downloadQuality720Title + } + } + + var description: String? { + switch self { + case .auto: + return CoreLocalization.Settings.downloadQualityAutoDescription + case .low_360: + return CoreLocalization.Settings.downloadQuality360Description + case .medium_540: + return nil + case .high_720: + return CoreLocalization.Settings.downloadQuality720Description + } + } + + var settingsDescription: String { + switch self { + case .auto: + return CoreLocalization.Settings.downloadQualityAutoTitle + " (" + + CoreLocalization.Settings.downloadQualityAutoDescription + ")" + case .low_360: + return CoreLocalization.Settings.downloadQuality360Title + " (" + + CoreLocalization.Settings.downloadQuality360Description + ")" + case .medium_540: + return CoreLocalization.Settings.downloadQuality540Title + case .high_720: + return CoreLocalization.Settings.downloadQuality720Title + " (" + + CoreLocalization.Settings.downloadQuality720Description + ")" + } + } +} + diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index a132e9445..2c982c851 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -51,6 +51,7 @@ "ALERT.LOGOUT" = "Log out"; "ALERT.LEAVE" = "Leave"; "ALERT.KEEP_EDITING" = "Keep editing"; +"ALERT.DELETE" = "DELETE"; "NO_INTERNET.OFFLINE" = "Offline"; "NO_INTERNET.DISMISS" = "Dismiss"; @@ -63,6 +64,15 @@ "DOWNLOAD_MANAGER.DOWNLOADED" = "Downloaded"; "DOWNLOAD_MANAGER.COMPLETED" = "Completed"; +"SETTINGS.VIDEO_DOWNLOAD_QUALITY_TITLE" = "Video download quality"; +"SETTINGS.DOWNLOAD_QUALITY_AUTO_TITLE" = "Auto"; +"SETTINGS.DOWNLOAD_QUALITY_AUTO_DESCRIPTION" = "Recommended"; +"SETTINGS.DOWNLOAD_QUALITY_360_TITLE" = "360p"; +"SETTINGS.DOWNLOAD_QUALITY_360_DESCRIPTION" = "Lower data usage"; +"SETTINGS.DOWNLOAD_QUALITY_540_TITLE" = "540p"; +"SETTINGS.DOWNLOAD_QUALITY_720_TITLE" = "720p"; +"SETTINGS.DOWNLOAD_QUALITY_720_DESCRIPTION" = "Best quality"; + "DONE" = "Done"; "PICKER.SEARCH" = "Search"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index f5763d308..1f0ecb198 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -62,6 +62,15 @@ "DOWNLOAD_MANAGER.DOWNLOADED" = "Скачано"; "DOWNLOAD_MANAGER.COMPLETED" = "Завершено"; +"SETTINGS.VIDEO_DOWNLOAD_QUALITY_TITLE" = "Video download quality"; +"SETTINGS.DOWNLOAD_QUALITY_AUTO_TITLE" = "Auto"; +"SETTINGS.DOWNLOAD_QUALITY_AUTO_DESCRIPTION" = "Recommended"; +"SETTINGS.DOWNLOAD_QUALITY_360_TITLE" = "360p"; +"SETTINGS.DOWNLOAD_QUALITY_360_DESCRIPTION" = "Lower data usage"; +"SETTINGS.DOWNLOAD_QUALITY_540_TITLE" = "540p"; +"SETTINGS.DOWNLOAD_QUALITY_720_TITLE" = "720p"; +"SETTINGS.DOWNLOAD_QUALITY_720_DESCRIPTION" = "Best quality"; + "DONE" = "Зберегти"; "PICKER.SEARCH" = "Знайти"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 38f92443f..3ef12c3f2 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -67,7 +67,15 @@ 975F475E2B6151FD00E5B031 /* CourseDatesMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 975F475D2B6151FD00E5B031 /* CourseDatesMock.swift */; }; 975F47602B615DA700E5B031 /* CourseStructureMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 975F475F2B615DA700E5B031 /* CourseStructureMock.swift */; }; 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 */; }; + 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 */; }; + 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 */; }; DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */; }; @@ -163,7 +171,15 @@ 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 = ""; }; + BA58CF5C2B3D804D005B102E /* CourseStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStorage.swift; sourceTree = ""; }; + BA58CF602B471041005B102E /* VideoDownloadQualityBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityBarView.swift; sourceTree = ""; }; + BA58CF632B471363005B102E /* VideoDownloadQualityContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityContainerView.swift; sourceTree = ""; }; + BAAD62C72AFD00EE000E6103 /* CourseStructureNestedListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStructureNestedListView.swift; sourceTree = ""; }; + BAC0E0D72B32EF03006B68A9 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; + BAC0E0DA2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseVideoDownloadBarViewModel.swift; sourceTree = ""; }; + BAC0E0DD2B32F0F3006B68A9 /* DownloadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsViewModel.swift; sourceTree = ""; }; + BAD9CA432B2C87A200DE790A /* CourseStructureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStructureView.swift; sourceTree = ""; }; + BAD9CA492B2C88E000DE790A /* CourseVideoDownloadBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseVideoDownloadBarView.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 = ""; }; @@ -292,6 +308,7 @@ 02B6B3B928E1D13500232911 /* Network */, 975F475C2B61517A00E5B031 /* Mock */, 02B6B3BB28E1D14F00232911 /* CourseRepository.swift */, + BA58CF5C2B3D804D005B102E /* CourseStorage.swift */, ); path = Data; sourceTree = ""; @@ -346,6 +363,8 @@ 02DFC65029ACC20A00EA4BB9 /* Handouts */, 070019A928F6F59D00D5FC78 /* Unit */, 070019AA28F6F79E00D5FC78 /* Video */, + BAC0E0DC2B32F0EA006B68A9 /* Downloads */, + BAD9CA482B2C88D500DE790A /* Subviews */, 02F3BFDC29252E900051930C /* CourseRouter.swift */, 02F175362A4DAFD20019CD70 /* CourseAnalytics.swift */, ); @@ -378,12 +397,10 @@ 070019A728F6F2D600D5FC78 /* Outline */ = { isa = PBXGroup; children = ( + BAD9CA462B2C888600DE790A /* CourseVertical */, + BAD9CA472B2C88AA00DE790A /* CourseStructure */, 02635AC62A24F181008062F2 /* ContinueWithView.swift */, 0270210128E736E700F54332 /* CourseOutlineView.swift */, - 02A8076729474831007F53AB /* CourseVerticalView.swift */, - 0248C92629C097EB00DC8402 /* CourseVerticalViewModel.swift */, - BAAD62C72AFD00EE000E6103 /* CourseExpandableContentView.swift */, - 06FD7EDE2B1F29F3008D632B /* CourseVerticalImageView.swift */, ); path = Outline; sourceTree = ""; @@ -451,6 +468,61 @@ path = Unit; sourceTree = ""; }; + BA58CF622B471047005B102E /* VideoDownloadQualityBarView */ = { + isa = PBXGroup; + children = ( + BA58CF602B471041005B102E /* VideoDownloadQualityBarView.swift */, + BA58CF632B471363005B102E /* VideoDownloadQualityContainerView.swift */, + ); + path = VideoDownloadQualityBarView; + sourceTree = ""; + }; + BAC0E0D92B32F0A2006B68A9 /* CourseVideoDownloadBarView */ = { + isa = PBXGroup; + children = ( + BAD9CA492B2C88E000DE790A /* CourseVideoDownloadBarView.swift */, + BAC0E0DA2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift */, + ); + path = CourseVideoDownloadBarView; + sourceTree = ""; + }; + BAC0E0DC2B32F0EA006B68A9 /* Downloads */ = { + isa = PBXGroup; + children = ( + BAC0E0D72B32EF03006B68A9 /* DownloadsView.swift */, + BAC0E0DD2B32F0F3006B68A9 /* DownloadsViewModel.swift */, + ); + path = Downloads; + sourceTree = ""; + }; + BAD9CA462B2C888600DE790A /* CourseVertical */ = { + isa = PBXGroup; + children = ( + 06FD7EDE2B1F29F3008D632B /* CourseVerticalImageView.swift */, + 02A8076729474831007F53AB /* CourseVerticalView.swift */, + 0248C92629C097EB00DC8402 /* CourseVerticalViewModel.swift */, + ); + path = CourseVertical; + sourceTree = ""; + }; + BAD9CA472B2C88AA00DE790A /* CourseStructure */ = { + isa = PBXGroup; + children = ( + BAD9CA432B2C87A200DE790A /* CourseStructureView.swift */, + BAAD62C72AFD00EE000E6103 /* CourseStructureNestedListView.swift */, + ); + path = CourseStructure; + sourceTree = ""; + }; + BAD9CA482B2C88D500DE790A /* Subviews */ = { + isa = PBXGroup; + children = ( + BAC0E0D92B32F0A2006B68A9 /* CourseVideoDownloadBarView */, + BA58CF622B471047005B102E /* VideoDownloadQualityBarView */, + ); + path = Subviews; + sourceTree = ""; + }; 975F475C2B61517A00E5B031 /* Mock */ = { isa = PBXGroup; children = ( @@ -716,13 +788,16 @@ 022C64DA29ACEC50000F532B /* HandoutsViewModel.swift in Sources */, 02635AC72A24F181008062F2 /* ContinueWithView.swift in Sources */, 068DDA622B1E198700FF8CCB /* CourseUnitDropDownTitle.swift in Sources */, + BAD9CA4A2B2C88E000DE790A /* CourseVideoDownloadBarView.swift in Sources */, 022C64DE29AD167A000F532B /* HandoutsUpdatesDetailView.swift in Sources */, + BA58CF612B471041005B102E /* VideoDownloadQualityBarView.swift in Sources */, 0270210328E736E700F54332 /* CourseOutlineView.swift in Sources */, 068DDA602B1E198700FF8CCB /* CourseUnitVerticalsDropdownView.swift in Sources */, 022C64E029ADEA9B000F532B /* Data_UpdatesResponse.swift in Sources */, 02454CA02A2618E70043052A /* YouTubeView.swift in Sources */, 02454CA22A26190A0043052A /* EncodedVideoView.swift in Sources */, 068DDA612B1E198700FF8CCB /* CourseUnitDropDownCell.swift in Sources */, + BAC0E0DE2B32F0F3006B68A9 /* DownloadsViewModel.swift in Sources */, 02B6B3BC28E1D14F00232911 /* CourseRepository.swift in Sources */, 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */, 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */, @@ -737,27 +812,33 @@ 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 /* CourseExpandableContentView.swift in Sources */, + BAAD62C82AFD00EE000E6103 /* CourseStructureNestedListView.swift in Sources */, 02A8076829474831007F53AB /* CourseVerticalView.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */, 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */, 06FD7EDF2B1F29F3008D632B /* CourseVerticalImageView.swift in Sources */, + BAC0E0DB2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift in Sources */, DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */, 02F066E829DC71750073E13B /* SubtittlesView.swift in Sources */, 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */, + BA58CF642B471363005B102E /* VideoDownloadQualityContainerView.swift in Sources */, + BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */, 02454CA62A26196C0043052A /* UnknownView.swift in Sources */, 0766DFD0299AB29000EBEF6A /* PlayerViewController.swift in Sources */, 022C64DC29ACFDEE000F532B /* Data_HandoutsResponse.swift in Sources */, 022C64D829ACEC48000F532B /* HandoutsView.swift in Sources */, 02454CA82A2619890043052A /* DiscussionView.swift in Sources */, 0265B4B728E2141D00E6EAFD /* Strings.swift in Sources */, + 02B6B3C128E1DBA100232911 /* Data_CourseDetailsResponse.swift in Sources */, + BAC0E0D82B32EF03006B68A9 /* DownloadsView.swift in Sources */, DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */, 0766DFCC299AA7A600EBEF6A /* YouTubeVideoPlayer.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index df55aec12..d5f540a0d 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -201,14 +201,31 @@ public class CourseRepository: CourseRepositoryProtocol { displayName: block.displayName, studentUrl: block.studentUrl, subtitles: subtitles, - videoUrl: block.userViewData?.encodedVideo?.fallback?.url, - youTubeUrl: block.userViewData?.encodedVideo?.youTube?.url + encodedVideo: .init( + fallback: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.fallback), + youtube: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.youTube), + desktopMP4: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.desktopMP4), + mobileHigh: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.mobileHigh), + mobileLow: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.mobileLow), + hls: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.hls) + ) ) } + private func parseVideo(encodedVideo: DataLayer.EncodedVideoData?) -> CourseBlockVideo? { + guard let encodedVideo else { + return nil + } + return .init( + url: encodedVideo.url, + fileSize: encodedVideo.fileSize, + streamPriority: encodedVideo.streamPriority + ) + } } // Mark - For testing and SwiftUI preview +// swiftlint:disable all #if DEBUG class CourseRepositoryMock: CourseRepositoryProtocol { func getCourseDatesOffline(courseID: String) async throws -> CourseDates { @@ -366,18 +383,1027 @@ And there are various ways of describing it-- call it oral poetry or return SubtitleUrl(language: $0.key, url: url) } - return CourseBlock(blockId: block.blockId, - id: block.id, - courseId: courseId, - topicId: block.userViewData?.topicID, - graded: block.graded, - completion: block.completion ?? 0, - type: BlockType(rawValue: block.type) ?? .unknown, - displayName: block.displayName, - studentUrl: block.studentUrl, - subtitles: subtitles, - videoUrl: block.userViewData?.encodedVideo?.fallback?.url, - youTubeUrl: block.userViewData?.encodedVideo?.youTube?.url) + return CourseBlock( + blockId: block.blockId, + id: block.id, + courseId: courseId, + topicId: block.userViewData?.topicID, + graded: block.graded, + completion: block.completion ?? 0, + type: BlockType(rawValue: block.type) ?? .unknown, + displayName: block.displayName, + studentUrl: block.studentUrl, + subtitles: subtitles, + encodedVideo: .init( + fallback: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.fallback), + youtube: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.youTube), + desktopMP4: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.desktopMP4), + mobileHigh: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.mobileHigh), + mobileLow: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.mobileLow), + hls: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.hls) + ) + ) + } + + private func parseVideo(encodedVideo: DataLayer.EncodedVideoData?) -> CourseBlockVideo? { + guard let encodedVideo else { + return nil + } + return .init( + url: encodedVideo.url, + fileSize: encodedVideo.fileSize, + streamPriority: encodedVideo.streamPriority + ) + } + + private let courseStructureJson: String = """ + {"root": "block-v1:QA+comparison+2022+type@course+block@course", + "blocks": { + "block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78": { + "id": "block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78", + "block_id": "be1704c576284ba39753c6f0ea4a4c78", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78", + "type": "comparison", + "display_name": "Comparison", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296": { + "id": "block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296", + "block_id": "93acc543871e4c73bc20a72a64e93296", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296", + "type": "problem", + "display_name": "Dropdown with Hints and Feedback", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b": { + "id": "block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b", + "block_id": "06c17035106e48328ebcd042babcf47b", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b", + "type": "comparison", + "display_name": "Comparison", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58": { + "id": "block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58", + "block_id": "c19e41b61db14efe9c45f1354332ae58", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58", + "type": "problem", + "display_name": "Text Input with Hints and Feedback", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb": { + "id": "block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb", + "block_id": "0d96732f577b4ff68799faf8235d1bfb", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb", + "type": "problem", + "display_name": "Numerical Input with Hints and Feedback", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96": { + "id": "block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96", + "block_id": "dd2e22fdf0724bd88c8b2e6b68dedd96", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96", + "type": "problem", + "display_name": "Blank Common Problem", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd": { + "id": "block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd", + "block_id": "d1e091aa305741c5bedfafed0d269efd", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd", + "type": "problem", + "display_name": "Blank Common Problem", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7": { + "id": "block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7", + "block_id": "23e10dea806345b19b77997b4fc0eea7", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7", + "type": "comparison", + "display_name": "Comparison", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904": { + "id": "block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904", + "block_id": "29e7eddbe8964770896e4036748c9904", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904", + "type": "vertical", + "display_name": "Unit", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78", + "block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296", + "block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b", + "block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58", + "block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb", + "block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96", + "block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd", + "block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6": { + "id": "block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6", + "block_id": "f468bb5c6e8641179e523c7fcec4e6d6", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6", + "type": "sequential", + "display_name": "Subsection", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234": { + "id": "block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234", + "block_id": "eaf91d8fc70547339402043ba1a1c234", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234", + "type": "problem", + "display_name": "Dropdown with Hints and Feedback", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751": { + "id": "block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751", + "block_id": "fac531c3f1f3400cb8e3b97eb2c3d751", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751", + "type": "comparison", + "display_name": "Comparison", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de": { + "id": "block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de", + "block_id": "74a1074024fe401ea305534f2241e5de", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de", + "type": "html", + "display_name": "Raw HTML", + "graded": false, + "student_view_data": { + "last_modified": "2023-05-04T19:08:07Z", + "html_data": "https://s3.eu-central-1.amazonaws.com/vso-dev-edx-sorage/htmlxblock/QA/comparison/html/74a1074024fe401ea305534f2241e5de/content_html.zip", + "size": 576, + "index_page": "index.html", + "icon_class": "other" + }, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca": { + "id": "block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca", + "block_id": "e5b2e105f4f947c5b76fb12c35da1eca", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca", + "type": "vertical", + "display_name": "Unit", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234", + "block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751", + "block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2": { + "id": "block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2", + "block_id": "d37cb0c5c2d24ddaacf3494760a055f2", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2", + "type": "sequential", + "display_name": "Another one subsection", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846": { + "id": "block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846", + "block_id": "abecaefe203c4c93b441d16cea3b7846", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846", + "type": "chapter", + "display_name": "Section", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6", + "block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de": { + "id": "block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de", + "block_id": "a0c3ac29daab425f92a34b34eb2af9de", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de", + "type": "pdf", + "display_name": "PDF title", + "graded": false, + "student_view_data": { + "last_modified": "2023-04-26T08:43:45Z", + "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", + }, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9": { + "id": "block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9", + "block_id": "bcd1b0f3015b4d3696b12f65a5d682f9", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9", + "type": "pdf", + "display_name": "PDF", + "graded": false, + "student_view_data": { + "last_modified": "2023-04-26T08:43:45Z", + "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", + }, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59": { + "id": "block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59", + "block_id": "67d805daade34bd4b6ace607e6d48f59", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59", + "type": "pdf", + "display_name": "PDF", + "graded": false, + "student_view_data": { + "last_modified": "2023-04-26T08:43:45Z", + "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", + }, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974": { + "id": "block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974", + "block_id": "828606a51f4e44198e92f86a45be7974", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974", + "type": "pdf", + "display_name": "PDF", + "graded": false, + "student_view_data": { + "last_modified": "2023-04-26T08:43:45Z", + "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", + }, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee": { + "id": "block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee", + "block_id": "8646c3bc2184467b86e5ef01ecd452ee", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee", + "type": "pdf", + "display_name": "PDF", + "graded": false, + "student_view_data": { + "last_modified": "2023-04-26T08:43:45Z", + "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", + }, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1": { + "id": "block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1", + "block_id": "e2faa0e62223489e91a41700865c5fc1", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1", + "type": "vertical", + "display_name": "Unit", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de", + "block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9", + "block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59", + "block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974", + "block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52": { + "id": "block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52", + "block_id": "0c5e89fa6d7a4fac8f7b26f2ca0bbe52", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52", + "type": "problem", + "display_name": "Checkboxes with Hints and Feedback", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4": { + "id": "block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4", + "block_id": "8ba437d8b20d416d91a2d362b0c940a4", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4", + "type": "vertical", + "display_name": "Unit", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d": { + "id": "block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d", + "block_id": "021f70794f7349998e190b060260b70d", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d", + "type": "pdf", + "display_name": "PDF", + "graded": false, + "student_view_data": { + "last_modified": "2023-04-26T08:43:45Z", + "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", + }, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1": { + "id": "block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1", + "block_id": "2c344115d3554ac58c140ec86e591aa1", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1", + "type": "vertical", + "display_name": "Unit", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe": { + "id": "block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe", + "block_id": "6c9c6ba663b54c0eb9cbdcd0c6b4bebe", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe", + "type": "sequential", + "display_name": "Subsection", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1", + "block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4", + "block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7": { + "id": "block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7", + "block_id": "d5a4f1f2f5314288aae400c270fb03f7", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7", + "type": "chapter", + "display_name": "PDF", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf": { + "id": "block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf", + "block_id": "7ab45affb80f4846a60648ec6aff9fbf", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf", + "type": "chapter", + "display_name": "Section", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ] + }, + "block-v1:QA+comparison+2022+type@course+block@course": { + "id": "block-v1:QA+comparison+2022+type@course+block@course", + "block_id": "course", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@course+block@course", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@course+block@course?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@course+block@course", + "type": "course", + "display_name": "Comparison xblock test coursre", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846", + "block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7", + "block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf" + ], + "completion": 0 + } + }, + "id": "course-v1:QA+comparison+2022", + "name": "Comparison xblock test coursre", + "number": "comparison", + "org": "QA", + "start": "2022-01-01T00:00:00Z", + "start_display": "01 january 2022 р.", + "start_type": "timestamp", + "end": null, + "courseware_access": { + "has_access": true, + "error_code": null, + "developer_message": null, + "user_message": null, + "additional_context_user_message": null, + "user_fragment": null + }, + "media": { + "image": { + "raw": "/asset-v1:QA+comparison+2022+type@asset+block@images_course_image.jpg", + "small": "/asset-v1:QA+comparison+2022+type@asset+block@images_course_image.jpg", + "large": "/asset-v1:QA+comparison+2022+type@asset+block@images_course_image.jpg" + } + }, + "certificate": { + + }, + "is_self_paced": false + } + """ + + private let courseDatesJSON: String = """ + { + "dates_banner_info": { + "missed_deadlines": false, + "content_type_gating_enabled": true, + "missed_gated_content": false, + "verified_upgrade_link": null + }, + "course_date_blocks": [ + { + "assignment_type": null, + "complete": null, + "date": "2023-08-30T15:00:00Z", + "date_type": "course-start-date", + "description": "", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Course starts", + "extra_info": null, + "first_component_block_id": "" + }, + { + "assignment_type": "Problem Set", + "complete": true, + "date": "2023-09-14T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@ca19e125470846f2a36ad1225410e39a", + "link_text": "", + "title": "Problem Set 1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-14T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Problem Set 1.1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530a" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-21T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ecec", + "link_text": "", + "title": "Problem Set 2", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abd" + }, + { + "assignment_type": "Problem Set", + "complete": true, + "date": "2023-09-21T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ececc", + "link_text": "", + "title": "Problem Set 2.1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abdc" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-21T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": false, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ececcc", + "link_text": "", + "title": "Problem Set 2.2", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abdcc" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-28T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@bfe9eb02884a4812883ff9e543887968", + "link_text": "", + "title": "Problem Set 3", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@5e117d71433647eaa6de63434641c011" + }, + { + "assignment_type": "Midterm", + "complete": false, + "date": "2023-10-04T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": false, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@bb284b9c4ff04091951f77b50e3b72f4", + "link_text": "", + "title": "Midterm Exam (time limit removed due to grader issues)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@vertical+block@ec1c5d83de6244d38b1f3ff4d32b6e17" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-10-12T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@64f4d344ecdc48d2bef514882e6236ab", + "link_text": "", + "title": "Problem Set 4", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@eeb64a67e52e4f3e80656b9233204f25" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-10-19T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@79d22d4ab4f740158930fca4e80d67db", + "link_text": "", + "title": "Problem Set 5", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@3dde572871fc4b6ebdb47722a184a514" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-10-26T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@3d419098708e4bcd9209ffa31a4cb3dc", + "link_text": "", + "title": "Problem Set 6", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@9b2a0176bf6a4c21ad4a63c2fce2d0cb" + }, + { + "assignment_type": "Final Exam", + "complete": false, + "date": "2023-10-31T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": false, + "link": "", + "link_text": "", + "title": "Final Exam (time limit removed due to grader issues)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@vertical+block@e7b4f091d7ad457097d0bbda9d9af267" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@221a4c17dba341d6a970a0d80343253c", + "link_text": "", + "title": "1. Introduction to Python (TIME: 1:03:12)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@ad9387910b7e47069c452efebd7b36dd" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@35f82f6c3ecb4e9e913dc279a9b73a9f", + "link_text": "", + "title": "2. Core Elements of Programs (TIME: 54:14)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@8fb4fa767a204d41a6366c2bc53bea22" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@62f08cc899344863a1ab678aee506dec", + "link_text": "", + "title": "3. Simple Algorithms (TIME: 41:06)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@1f2b055948c9467492649b59e24e8fdc" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@38007cdb67c44b46b124cdbce33510b5", + "link_text": "", + "title": "4. Functions (TIME: 1:08:06)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@9dc4c11c46274b87964c7534b449d50a" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@01df98c1e74a459b8fb20d2d785622cd", + "link_text": "", + "title": "5. Tuples and Lists", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@3464df78190b43948ba0507ef4287290" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@8a590293a22e46dd9760ec917d122ec1", + "link_text": "", + "title": "6. Dictionaries", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@d2abc5b3db0d43ba90c5d3a25e95e2d5" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@78648402e8bf4738ade97101cc1ba263", + "link_text": "", + "title": "7. Testing and Debugging", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@dd0621fbfe594e789b187a1e4f8406eb" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@c81c3de20ec54c37a04a8b3d1806e82c", + "link_text": "", + "title": "8. Exceptions and Assertions", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@6038a1b2f8a340eb8cdb41c021d62234" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@37cb9a5012e443bbaa776a80afd9c87a", + "link_text": "", + "title": "9. Classes and Inheritance", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@b87e596b827142f09e9664fac3ab0be0" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@54cd6b1bbbbe40f294ac0b5664c03f1e", + "link_text": "", + "title": "10. An Extended Example", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@6bc79b1a29ac46a7857caa53a8e203d0" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@1334ab336b1b4458b5c2972c50e903b2", + "link_text": "", + "title": "11. Computational Complexity", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@be73e5a3ee7847d98805a257189b9fad" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@a7387dbd3728491c8f834e29a73e0cf4", + "link_text": "", + "title": "12. Searching and Sorting Algorithms", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@fa7e29b3b95b4a3b963d1c5dfdd4e8f8" + }, + { + "assignment_type": null, + "complete": null, + "date": "2023-11-01T23:30:00Z", + "date_type": "course-end-date", + "description": "After the course ends, the course content will be archived and no longer active.", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Course ends", + "extra_info": null, + "first_component_block_id": "" + }, + { + "assignment_type": null, + "complete": null, + "date": "2023-11-03T00:00:00Z", + "date_type": "certificate-available-date", + "description": "Day certificates will become available for passing verified learners.", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Certificate Available", + "extra_info": null, + "first_component_block_id": "" + }, + { + "assignment_type": null, + "complete": null, + "date": "2023-11-23T12:34:28Z", + "date_type": "course-expired-date", + "description": "You lose all access to this course, including your progress.", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Audit Access Expires", + "extra_info": null, + "first_component_block_id": "" + } + ], + "has_ended": false, + "learner_is_full_access": false, + "user_timezone": null } + """ } #endif +// swiftlint:enable all diff --git a/Course/Course/Data/CourseStorage.swift b/Course/Course/Data/CourseStorage.swift new file mode 100644 index 000000000..1bfc64704 --- /dev/null +++ b/Course/Course/Data/CourseStorage.swift @@ -0,0 +1,25 @@ +// +// CourseStorage.swift +// Course +// +// Created by Eugene Yatsenko on 28.12.2023. +// + +import Foundation +import Core + +public protocol CourseStorage { + var allowedDownloadLargeFile: Bool? { get set } + var userSettings: UserSettings? { get set } +} + +#if DEBUG +public class CourseStorageMock: CourseStorage { + + public var userSettings: UserSettings? + + public var allowedDownloadLargeFile: Bool? + + public init() {} +} +#endif diff --git a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift index 4e67cdfc3..302625837 100644 --- a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift +++ b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift @@ -129,29 +129,55 @@ public extension DataLayer { } struct CourseDetailEncodedVideoData: Decodable { - public let youTube: CourseDetailYouTubeData? - public let fallback: CourseDetailYouTubeData? - + public let youTube: EncodedVideoData? + public let fallback: EncodedVideoData? + public let desktopMP4: EncodedVideoData? + public let mobileHigh: EncodedVideoData? + public let mobileLow: EncodedVideoData? + public let hls: EncodedVideoData? + public init( - youTube: CourseDetailYouTubeData?, - fallback: CourseDetailYouTubeData? + youTube: EncodedVideoData?, + fallback: EncodedVideoData?, + desktopMP4: EncodedVideoData? = nil, + mobileHigh: EncodedVideoData? = nil, + mobileLow: EncodedVideoData? = nil, + hls: EncodedVideoData? = nil ) { self.youTube = youTube self.fallback = fallback + self.desktopMP4 = desktopMP4 + self.mobileHigh = mobileHigh + self.mobileLow = mobileLow + self.hls = hls } enum CodingKeys: String, CodingKey { case youTube = "youtube" case fallback + case desktopMP4 = "desktop_mp4" + case mobileHigh = "mobile_high" + case mobileLow = "mobile_low" + case hls } } - struct CourseDetailYouTubeData: Decodable { + struct EncodedVideoData: Decodable { public let url: String? - - public init(url: String?) { + public let fileSize: Int? + public let streamPriority: Int? + + public init(url: String?, fileSize: Int?, streamPriority: Int? = nil) { self.url = url + self.fileSize = fileSize + self.streamPriority = streamPriority } - + + enum CodingKeys: String, CodingKey { + case url + case fileSize = "file_size" + case streamPriority = "stream_priority" + } + } } diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index c24fa4e5a..2ec79a638 100644 --- a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents +++ b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents @@ -7,18 +7,27 @@ - - + + + + + + + + + + + @@ -87,4 +96,4 @@ - \ No newline at end of file + diff --git a/Course/Course/Presentation/Container/BaseCourseViewModel.swift b/Course/Course/Presentation/Container/BaseCourseViewModel.swift index b1fa3f069..04cd5a05a 100644 --- a/Course/Course/Presentation/Container/BaseCourseViewModel.swift +++ b/Course/Course/Presentation/Container/BaseCourseViewModel.swift @@ -18,13 +18,4 @@ open class BaseCourseViewModel: ObservableObject { init(manager: DownloadManagerProtocol) { self.manager = manager } - - func onBackground() { - manager.pauseDownloading() - } - - func onForeground() { - try? manager.resumeDownloading() - } - } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 3e17216fd..2e154ba69 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -33,7 +33,7 @@ public struct CourseContainerView: View { case .dates: return CourseLocalization.CourseContainer.dates case .discussion: - return CourseLocalization.CourseContainer.discussion + return CourseLocalization.CourseContainer.discussions case .handounds: return CourseLocalization.CourseContainer.handouts } @@ -98,7 +98,7 @@ public struct CourseContainerView: View { isVideo: false ) } else { - VStack { + VStack(spacing: 0) { if viewModel.config.uiComponents.courseTopTabBarEnabled { topTabBar } @@ -242,6 +242,7 @@ struct CourseScreensView_Previews: PreviewProvider { config: ConfigMock(), connectivity: Connectivity(), manager: DownloadManagerMock(), + storage: CourseStorageMock(), isActive: true, courseStart: nil, courseEnd: nil, diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 84f7221de..dfb5ecd89 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -12,14 +12,16 @@ import Combine public class CourseContainerViewModel: BaseCourseViewModel { + @Published private(set) var isShowProgress = false @Published var courseStructure: CourseStructure? @Published var courseVideosStructure: CourseStructure? - @Published private(set) var isShowProgress = false @Published var showError: Bool = false @Published var sequentialsDownloadState: [String: DownloadViewState] = [:] - @Published var verticalsDownloadState: [String: DownloadViewState] = [:] + @Published private(set) var downloadableVerticals: Set = [] @Published var continueWith: ContinueWith? - + @Published var userSettings: UserSettings? + @Published var isInternetAvaliable: Bool = true + var errorMessage: String? { didSet { withAnimation { @@ -37,11 +39,15 @@ public class CourseContainerViewModel: BaseCourseViewModel { let courseEnd: Date? let enrollmentStart: Date? let enrollmentEnd: Date? - + + var courseDownloadTasks: [DownloadDataTask] = [] + private(set) var waitingDownloads: [CourseBlock]? + private let interactor: CourseInteractorProtocol private let authInteractor: AuthInteractorProtocol private let analytics: CourseAnalytics - + private(set) var storage: CourseStorage + public init( interactor: CourseInteractorProtocol, authInteractor: AuthInteractorProtocol, @@ -50,6 +56,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { config: ConfigProtocol, connectivity: ConnectivityProtocol, manager: DownloadManagerProtocol, + storage: CourseStorage, isActive: Bool?, courseStart: Date?, courseEnd: Date?, @@ -67,17 +74,13 @@ public class CourseContainerViewModel: BaseCourseViewModel { self.courseEnd = courseEnd self.enrollmentStart = enrollmentStart self.enrollmentEnd = enrollmentEnd - + self.storage = storage + self.userSettings = storage.userSettings + self.isInternetAvaliable = connectivity.isInternetAvaliable + super.init(manager: manager) - - manager.publisher() - .sink(receiveValue: { [weak self] _ in - guard let self else { return } - DispatchQueue.main.async { - self.setDownloadsStates() - } - }) - .store(in: &cancellables) + + addObservers() } @MainActor @@ -86,7 +89,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { if courseStart < Date() { isShowProgress = withProgress do { - if connectivity.isInternetAvaliable { + if isInternetAvaliable { courseStructure = try await interactor.getCourseBlocks(courseID: courseID) isShowProgress = false if let courseStructure { @@ -102,7 +105,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { courseStructure = try await interactor.getLoadedCourseBlocks(courseID: courseID) } courseVideosStructure = interactor.getCourseVideoBlocks(fullStructure: courseStructure!) - setDownloadsStates() + await setDownloadsStates() isShowProgress = false } catch let error { @@ -116,7 +119,12 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } } - + + func update(downloadQuality: DownloadQuality) { + storage.userSettings?.downloadQuality = downloadQuality + userSettings = storage.userSettings + } + @MainActor func tryToRefreshCookies() async { try? await authInteractor.getCookies(force: false) @@ -131,33 +139,38 @@ public class CourseContainerViewModel: BaseCourseViewModel { ) } - func verticalsBlocksDownloadable(by courseSequential: CourseSequential) -> [String: DownloadViewState] { - verticalsDownloadState.filter { dict in + @MainActor + func onDownloadViewTap(chapter: CourseChapter, blockId: String, state: DownloadViewState) async { + guard let sequential = chapter.childs + .first(where: { $0.id == blockId }) else { + return + } + + let blocks = sequential.childs.flatMap { $0.childs } + .filter { $0.isDownloadable } + + if state == .available, isShowedAllowLargeDownloadAlert(blocks: blocks) { + return + } + + await download(state: state, blocks: blocks) + } + + func verticalsBlocksDownloadable(by courseSequential: CourseSequential) -> [CourseBlock] { + let verticals = downloadableVerticals.filter { verticalState in courseSequential.childs.contains(where: { item in - let state = verticalsDownloadState[dict.key] - return (state == .available || state == .downloading) && dict.key == item.id + return verticalState.vertical.id == item.id }) } + return verticals.flatMap { $0.vertical.childs.filter { $0.isDownloadable } } } - func onDownloadViewTap(chapter: CourseChapter, blockId: String, state: DownloadViewState) { - let blocks = chapter.childs - .first(where: { $0.id == blockId })?.childs - .flatMap { $0.childs } - .filter { $0.isDownloadable } ?? [] - + func continueDownload() { + guard let blocks = waitingDownloads else { + return + } do { - switch state { - case .available: - try manager.addToDownloadQueue(blocks: blocks) - sequentialsDownloadState[blockId] = .downloading - case .downloading: - try manager.cancelDownloading(courseId: courseStructure?.id ?? "", blocks: blocks) - sequentialsDownloadState[blockId] = .available - case .finished: - manager.deleteFile(blocks: blocks) - sequentialsDownloadState[blockId] = .available - } + try manager.addToDownloadQueue(blocks: blocks) } catch let error { if error is NoWiFiError { errorMessage = CoreLocalization.Error.wifi @@ -215,27 +228,91 @@ public class CourseContainerViewModel: BaseCourseViewModel { blockId: blockId ) } - + + func hasVideoForDowbloads() -> Bool { + guard let courseVideosStructure = courseVideosStructure else { + return false + } + return courseVideosStructure.childs + .flatMap { $0.childs } + .contains(where: { $0.isDownloadable }) + } + + func isAllDownloading() -> Bool { + let totalCount = downloadableVerticals.count + let downloadingCount = downloadableVerticals.filter { $0.state == .downloading }.count + let finishedCount = downloadableVerticals.filter { $0.state == .finished }.count + if finishedCount == totalCount { return false } + return totalCount - finishedCount == downloadingCount + } + + @MainActor + func download(state: DownloadViewState, blocks: [CourseBlock]) async { + do { + switch state { + case .available: + try manager.addToDownloadQueue(blocks: blocks) + case .downloading: + try await manager.cancelDownloading(courseId: courseStructure?.id ?? "", blocks: blocks) + case .finished: + await manager.deleteFile(blocks: blocks) + } + } catch let error { + if error is NoWiFiError { + errorMessage = CoreLocalization.Error.wifi + } + } + } + @MainActor - private func setDownloadsStates() { + func isShowedAllowLargeDownloadAlert(blocks: [CourseBlock]) -> Bool { + waitingDownloads = nil + if storage.allowedDownloadLargeFile == false, manager.isLargeVideosSize(blocks: blocks) { + waitingDownloads = blocks + router.presentAlert( + alertTitle: CourseLocalization.Download.download, + alertMessage: CourseLocalization.Download.downloadLargeFileMessage, + positiveAction: CourseLocalization.Alert.accept, + onCloseTapped: { + self.router.dismiss(animated: true) + }, + okTapped: { + self.continueDownload() + self.router.dismiss(animated: true) + }, + type: .default(positiveAction: CourseLocalization.Alert.accept, image: nil) + ) + return true + } + return false + } + + @MainActor + func downloadableBlocks(from sequential: CourseSequential) -> [CourseBlock] { + let verticals = sequential.childs + let blocks = verticals + .flatMap { $0.childs } + .filter { $0.isDownloadable } + return blocks + } + + @MainActor + func setDownloadsStates() async { guard let course = courseStructure else { return } - let downloads = manager.getDownloadsForCourse(course.id) + courseDownloadTasks = await manager.getDownloadTasksForCourse(course.id) + downloadableVerticals = [] var sequentialsStates: [String: DownloadViewState] = [:] - var verticalsStates: [String: DownloadViewState] = [:] for chapter in course.childs { for sequential in chapter.childs where sequential.isDownloadable { 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 }) { + if let download = courseDownloadTasks.first(where: { $0.id == block.id }) { switch download.state { case .waiting, .inProgress: sequentialsChilds.append(.downloading) verticalsChilds.append(.downloading) - case .paused: - sequentialsChilds.append(.available) - verticalsChilds.append(.available) case .finished: sequentialsChilds.append(.finished) verticalsChilds.append(.finished) @@ -246,11 +323,11 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } if verticalsChilds.first(where: { $0 == .downloading }) != nil { - verticalsStates[vertical.id] = .downloading + downloadableVerticals.insert(.init(vertical: vertical, state: .downloading)) } else if verticalsChilds.allSatisfy({ $0 == .finished }) { - verticalsStates[vertical.id] = .finished + downloadableVerticals.insert(.init(vertical: vertical, state: .finished)) } else { - verticalsStates[vertical.id] = .available + downloadableVerticals.insert(.init(vertical: vertical, state: .available)) } } if sequentialsChilds.first(where: { $0 == .downloading }) != nil { @@ -262,7 +339,6 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } self.sequentialsDownloadState = sequentialsStates - self.verticalsDownloadState = verticalsStates } } @@ -286,4 +362,33 @@ public class CourseContainerViewModel: BaseCourseViewModel { } return nil } + + private func addObservers() { + manager.eventPublisher() + .sink { [weak self] state in + guard let self else { return } + if case .progress = state { return } + Task(priority: .background) { + debugLog(state, "--- state ---") + await self.setDownloadsStates() + } + } + .store(in: &cancellables) + + connectivity.internetReachableSubject + .sink { [weak self] _ in + guard let self else { return } + self.isInternetAvaliable = self.connectivity.isInternetAvaliable + } + .store(in: &cancellables) + } +} + +struct VerticalsDownloadState: Hashable { + let vertical: CourseVertical + let state: DownloadViewState + + var downloadableBlocks: [CourseBlock] { + vertical.childs.filter { $0.isDownloadable } + } } diff --git a/Course/Course/Presentation/Downloads/DownloadsView.swift b/Course/Course/Presentation/Downloads/DownloadsView.swift new file mode 100644 index 000000000..c6b0262d1 --- /dev/null +++ b/Course/Course/Presentation/Downloads/DownloadsView.swift @@ -0,0 +1,108 @@ +// +// DownloadsView.swift +// Course +// +// Created by Eugene Yatsenko on 20.12.2023. +// + +import SwiftUI +import Core +import Theme +import Combine + +struct DownloadsView: View { + + // MARK: - Properties + + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel: DownloadsViewModel + + init( + courseId: String? = nil, + manager: DownloadManagerProtocol + ) { + self._viewModel = .init( + wrappedValue: .init(courseId: courseId, manager: manager) + ) + } + + // MARK: - Body + + var body: some View { + NavigationView { + ScrollView { + LazyVStack { + ForEach( + viewModel.downloads, + content: cell + ) + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(CourseLocalization.Download.downloads) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .foregroundColor(Theme.Colors.accentColor) + } + .accessibilityIdentifier("close_button") + } + } + .padding(.top, 1) + } + } + + // MARK: - Views + + @ViewBuilder + func cell(task: DownloadDataTask) -> some View { + VStack(spacing: 0) { + VStack { + HStack { + VStack(alignment: .leading) { + let title = viewModel.title(task: task) + Text(title) + .font(Theme.Fonts.titleMedium) + .multilineTextAlignment(.leading) + .lineLimit(1) + .accessibilityElement(children: .ignore) + .accessibilityLabel(title) + .accessibilityIdentifier("file_name_text") + let fileSizeInMbText = task.fileSizeInMbText + Text(fileSizeInMbText) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textSecondary) + .multilineTextAlignment(.leading) + .lineLimit(1) + .accessibilityElement(children: .ignore) + .accessibilityLabel(fileSizeInMbText) + .accessibilityIdentifier("file_size_text") + if task.state != .finished { + ProgressView(value: task.progress, total: 1.0) + .tint(Theme.Colors.accentColor) + .accessibilityIdentifier("progress_line_view") + } + } + Spacer() + Button { + Task { + await viewModel.cancelDownloading(task: task) + } + } label: { + DownloadProgressView() + .accessibilityElement(children: .ignore) + .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) + .accessibilityIdentifier("cancel_download_button") + } + .padding(.horizontal, 15) + } + .padding(.leading, 20) + .padding(.vertical, 5) + Divider() + } + } + } +} diff --git a/Course/Course/Presentation/Downloads/DownloadsViewModel.swift b/Course/Course/Presentation/Downloads/DownloadsViewModel.swift new file mode 100644 index 000000000..b71566f58 --- /dev/null +++ b/Course/Course/Presentation/Downloads/DownloadsViewModel.swift @@ -0,0 +1,86 @@ +// +// DownloadsViewModel.swift +// Course +// +// Created by Eugene Yatsenko on 20.12.2023. +// + +import Foundation +import Core +import Combine + +final class DownloadsViewModel: ObservableObject { + + // MARK: - Properties + + @Published private(set) var downloads: [DownloadDataTask] = [] + private let courseId: String? + + private let manager: DownloadManagerProtocol + private var cancellables = Set() + + init( + courseId: String? = nil, + manager: DownloadManagerProtocol + ) { + self.courseId = courseId + self.manager = manager + Task { await configure() } + observers() + } + + // MARK: - Intents + + func title(task: DownloadDataTask) -> String { + task.displayName.isEmpty ? + "(\(CourseLocalization.Download.untitled))" : + task.displayName + } + + @MainActor + func cancelDownloading(task: DownloadDataTask) async { + do { + try await manager.cancelDownloading(task: task) + downloads.removeAll(where: { $0.id == task.id }) + } catch { + debugLog(error) + } + } + + @MainActor + private func configure() async { + defer { + filter() + } + if let courseId = courseId { + downloads = await manager.getDownloadTasksForCourse(courseId) + return + } + downloads = await manager.getDownloadTasks() + + } + + private func observers() { + manager.eventPublisher() + .sink { [weak self] event in + guard let self else { return } + switch event { + case .progress(let progress, let downloadData): + if let firstIndex = downloads.firstIndex(where: { $0.id == downloadData.id }) { + self.downloads[firstIndex].progress = progress + } + case .finished(let downloadData): + downloads.removeAll(where: { $0.id == downloadData.id }) + default: + break + } + } + .store(in: &cancellables) + } + + private func filter() { + downloads = downloads + .filter { $0.state == .inProgress || $0.state == .waiting } + .sorted(by: { $0.state.order < $1.state.order }) + } +} diff --git a/Course/Course/Presentation/Outline/ContinueWithView.swift b/Course/Course/Presentation/Outline/ContinueWithView.swift index 8116fa90b..e1b929728 100644 --- a/Course/Course/Presentation/Outline/ContinueWithView.swift +++ b/Course/Course/Presentation/Outline/ContinueWithView.swift @@ -80,27 +80,24 @@ struct ContinueWithView_Previews: PreviewProvider { blockId: "1", id: "1", courseId: "123", - topicId: "1", - graded: false, + graded: true, completion: 0, - type: .video, - displayName: "Lesson 1", + type: .html, + displayName: "Continue lesson", studentUrl: "", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ), CourseBlock( blockId: "2", id: "2", courseId: "123", - topicId: "2", - graded: false, + graded: true, completion: 0, - type: .video, - displayName: "Lesson 2", - studentUrl: "2", - videoUrl: nil, - youTubeUrl: nil + type: .html, + displayName: "Continue lesson", + studentUrl: "", + encodedVideo: nil + ) ] @@ -120,8 +117,7 @@ struct ContinueWithView_Previews: PreviewProvider { completion: 1, childs: blocks ) - ) { - } + ) { } } } #endif diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 81d566c03..3f445803f 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -19,7 +19,11 @@ public struct CourseOutlineView: View { @State private var openCertificateView: Bool = false private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - + + @State private var showingDownloads: Bool = false + @State private var showingVideoDownloadQuality: Bool = false + @State private var showingNoWifiMessage: Bool = false + public init( viewModel: CourseContainerViewModel, title: String, @@ -46,6 +50,8 @@ public struct CourseOutlineView: View { courseBanner(proxy: proxy) } + downloadQualityBars + if let continueWith = viewModel.continueWith, let courseStructure = viewModel.courseStructure, !isVideo { @@ -90,7 +96,7 @@ public struct CourseOutlineView: View { // MARK: - Sections if viewModel.config.uiComponents.courseNestedListEnabled { - CourseExpandableContentView( + CourseStructureNestedListView( proxy: proxy, course: course, viewModel: viewModel @@ -117,8 +123,9 @@ public struct CourseOutlineView: View { .onRightSwipeGesture { viewModel.router.back() } - }.padding(.top, 8) - .accessibilityAction {} + } + .padding(.top, viewModel.config.uiComponents.courseTopTabBarEnabled ? 0 : 8) + .accessibilityAction {} // MARK: - Offline mode SnackBar OfflineSnackBarView( @@ -134,7 +141,7 @@ public struct CourseOutlineView: View { Spacer() SnackBarView(message: viewModel.errorMessage) } - .padding(.bottom, viewModel.connectivity.isInternetAvaliable + .padding(.bottom, viewModel.isInternetAvaliable ? 0 : OfflineSnackBarView.height) .transition(.move(edge: .bottom)) .onAppear { @@ -156,6 +163,48 @@ public struct CourseOutlineView: View { Theme.Colors.background .ignoresSafeArea() ) + .sheet(isPresented: $showingDownloads) { + DownloadsView(manager: viewModel.manager) + } + .sheet(isPresented: $showingVideoDownloadQuality) { + viewModel.storage.userSettings.map { + VideoDownloadQualityContainerView( + downloadQuality: $0.downloadQuality, + didSelect: viewModel.update(downloadQuality:) + ) + } + } + } + + @ViewBuilder + private var downloadQualityBars: some View { + if isVideo, + let courseVideosStructure = viewModel.courseVideosStructure, + viewModel.hasVideoForDowbloads() { + VStack(spacing: 0) { + CourseVideoDownloadBarView( + courseStructure: courseVideosStructure, + courseViewModel: viewModel, + onNotInternetAvaliable: { + viewModel.errorMessage = CourseLocalization.Download.noWifiMessage + }, + onTap: { + showingDownloads = true + } + ) + viewModel.userSettings.map { + VideoDownloadQualityBarView( + downloadQuality: $0.downloadQuality + ) { + if viewModel.isAllDownloading() { + viewModel.errorMessage = CourseLocalization.Download.changeQualityAlert + return + } + showingVideoDownloadQuality = true + } + } + } + } } private func courseBanner(proxy: GeometryProxy) -> some View { @@ -212,130 +261,6 @@ public struct CourseOutlineView: View { } } -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(.accentColor) - } 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 { - 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) - .overlay(Theme.Colors.cardViewStroke) - .padding(.horizontal, 24) - } - } - } - } - } -} - #if DEBUG struct CourseOutlineView_Previews: PreviewProvider { static var previews: some View { @@ -347,6 +272,7 @@ struct CourseOutlineView_Previews: PreviewProvider { config: ConfigMock(), connectivity: Connectivity(), manager: DownloadManagerMock(), + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, diff --git a/Course/Course/Presentation/Outline/CourseExpandableContentView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift similarity index 57% rename from Course/Course/Presentation/Outline/CourseExpandableContentView.swift rename to Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift index d0f73e917..1ba3f8027 100644 --- a/Course/Course/Presentation/Outline/CourseExpandableContentView.swift +++ b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift @@ -1,5 +1,5 @@ // -// CourseStructureView.swift +// CourseStructureNestedListView.swift // Course // // Created by Eugene Yatsenko on 09.11.2023. @@ -10,7 +10,7 @@ import Core import Kingfisher import Theme -struct CourseExpandableContentView: View { +struct CourseStructureNestedListView: View { private let proxy: GeometryProxy private let course: CourseStructure @@ -76,10 +76,10 @@ struct CourseExpandableContentView: View { chapter: CourseChapter, isExpanded: Bool ) -> some View { - Button { - onLabelClick(sequential: sequential, chapter: chapter) - } label: { - HStack { + HStack { + Button { + onLabelClick(sequential: sequential, chapter: chapter) + } label: { Group { if sequential.completion == 1 { CoreAssets.finished.swiftUIImage @@ -94,23 +94,19 @@ struct CourseExpandableContentView: View { .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) + Spacer() + downloadButton( + sequential: sequential, + chapter: chapter + ) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(sequential.displayName) + .padding(.leading, 40) + .padding(.trailing, 28) + .padding(.vertical, 14) } @ViewBuilder @@ -121,42 +117,81 @@ struct CourseExpandableContentView: 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() + if viewModel.isInternetAvaliable { + Button { + Task { + await viewModel.onDownloadViewTap( + chapter: chapter, + blockId: sequential.id, + state: state + ) + } + } label: { + DownloadAvailableView() + .accessibilityElement(children: .ignore) + .accessibilityLabel(CourseLocalization.Accessibility.download) } + downloadCount(sequential: sequential) + } case .downloading: - DownloadProgressView() - .onTapGesture { - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: chapter.id, - state: state - ) - } - .onBackground { - viewModel.onBackground() + if viewModel.isInternetAvaliable { + Button { + Task { + await viewModel.onDownloadViewTap( + chapter: chapter, + blockId: sequential.id, + state: state + ) + } + } label: { + DownloadProgressView() + .accessibilityElement(children: .ignore) + .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) } + } case .finished: - DownloadFinishedView() - .onTapGesture { - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: chapter.id, - state: state + 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: .default( + positiveAction: CoreLocalization.Alert.delete, + image: CoreAssets.bgDelete.swiftUIImage ) - } + ) + } 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) diff --git a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift new file mode 100644 index 000000000..fad515777 --- /dev/null +++ b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift @@ -0,0 +1,134 @@ +// +// 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(.accentColor) + } 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/CourseVerticalImageView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift similarity index 90% rename from Course/Course/Presentation/Outline/CourseVerticalImageView.swift rename to Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift index 9c035fc35..7d6288fd7 100644 --- a/Course/Course/Presentation/Outline/CourseVerticalImageView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift @@ -39,8 +39,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .video, displayName: "Block 1", studentUrl: "", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ) ] @@ -55,8 +54,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .problem, displayName: "Block 1", studentUrl: "", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ) ] let blocks3 = [ @@ -70,8 +68,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .discussion, displayName: "Block 1", studentUrl: "", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ) ] let blocks4 = [ @@ -85,8 +82,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .html, displayName: "Block 1", studentUrl: "", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ) ] let blocks5 = [ @@ -100,8 +96,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .unknown, displayName: "Block 1", studentUrl: "", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ) ] HStack { diff --git a/Course/Course/Presentation/Outline/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift similarity index 88% rename from Course/Course/Presentation/Outline/CourseVerticalView.swift rename to Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index b5401b4dc..889d6e155 100644 --- a/Course/Course/Presentation/Outline/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -90,36 +90,38 @@ public struct CourseVerticalView: View { .accessibilityElement(children: .ignore) .accessibilityLabel(CourseLocalization.Accessibility.download) .onTapGesture { - viewModel.onDownloadViewTap( - blockId: vertical.id, - state: state - ) - } - .onForeground { - viewModel.onForeground() + Task { + await viewModel.onDownloadViewTap( + blockId: vertical.id, + state: state + ) + } + } case .downloading: DownloadProgressView() .accessibilityElement(children: .ignore) .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) .onTapGesture { - viewModel.onDownloadViewTap( - blockId: vertical.id, - state: state - ) - } - .onBackground { - viewModel.onBackground() + Task { + await viewModel.onDownloadViewTap( + blockId: vertical.id, + state: state + ) + } + } case .finished: DownloadFinishedView() .accessibilityElement(children: .ignore) .accessibilityLabel(CourseLocalization.Accessibility.deleteDownload) .onTapGesture { - viewModel.onDownloadViewTap( - blockId: vertical.id, - state: state - ) + Task { + await viewModel.onDownloadViewTap( + blockId: vertical.id, + state: state + ) + } } } } diff --git a/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift similarity index 87% rename from Course/Course/Presentation/Outline/CourseVerticalViewModel.swift rename to Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift index bf113389e..4ce5ebd70 100644 --- a/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift @@ -49,16 +49,19 @@ public class CourseVerticalViewModel: BaseCourseViewModel { manager.publisher() .sink(receiveValue: { [weak self] _ in guard let self else { return } - DispatchQueue.main.async { - self.setDownloadsStates() + Task { + await self.setDownloadsStates() } }) .store(in: &cancellables) - setDownloadsStates() + Task { + await setDownloadsStates() + } } - func onDownloadViewTap(blockId: String, state: DownloadViewState) { + @MainActor + func onDownloadViewTap(blockId: String, state: DownloadViewState) async { if let vertical = verticals.first(where: { $0.id == blockId }) { let blocks = vertical.childs.filter { $0.isDownloadable } do { @@ -67,10 +70,10 @@ public class CourseVerticalViewModel: BaseCourseViewModel { try manager.addToDownloadQueue(blocks: blocks) downloadState[vertical.id] = .downloading case .downloading: - try manager.cancelDownloading(courseId: vertical.courseId, blocks: blocks) + try await manager.cancelDownloading(courseId: vertical.courseId, blocks: blocks) downloadState[vertical.id] = .available case .finished: - manager.deleteFile(blocks: blocks) + await manager.deleteFile(blocks: blocks) downloadState[vertical.id] = .available } } catch let error { @@ -94,19 +97,18 @@ public class CourseVerticalViewModel: BaseCourseViewModel { ) } - private func setDownloadsStates() { + @MainActor + private func setDownloadsStates() async { guard let courseId = verticals.first?.courseId else { return } - let downloads = manager.getDownloadsForCourse(courseId) + let downloadTasks = await manager.getDownloadTasksForCourse(courseId) var states: [String: DownloadViewState] = [:] for vertical in verticals where vertical.isDownloadable { var childs: [DownloadViewState] = [] for block in vertical.childs where block.isDownloadable { - if let download = downloads.first(where: { $0.id == block.id }) { + if let download = downloadTasks.first(where: { $0.id == block.id }) { switch download.state { case .waiting, .inProgress: childs.append(.downloading) - case .paused: - childs.append(.available) case .finished: childs.append(.finished) } diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift new file mode 100644 index 000000000..600b565da --- /dev/null +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift @@ -0,0 +1,145 @@ +// +// CourseVideoDownloadBarView.swift +// Course +// +// Created by Eugene Yatsenko on 15.12.2023. +// + +import SwiftUI +import Core +import Theme +import Combine + +struct CourseVideoDownloadBarView: View { + + // MARK: - Properties + + @StateObject var viewModel: CourseVideoDownloadBarViewModel + private var onTap: (() -> Void)? + private var onNotInternetAvaliable: (() -> Void)? + + init( + courseStructure: CourseStructure, + courseViewModel: CourseContainerViewModel, + onNotInternetAvaliable: (() -> Void)?, + onTap: (() -> Void)? = nil + ) { + self._viewModel = .init( + wrappedValue: .init( + courseStructure: courseStructure, + courseViewModel: courseViewModel + ) + ) + self.onNotInternetAvaliable = onNotInternetAvaliable + self.onTap = onTap + } + + // MARK: - Body + + var body: some View { + VStack(spacing: 0) { + Divider() + HStack(spacing: 0) { + image + titles + toggle + } + .padding(.vertical, 10) + if viewModel.isOn, !viewModel.allVideosDownloaded { + ProgressView(value: viewModel.progress, total: 1) + .tint(Theme.Colors.accentColor) + .accessibilityIdentifier("progress_line_view") + } + Divider() + } + .contentShape(Rectangle()) + .onTapGesture { + Task { + let downloads = await viewModel.allActiveDownloads() + if !downloads.isEmpty { + onTap?() + } + } + } + .accessibilityIdentifier("videos_download_bar") + } + + // MARK: - Views + + private var image: some View { + VStack { + if viewModel.isOn, !viewModel.allVideosDownloaded { + ProgressView() + .accessibilityIdentifier("progress_view") + } else { + CoreAssets.video.swiftUIImage + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .accessibilityIdentifier("video_image") + } + } + .frame(width: 40, height: 40) + .padding(.leading, 15) + } + + @ViewBuilder + private var titles: some View { + HStack { + VStack(alignment: .leading) { + let title = viewModel.title + Text(title) + .lineLimit(1) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityElement(children: .ignore) + .accessibilityLabel(title) + .accessibilityIdentifier("bar_title_text") + HStack(spacing: 0) { + Group { + if viewModel.remainingVideos == 0 { + let text = "\(CourseLocalization.Download.videos) \(viewModel.totalFinishedVideos)" + Text(text) + .accessibilityElement(children: .ignore) + .accessibilityLabel(text) + .accessibilityIdentifier("videos_total_finished_text") + } else { + let text = "\(CourseLocalization.Download.remaining) \(viewModel.remainingVideos)" + Text(text) + .accessibilityElement(children: .ignore) + .accessibilityLabel(text) + .accessibilityIdentifier("remaining_videos_text") + } + if let totalSize = viewModel.totalSize { + let text = ", \(totalSize)MB \(CourseLocalization.Download.total)" + Text(text) + .accessibilityElement(children: .ignore) + .accessibilityLabel(text) + .accessibilityIdentifier("total_size_text") + } + } + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + } + } + Spacer() + } + .padding(.horizontal, 10) + .layoutPriority(1) + } + + private var toggle: some View { + Toggle("", isOn: .constant(viewModel.isOn)) + .toggleStyle(SwitchToggleStyle(tint: Theme.Colors.accentColor)) + .padding(.trailing, 15) + .onTapGesture { + if !viewModel.isInternetAvaliable { + onNotInternetAvaliable?() + return + } + Task { await viewModel.onToggle() } + } + .accessibilityIdentifier("download_toggle") + } +} diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift new file mode 100644 index 000000000..4a64dbf4f --- /dev/null +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift @@ -0,0 +1,249 @@ +// +// CourseVideoDownloadBarViewModel.swift +// Course +// +// Created by Eugene Yatsenko on 20.12.2023. +// + +import Foundation +import Core +import Combine + +final class CourseVideoDownloadBarViewModel: ObservableObject { + + // MARK: - Properties + + private let courseStructure: CourseStructure + private let courseViewModel: CourseContainerViewModel + + @Published private(set) var currentDownloadTask: DownloadDataTask? + @Published private(set) var isOn: Bool = false + + private var cancellables = Set() + + var isInternetAvaliable: Bool { + courseViewModel.isInternetAvaliable + } + + var title: String { + if isOn { + if remainingVideos == 0 { + return CourseLocalization.Download.allVideosDownloaded + } else { + return CourseLocalization.Download.downloadingVideos + } + } else { + return CourseLocalization.Download.downloadToDevice + } + } + + var progress: Double { + guard let currentDownloadTask = currentDownloadTask else { + return 0.0 + } + guard let index = courseViewModel.courseDownloadTasks.firstIndex( + where: { $0.id == currentDownloadTask.id } + ) else { + return 0.0 + } + courseViewModel.courseDownloadTasks[index].progress = currentDownloadTask.progress + return courseViewModel + .courseDownloadTasks + .reduce(0) { $0 + $1.progress } / Double(courseViewModel.courseDownloadTasks.count) + } + + var downloadableVerticals: Set { + courseViewModel.downloadableVerticals + } + + var allVideosDownloaded: Bool { + let totalFinishedCount = downloadableVerticals.filter { $0.state == .finished }.count + return totalFinishedCount == downloadableVerticals.count + } + + var remainingVideos: Int { + let inProgress = downloadableVerticals.filter { $0.state != .finished } + return inProgress.flatMap { $0.downloadableBlocks }.count + } + + var downloadingVideos: Int { + let downloading = downloadableVerticals.filter { $0.state == .downloading } + return downloading.flatMap { $0.downloadableBlocks }.count + } + + var totalFinishedVideos: Int { + let finished = downloadableVerticals.filter { $0.state == .finished } + return finished.flatMap { $0.downloadableBlocks }.count + } + + var totalSize: String? { + let downloadQuality = courseViewModel.userSettings?.downloadQuality ?? .auto + let mb = courseStructure.totalVideosSizeInMb( + downloadQuality: downloadQuality + ) + + if mb == 0 { return nil } + + if isOn { + let size = mb - calculateSize(value: mb, percentage: progress * 100) + if size == 0 { + return String(format: "%.2f", mb) + } + return String(format: "%.2f", size) + } + + let size = blockToMB( + data: Set(downloadableVerticals + .filter { $0.state != .finished } + .flatMap { $0.downloadableBlocks } + ), + downloadQuality: downloadQuality + ) + + return String(format: "%.2f", size) + } + + init( + courseStructure: CourseStructure, + courseViewModel: CourseContainerViewModel + ) { + self.courseStructure = courseStructure + self.courseViewModel = courseViewModel + observers() + } + + func allActiveDownloads() async -> [DownloadDataTask] { + await courseViewModel.manager.getDownloadTasks() + .filter { $0.state == .inProgress || $0.state == .waiting } + } + + @MainActor + func onToggle() async { + if allVideosDownloaded { + courseViewModel.router.presentAlert( + alertTitle: "Warning", + alertMessage: "\(CourseLocalization.Alert.deleteAllVideos) \"\(courseStructure.displayName)\"?", + positiveAction: CoreLocalization.Alert.delete, + onCloseTapped: { [weak self] in + self?.courseViewModel.router.dismiss(animated: true) + }, + okTapped: { [weak self] in + guard let self else { return } + Task { + await self.downloadAll(isOn: false) + } + self.courseViewModel.router.dismiss(animated: true) + }, + type: .default(positiveAction: CoreLocalization.Alert.delete, image: CoreAssets.bgDelete.swiftUIImage) + ) + return + } + + if isOn { + courseViewModel.router.presentAlert( + alertTitle: "Warning", + alertMessage: "\(CourseLocalization.Alert.stopDownloading) \"\(courseStructure.displayName)\"?", + positiveAction: CoreLocalization.Alert.accept, + onCloseTapped: { [weak self] in + self?.courseViewModel.router.dismiss(animated: true) + }, + okTapped: { [weak self] in + guard let self else { return } + Task { + await self.downloadAll(isOn: false) + } + self.courseViewModel.router.dismiss(animated: true) + }, + type: .default(positiveAction: CoreLocalization.Alert.accept, image: nil) + ) + return + } + + await downloadAll(isOn: true) + } + + @MainActor + private func downloadAll(isOn: Bool) async { + let blocks = downloadableVerticals.flatMap { $0.vertical.childs } + + if isOn, courseViewModel.isShowedAllowLargeDownloadAlert(blocks: blocks) { + return + } + + if isOn { + let blocks = downloadableVerticals.filter { $0.state != .finished }.flatMap { $0.vertical.childs } + await courseViewModel.download( + state: .available, + blocks: blocks + ) + } else { + do { + try await courseViewModel.manager.cancelDownloading(courseId: courseStructure.id) + } catch { + debugLog(error) + } + + } + } + + // MARK: - Private intents + + private func toggleStateIsOn() { + let totalCount = courseViewModel.downloadableVerticals.count + let availableCount = courseViewModel.downloadableVerticals.filter { $0.state == .available }.count + let finishedCount = courseViewModel.downloadableVerticals.filter { $0.state == .finished }.count + let downloadingCount = courseViewModel.downloadableVerticals.filter { $0.state == .downloading }.count + + if downloadingCount == totalCount { + self.isOn = true + return + } + if totalCount == finishedCount { + self.isOn = true + return + } + if availableCount > 0 { + self.isOn = false + return + } + if downloadingCount == 0 { + self.isOn = false + return + } + + let isOn = totalCount - finishedCount == downloadingCount + self.isOn = isOn + } + + private func observers() { + currentDownloadTask = courseViewModel.manager.currentDownloadTask + toggleStateIsOn() + courseViewModel.$downloadableVerticals + .sink { [weak self] _ in + guard let self else { return } + self.currentDownloadTask = self.courseViewModel.manager.currentDownloadTask + self.toggleStateIsOn() + } + .store(in: &cancellables) + courseViewModel.manager.eventPublisher() + .sink { [weak self] state in + guard let self else { return } + if case .progress = state { + self.currentDownloadTask = self.courseViewModel.manager.currentDownloadTask + } + self.toggleStateIsOn() + } + .store(in: &cancellables) + } + + private func calculateSize(value: Double, percentage: Double) -> Double { + let val = value * percentage + return val / 100.0 + } + + private func blockToMB(data: Set, downloadQuality: DownloadQuality) -> Double { + data.reduce(0) { + $0 + Double($1.encodedVideo?.video(downloadQuality: downloadQuality)?.fileSize ?? 0) + } / 1024.0 / 1024.0 + } +} diff --git a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityBarView.swift b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityBarView.swift new file mode 100644 index 000000000..56d4fa158 --- /dev/null +++ b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityBarView.swift @@ -0,0 +1,71 @@ +// +// VideoDownloadQualityBarView.swift +// Course +// +// Created by Eugene Yatsenko on 04.01.2024. +// + +import SwiftUI +import Core +import Theme +import Combine +import Profile + +struct VideoDownloadQualityBarView: View { + + private var downloadQuality: DownloadQuality + private var onTap: (() -> Void)? + + init(downloadQuality: DownloadQuality, onTap: (() -> Void)? = nil) { + self.downloadQuality = downloadQuality + self.onTap = onTap + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + VStack { + Image(systemName: "gearshape") + .renderingMode(.template) + .resizable() + .scaledToFit() + .font(.system(size: 25, weight: .medium)) + .frame(width: 25, height: 25) + .accessibilityIdentifier("gearshape_image") + + } + .frame(width: 40, height: 40) + .padding(.leading, 15) + titles + Spacer() + } + .padding(.vertical, 10) + Divider() + } + .contentShape(Rectangle()) + .onTapGesture { onTap?() } + .accessibilityIdentifier("video_download_quality_bar") + } + + @ViewBuilder + private var titles: some View { + VStack(alignment: .leading) { + let videoDownloadQualityTitle = CoreLocalization.Settings.videoDownloadQualityTitle + Text(videoDownloadQualityTitle) + .lineLimit(1) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityElement(children: .ignore) + .accessibilityLabel(videoDownloadQualityTitle) + .accessibilityIdentifier("video_quality_title_text") + let settingsDescription = downloadQuality.settingsDescription + Text(settingsDescription) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + .accessibilityElement(children: .ignore) + .accessibilityLabel(settingsDescription) + .accessibilityIdentifier("video_quality_description_text") + } + .padding(.horizontal, 10) + } +} diff --git a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift new file mode 100644 index 000000000..f81c4b91d --- /dev/null +++ b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift @@ -0,0 +1,45 @@ +// +// VideoDownloadQualityContainerView.swift +// Course +// +// Created by Eugene Yatsenko on 04.01.2024. +// + +import SwiftUI +import Core +import Theme + +struct VideoDownloadQualityContainerView: View { + + @Environment(\.dismiss) private var dismiss + + private var downloadQuality: DownloadQuality + private var didSelect: ((DownloadQuality) -> Void)? + + init(downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?) { + self.downloadQuality = downloadQuality + self.didSelect = didSelect + } + + var body: some View { + NavigationView { + VideoDownloadQualityView( + downloadQuality: downloadQuality, + didSelect: didSelect + ) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .foregroundColor(Theme.Colors.accentColor) + } + .accessibilityIdentifier("close_button") + } + } + .padding(.top, 1) + } + } +} diff --git a/Course/Course/Presentation/Unit/CourseNavigationView.swift b/Course/Course/Presentation/Unit/CourseNavigationView.swift index a302323fd..4c8ef55fe 100644 --- a/Course/Course/Presentation/Unit/CourseNavigationView.swift +++ b/Course/Course/Presentation/Unit/CourseNavigationView.swift @@ -135,6 +135,7 @@ struct CourseNavigationView_Previews: PreviewProvider { router: CourseRouterMock(), analytics: CourseAnalyticsMock(), connectivity: Connectivity(), + storage: CourseStorageMock(), manager: DownloadManagerMock() ) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 9b702dc72..8159033ec 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -92,7 +92,7 @@ public struct CourseUnitView: View { } } } - switch LessonType.from(block) { + switch LessonType.from(block, streamingQuality: viewModel.streamingQuality) { // MARK: YouTube case let .youtube(url, blockID): if index >= viewModel.index - 1 && index <= viewModel.index + 1 { @@ -132,8 +132,9 @@ public struct CourseUnitView: View { playerStateSubject: playerStateSubject, languages: block.subtitles ?? [], isOnScreen: index == viewModel.index - ).frameLimit() - + ) + .frameLimit() + if !isHorizontal { Spacer(minLength: 150) } @@ -400,8 +401,7 @@ struct CourseUnitView_Previews: PreviewProvider { type: .video, displayName: "Lesson 1", studentUrl: "", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ), CourseBlock( blockId: "2", @@ -413,8 +413,7 @@ struct CourseUnitView_Previews: PreviewProvider { type: .video, displayName: "Lesson 2", studentUrl: "2", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ), CourseBlock( blockId: "3", @@ -426,8 +425,7 @@ struct CourseUnitView_Previews: PreviewProvider { type: .unknown, displayName: "Lesson 3", studentUrl: "3", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ), CourseBlock( blockId: "4", @@ -439,8 +437,7 @@ struct CourseUnitView_Previews: PreviewProvider { type: .unknown, displayName: "4", studentUrl: "4", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ), ] @@ -510,7 +507,8 @@ struct CourseUnitView_Previews: PreviewProvider { interactor: CourseInteractor.mock, router: CourseRouterMock(), analytics: CourseAnalyticsMock(), - connectivity: Connectivity(), + connectivity: Connectivity(), + storage: CourseStorageMock(), manager: DownloadManagerMock() ), sectionName: "") } diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index cd65b58c6..e6e988156 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -10,12 +10,12 @@ import Core public enum LessonType: Equatable { case web(url: String, injections: [WebviewInjection]) - case youtube(viewYouTubeUrl: String, blockID: String) + case youtube(youtubeVideoUrl: String, blockID: String) case video(videoUrl: String, blockID: String) case unknown(String) case discussion(String, String, String) - static func from(_ block: CourseBlock) -> Self { + static func from(_ block: CourseBlock, streamingQuality: StreamingQuality) -> Self { switch block.type { case .course, .chapter, .vertical, .sequential, .unknown: return .unknown(block.studentUrl) @@ -24,11 +24,11 @@ public enum LessonType: Equatable { case .discussion: return .discussion(block.topicId ?? "", block.id, block.displayName) case .video: - if block.youTubeUrl != nil, let encodedVideo = block.videoUrl { + if block.encodedVideo?.youtubeVideoUrl != nil, let encodedVideo = block.encodedVideo?.video(streamingQuality: streamingQuality)?.url { return .video(videoUrl: encodedVideo, blockID: block.id) - } else if let viewYouTubeUrl = block.youTubeUrl { - return .youtube(viewYouTubeUrl: viewYouTubeUrl, blockID: block.id) - } else if let encodedVideo = block.videoUrl { + } else if let youtubeVideoUrl = block.encodedVideo?.youtubeVideoUrl { + return .youtube(youtubeVideoUrl: youtubeVideoUrl, blockID: block.id) + } else if let encodedVideo = block.encodedVideo?.video(streamingQuality: streamingQuality)?.url { return .video(videoUrl: encodedVideo, blockID: block.id) } else { return .unknown(block.studentUrl) @@ -78,12 +78,17 @@ public class CourseUnitViewModel: ObservableObject { let router: CourseRouter let analytics: CourseAnalytics let connectivity: ConnectivityProtocol + let storage: CourseStorage private let manager: DownloadManagerProtocol private var subtitlesDownloaded: Bool = false let chapters: [CourseChapter] let chapterIndex: Int let sequentialIndex: Int - + + var streamingQuality: StreamingQuality { + storage.userSettings?.streamingQuality ?? .auto + } + func loadIndex() { index = selectLesson() } @@ -100,6 +105,7 @@ public class CourseUnitViewModel: ObservableObject { router: CourseRouter, analytics: CourseAnalytics, connectivity: ConnectivityProtocol, + storage: CourseStorage, manager: DownloadManagerProtocol ) { self.lessonID = lessonID @@ -115,6 +121,7 @@ public class CourseUnitViewModel: ObservableObject { self.analytics = analytics self.connectivity = connectivity self.manager = manager + self.storage = storage } private func selectLesson() -> Int { diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift index 8ce21dc08..612c164be 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift @@ -81,8 +81,7 @@ struct CourseUnitDropDownCell_Previews: PreviewProvider { type: .video, displayName: "Lesson 1", studentUrl: "", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ) ] ) diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift index ef3964152..60b0a501e 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift @@ -54,8 +54,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { type: .video, displayName: "Lesson 1", studentUrl: "", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ), CourseBlock( blockId: "2", @@ -67,8 +66,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { type: .video, displayName: "Lesson 2", studentUrl: "2", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ), CourseBlock( blockId: "3", @@ -80,8 +78,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { type: .unknown, displayName: "Lesson 3", studentUrl: "3", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ), CourseBlock( blockId: "4", @@ -93,8 +90,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { type: .unknown, displayName: "4", studentUrl: "4", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ) ] diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift index cf3db4309..1bee39cc4 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift @@ -67,8 +67,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { type: .video, displayName: "Lesson 1", studentUrl: "", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil + ), CourseBlock( blockId: "2", @@ -80,8 +80,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { type: .video, displayName: "Lesson 2", studentUrl: "2", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil + ), CourseBlock( blockId: "3", @@ -93,8 +93,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { type: .unknown, displayName: "Lesson 3", studentUrl: "3", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil + ), CourseBlock( blockId: "4", @@ -106,8 +106,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { type: .unknown, displayName: "4", studentUrl: "4", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ) ] diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 2af8e335c..a64b20db6 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -19,8 +19,16 @@ public enum CourseLocalization { public static let download = CourseLocalization.tr("Localizable", "ACCESSIBILITY.DOWNLOAD", fallback: "Download") } public enum Alert { + /// Accept + public static let accept = CourseLocalization.tr("Localizable", "ALERT.ACCEPT", fallback: "Accept") + /// Are you sure you want to delete all video(s) for + public static let deleteAllVideos = CourseLocalization.tr("Localizable", "ALERT.DELETE_ALL_VIDEOS", fallback: "Are you sure you want to delete all video(s) for") + /// Are you sure you want to delete video(s) for + public static let deleteVideos = CourseLocalization.tr("Localizable", "ALERT.DELETE_VIDEOS", fallback: "Are you sure you want to delete video(s) for") /// Rotate your device to view this video in full screen. public static let rotateDevice = CourseLocalization.tr("Localizable", "ALERT.ROTATE_DEVICE", fallback: "Rotate your device to view this video in full screen.") + /// 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 Courseware { /// Back to outline @@ -51,8 +59,8 @@ public enum CourseLocalization { public static let course = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.COURSE", fallback: "Course") /// Dates public static let dates = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.DATES", fallback: "Dates") - /// Discussion - public static let discussion = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.DISCUSSION", fallback: "Discussion") + /// Discussions + public static let discussions = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.DISCUSSIONS", fallback: "Discussions") /// Handouts public static let handouts = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.HANDOUTS", fallback: "Handouts") /// Handouts In developing @@ -60,6 +68,33 @@ public enum CourseLocalization { /// Videos public static let videos = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.VIDEOS", fallback: "Videos") } + public enum Download { + /// All videos downloaded + public static let allVideosDownloaded = CourseLocalization.tr("Localizable", "DOWNLOAD.ALL_VIDEOS_DOWNLOADED", fallback: "All videos downloaded") + /// You cannot change the download video quality when all videos are downloading + public static let changeQualityAlert = CourseLocalization.tr("Localizable", "DOWNLOAD.CHANGE_QUALITY_ALERT", fallback: "You cannot change the download video quality when all videos are downloading") + /// Download + public static let download = CourseLocalization.tr("Localizable", "DOWNLOAD.DOWNLOAD", fallback: "Download") + /// The videos you've selected are larger than 1 GB. Do you want to download these videos? + public static let downloadLargeFileMessage = CourseLocalization.tr("Localizable", "DOWNLOAD.DOWNLOAD_LARGE_FILE_MESSAGE", fallback: "The videos you've selected are larger than 1 GB. Do you want to download these videos?") + /// Download to device + public static let downloadToDevice = CourseLocalization.tr("Localizable", "DOWNLOAD.DOWNLOAD_TO_DEVICE", fallback: "Download to device") + /// Downloading videos... + public static let downloadingVideos = CourseLocalization.tr("Localizable", "DOWNLOAD.DOWNLOADING_VIDEOS", fallback: "Downloading videos...") + /// Downloads + public static let downloads = CourseLocalization.tr("Localizable", "DOWNLOAD.DOWNLOADS", fallback: "Downloads") + /// Your current download settings only allow downloads over Wi-Fi. + /// Please connect to a Wi-Fi network or change your download settings. + public static let noWifiMessage = CourseLocalization.tr("Localizable", "DOWNLOAD.NO_WIFI_MESSAGE", fallback: "Your current download settings only allow downloads over Wi-Fi.\nPlease connect to a Wi-Fi network or change your download settings.") + /// Remaining + public static let remaining = CourseLocalization.tr("Localizable", "DOWNLOAD.REMAINING", fallback: "Remaining") + /// Total + public static let total = CourseLocalization.tr("Localizable", "DOWNLOAD.TOTAL", fallback: "Total") + /// Untitled + public static let untitled = CourseLocalization.tr("Localizable", "DOWNLOAD.UNTITLED", fallback: "Untitled") + /// Videos + public static let videos = CourseLocalization.tr("Localizable", "DOWNLOAD.VIDEOS", fallback: "Videos") + } public enum Error { /// Course component not found, please reload public static let componentNotFount = CourseLocalization.tr("Localizable", "ERROR.COMPONENT_NOT_FOUNT", fallback: "Course component not found, please reload") diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index ea370aed7..ee0be8b1c 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -30,11 +30,15 @@ "ERROR.COMPONENT_NOT_FOUNT" = "Course component not found, please reload"; "ALERT.ROTATE_DEVICE" = "Rotate your device to view this video in full screen."; +"ALERT.ACCEPT" = "Accept"; +"ALERT.DELETE_ALL_VIDEOS" = "Are you sure you want to delete all video(s) for"; +"ALERT.DELETE_VIDEOS" = "Are you sure you want to delete video(s) for"; +"ALERT.STOP_DOWNLOADING" = "Turning off the switch will stop downloading and delete all downloaded videos for"; "COURSE_CONTAINER.COURSE" = "Course"; "COURSE_CONTAINER.VIDEOS" = "Videos"; "COURSE_CONTAINER.DATES" = "Dates"; -"COURSE_CONTAINER.DISCUSSION" = "Discussion"; +"COURSE_CONTAINER.DISCUSSIONS" = "Discussions"; "COURSE_CONTAINER.HANDOUTS" = "Handouts"; "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING" = "Handouts In developing"; @@ -52,3 +56,17 @@ "ACCESSIBILITY.DOWNLOAD" = "Download"; "ACCESSIBILITY.CANCEL_DOWNLOAD" = "Cancel download"; "ACCESSIBILITY.DELETE_DOWNLOAD" = "Delete download"; + +"DOWNLOAD.DOWNLOADS" = "Downloads"; +"DOWNLOAD.DOWNLOAD" = "Download"; +"DOWNLOAD.ALL_VIDEOS_DOWNLOADED" = "All videos downloaded"; +"DOWNLOAD.DOWNLOADING_VIDEOS" = "Downloading videos..."; +"DOWNLOAD.DOWNLOAD_TO_DEVICE" = "Download to device"; +"DOWNLOAD.VIDEOS" = "Videos"; +"DOWNLOAD.REMAINING" = "Remaining"; +"DOWNLOAD.UNTITLED"= "Untitled"; +"DOWNLOAD.TOTAL"= "Total"; + +"DOWNLOAD.CHANGE_QUALITY_ALERT" = "You cannot change the download video quality when all videos are downloading"; +"DOWNLOAD.DOWNLOAD_LARGE_FILE_MESSAGE" = "The videos you've selected are larger than 1 GB. Do you want to download these videos?"; +"DOWNLOAD.NO_WIFI_MESSAGE" = "Your current download settings only allow downloads over Wi-Fi.\nPlease connect to a Wi-Fi network or change your download settings."; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index 59c7bce6a..e56d46a37 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -29,11 +29,15 @@ "ERROR.COMPONENT_NOT_FOUNT" = "Course component not found, please reload"; "ALERT.ROTATE_DEVICE" = "Поверніть пристрій, щоб переглянути це відео на весь екран."; +"ALERT.ACCEPT" = "Accept"; +"ALERT.DELETE_ALL_VIDEOS" = "Are you sure you want to delete all video(s) for"; +"ALERT.DELETE_VIDEOS" = "Are you sure you want to delete video(s) for"; +"ALERT.STOP_DOWNLOADING" = "Turning off the switch will stop downloading and delete all downloaded videos for"; "COURSE_CONTAINER.COURSE" = "Курс"; "COURSE_CONTAINER.VIDEOS" = "Всі відео"; -//"COURSE_CONTAINER.DATES" = "Dates"; -"COURSE_CONTAINER.DISCUSSION" = "Дискусії"; +"COURSE_CONTAINER.DATES" = "Dates"; +"COURSE_CONTAINER.DISCUSSIONS" = "Дискусії"; "COURSE_CONTAINER.HANDOUTS" = "Матеріали"; "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING" = "Матеріали в процесі розробки"; @@ -51,3 +55,17 @@ "ACCESSIBILITY.DOWNLOAD" = "Скачати"; "ACCESSIBILITY.CANCEL_DOWNLOAD" = "Скасувати завантаження"; "ACCESSIBILITY.DELETE_DOWNLOAD" = "Видалити файл"; + +"DOWNLOAD.DOWNLOADS" = "Downloads"; +"DOWNLOAD.DOWNLOAD" = "Download"; +"DOWNLOAD.ALL_VIDEOS_DOWNLOADED" = "All videos downloaded"; +"DOWNLOAD.DOWNLOADING_VIDEOS" = "Downloading videos..."; +"DOWNLOAD.DOWNLOAD_TO_DEVICE" = "Download to device"; +"DOWNLOAD.VIDEOS" = "Videos"; +"DOWNLOAD.REMAINING" = "Remaining"; +"DOWNLOAD.UNTITLED"= "Untitled"; +"DOWNLOAD.TOTAL"= "Total"; + +"DOWNLOAD.CHANGE_QUALITY_ALERT" = "You cannot change the download video quality when all videos are downloading"; +"DOWNLOAD.DOWNLOAD_LARGE_FILE_MESSAGE" = "The videos you've selected are larger than 1 GB. Do you want to download these videos?"; +"DOWNLOAD.NO_WIFI_MESSAGE" = "Your current download settings only allow downloads over Wi-Fi.\nPlease connect to a Wi-Fi network or change your download settings."; diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index af021d83a..5db12ccbf 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -2067,6 +2067,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { if scopes.contains(.perform) { methodPerformValues = [] } } + public var currentDownloadTask: DownloadDataTask? { + get { invocations.append(.p_currentDownloadTask_get); return __p_currentDownloadTask ?? optionalGivenGetterValue(.p_currentDownloadTask_get, "DownloadManagerProtocolMock - stub value for currentDownloadTask was not defined") } + } + private var __p_currentDownloadTask: (DownloadDataTask)? + @@ -2085,29 +2090,44 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func addToDownloadQueue(blocks: [CourseBlock]) throws { - addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) + open func eventPublisher() -> AnyPublisher { + addInvocation(.m_eventPublisher) + let perform = methodPerformValue(.m_eventPublisher) as? () -> Void + perform?() + var __value: AnyPublisher do { - _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void - } catch MockError.notStubed { - // do nothing + __value = try methodReturnValue(.m_eventPublisher).casted() } catch { - throw error + onFatalFailure("Stub return value not specified for eventPublisher(). Use given") + Failure("Stub return value not specified for eventPublisher(). Use given") + } + return __value + } + + open func getDownloadTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasks) + let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadTasks(). Use given") + Failure("Stub return value not specified for getDownloadTasks(). Use given") } + return __value } - open func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { - addInvocation(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) - let perform = methodPerformValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + open func getDownloadTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void perform?(`courseId`) - var __value: [DownloadData] + var __value: [DownloadDataTask] do { - __value = try methodReturnValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))).casted() + __value = try methodReturnValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))).casted() } catch { - onFatalFailure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") - Failure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + onFatalFailure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") } return __value } @@ -2125,12 +2145,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } - open func resumeDownloading() throws { - addInvocation(.m_resumeDownloading) - let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void - perform?() + open func cancelDownloading(task: DownloadDataTask) throws { + addInvocation(.m_cancelDownloading__task_task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_cancelDownloading__task_task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) do { - _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + _ = try methodReturnValue(.m_cancelDownloading__task_task(Parameter.value(`task`))).casted() as Void } catch MockError.notStubed { // do nothing } catch { @@ -2138,10 +2158,10 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } - open func pauseDownloading() { - addInvocation(.m_pauseDownloading) - let perform = methodPerformValue(.m_pauseDownloading) as? () -> Void - perform?() + open func cancelDownloading(courseId: String) { + addInvocation(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) } open func deleteFile(blocks: [CourseBlock]) { @@ -2169,28 +2189,72 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func isLargeVideosSize(blocks: [CourseBlock]) -> Bool { + addInvocation(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + Failure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + } + return __value + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + fileprivate enum MethodType { case m_publisher - case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) - case m_getDownloadsForCourse__courseId(Parameter) + case m_eventPublisher + case m_getDownloadTasks + case m_getDownloadTasksForCourse__courseId(Parameter) case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) - case m_resumeDownloading - case m_pauseDownloading + case m_cancelDownloading__task_task(Parameter) + case m_cancelDownloading__courseId_courseId(Parameter) case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_resumeDownloading + case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.m_publisher, .m_publisher): return .match - case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + case (.m_eventPublisher, .m_eventPublisher): return .match - case (.m_getDownloadsForCourse__courseId(let lhsCourseid), .m_getDownloadsForCourse__courseId(let rhsCourseid)): + case (.m_getDownloadTasks, .m_getDownloadTasks): return .match + + case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) return Matcher.ComparisonResult(results) @@ -2201,9 +2265,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) - case (.m_resumeDownloading, .m_resumeDownloading): return .match + case (.m_cancelDownloading__task_task(let lhsTask), .m_cancelDownloading__task_task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "task")) + return Matcher.ComparisonResult(results) - case (.m_pauseDownloading, .m_pauseDownloading): return .match + case (.m_cancelDownloading__courseId_courseId(let lhsCourseid), .m_cancelDownloading__courseId_courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + return Matcher.ComparisonResult(results) case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] @@ -2216,6 +2286,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) + + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_resumeDownloading, .m_resumeDownloading): return .match + case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } } @@ -2223,27 +2306,37 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { func intValue() -> Int { switch self { case .m_publisher: return 0 - case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue - case let .m_getDownloadsForCourse__courseId(p0): return p0.intValue + case .m_eventPublisher: return 0 + case .m_getDownloadTasks: return 0 + case let .m_getDownloadTasksForCourse__courseId(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue - case .m_resumeDownloading: return 0 - case .m_pauseDownloading: return 0 + case let .m_cancelDownloading__task_task(p0): return p0.intValue + case let .m_cancelDownloading__courseId_courseId(p0): return p0.intValue case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_resumeDownloading: return 0 + case .p_currentDownloadTask_get: return 0 } } func assertionName() -> String { switch self { case .m_publisher: return ".publisher()" - case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" - case .m_getDownloadsForCourse__courseId: return ".getDownloadsForCourse(_:)" + case .m_eventPublisher: return ".eventPublisher()" + case .m_getDownloadTasks: return ".getDownloadTasks()" + case .m_getDownloadTasksForCourse__courseId: return ".getDownloadTasksForCourse(_:)" case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" - case .m_resumeDownloading: return ".resumeDownloading()" - case .m_pauseDownloading: return ".pauseDownloading()" + case .m_cancelDownloading__task_task: return ".cancelDownloading(task:)" + case .m_cancelDownloading__courseId_courseId: return ".cancelDownloading(courseId:)" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } } @@ -2256,16 +2349,28 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { super.init(products) } + public static func currentDownloadTask(getter defaultValue: DownloadDataTask?...) -> PropertyStub { + return Given(method: .p_currentDownloadTask_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } public static func publisher(willReturn: AnyPublisher...) -> MethodStub { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getDownloadsForCourse(_ courseId: Parameter, willReturn: [DownloadData]...) -> MethodStub { - return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func eventPublisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { let willReturn: [AnyPublisher] = [] let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2273,10 +2378,24 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func getDownloadsForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadData]>) -> Void) -> MethodStub { - let willReturn: [[DownloadData]] = [] - let given: Given = { return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([DownloadData]).self) + public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) willProduce(stubber) return given } @@ -2287,13 +2406,10 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) willProduce(stubber) return given } @@ -2307,6 +2423,26 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func cancelDownloading(task: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(task: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } public static func resumeDownloading(willThrow: Error...) -> MethodStub { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) } @@ -2323,14 +2459,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { fileprivate var method: MethodType public static func publisher() -> Verify { return Verify(method: .m_publisher)} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} - public static func getDownloadsForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadsForCourse__courseId(`courseId`))} + public static func eventPublisher() -> Verify { return Verify(method: .m_eventPublisher)} + public static func getDownloadTasks() -> Verify { return Verify(method: .m_getDownloadTasks)} + public static func getDownloadTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadTasksForCourse__courseId(`courseId`))} public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} - public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} - public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} + public static func cancelDownloading(task: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__task_task(`task`))} + public static func cancelDownloading(courseId: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseId(`courseId`))} public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} + public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } public struct Perform { @@ -2340,20 +2481,23 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func publisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_publisher, performs: perform) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + public static func eventPublisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_eventPublisher, performs: perform) } - public static func getDownloadsForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_getDownloadsForCourse__courseId(`courseId`), performs: perform) + public static func getDownloadTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadTasks, performs: perform) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadTasksForCourse__courseId(`courseId`), performs: perform) } public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) } - public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_resumeDownloading, performs: perform) + public static func cancelDownloading(task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__task_task(`task`), performs: perform) } - public static func pauseDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_pauseDownloading, performs: perform) + public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) } public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) @@ -2364,6 +2508,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) + } + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) + } } public func given(_ method: Given) { diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index dc75d4402..a62719c81 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -24,7 +24,8 @@ final class CourseContainerViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() Given(connectivity, .isInternetAvaliable(getter: true)) - + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + let viewModel = CourseContainerViewModel( interactor: interactor, authInteractor: authInteractor, @@ -32,7 +33,8 @@ final class CourseContainerViewModelTests: XCTestCase { analytics: analytics, config: config, connectivity: connectivity, - manager: DownloadManagerMock(), + manager: DownloadManagerMock(), + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, @@ -50,8 +52,7 @@ final class CourseContainerViewModelTests: XCTestCase { type: .problem, displayName: "", studentUrl: "", - videoUrl: nil, - youTubeUrl: nil + encodedVideo: nil ) let vertical = CourseVertical( blockId: "", @@ -126,7 +127,8 @@ final class CourseContainerViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() Given(connectivity, .isInternetAvaliable(getter: false)) - + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + let viewModel = CourseContainerViewModel( interactor: interactor, authInteractor: authInteractor, @@ -134,7 +136,8 @@ final class CourseContainerViewModelTests: XCTestCase { analytics: analytics, config: config, connectivity: connectivity, - manager: DownloadManagerMock(), + manager: DownloadManagerMock(), + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, @@ -180,7 +183,8 @@ final class CourseContainerViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() Given(connectivity, .isInternetAvaliable(getter: true)) - + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + let viewModel = CourseContainerViewModel( interactor: interactor, authInteractor: authInteractor, @@ -188,7 +192,8 @@ final class CourseContainerViewModelTests: XCTestCase { analytics: analytics, config: config, connectivity: connectivity, - manager: DownloadManagerMock(), + manager: DownloadManagerMock(), + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, @@ -220,7 +225,8 @@ final class CourseContainerViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() Given(connectivity, .isInternetAvaliable(getter: true)) - + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + let viewModel = CourseContainerViewModel( interactor: interactor, authInteractor: authInteractor, @@ -229,6 +235,7 @@ final class CourseContainerViewModelTests: XCTestCase { config: config, connectivity: connectivity, manager: DownloadManagerMock(), + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, @@ -257,7 +264,8 @@ final class CourseContainerViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() Given(connectivity, .isInternetAvaliable(getter: true)) - + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + let viewModel = CourseContainerViewModel( interactor: interactor, authInteractor: authInteractor, @@ -265,7 +273,8 @@ final class CourseContainerViewModelTests: XCTestCase { analytics: analytics, config: config, connectivity: connectivity, - manager: DownloadManagerMock(), + manager: DownloadManagerMock(), + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, @@ -294,7 +303,8 @@ final class CourseContainerViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() Given(connectivity, .isInternetAvaliable(getter: true)) - + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + let viewModel = CourseContainerViewModel( interactor: interactor, authInteractor: authInteractor, @@ -302,7 +312,8 @@ final class CourseContainerViewModelTests: XCTestCase { analytics: analytics, config: config, connectivity: connectivity, - manager: DownloadManagerMock(), + manager: DownloadManagerMock(), + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, @@ -323,7 +334,7 @@ final class CourseContainerViewModelTests: XCTestCase { Verify(analytics, .courseOutlineHandoutsTabClicked(courseId: .value("1"), courseName: .value("name"))) } - func testOnDownloadViewAvailableTap() { + func testOnDownloadViewAvailableTap() async throws { let interactor = CourseInteractorProtocolMock() let authInteractor = AuthInteractorProtocolMock() let router = CourseRouterMock() @@ -331,11 +342,94 @@ final class CourseContainerViewModelTests: XCTestCase { let config = ConfigMock() let connectivity = ConnectivityProtocolMock() let downloadManager = DownloadManagerProtocolMock() - + + let blockId = "chapter:block:1" + + let block = CourseBlock( + blockId: blockId, + id: "1", + courseId: "123", + topicId: "", + graded: false, + completion: 0, + type: .video, + displayName: "", + studentUrl: "", + encodedVideo: .init( + fallback: nil, + youtube: nil, + desktopMP4: .init(url: "test.mp4", fileSize: 1000, streamPriority: 1), + mobileHigh: nil, + mobileLow: nil, + hls: nil + ) + + ) + + let vertical = CourseVertical( + blockId: blockId, + id: "vertical1", + courseId: "123", + displayName: "", + type: .vertical, + completion: 0, + childs: [block] + ) + + let sequential = CourseSequential( + blockId: blockId, + id: blockId, + displayName: "", + type: .chapter, + completion: 0, + childs: [vertical] + ) + + let chapter = CourseChapter( + blockId: blockId, + id: "1", + displayName: "Chapter 1", + type: .chapter, + childs: [sequential] + ) + + let courseStructure = CourseStructure( + id: "123", + graded: true, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "", + topicID: nil, + childs: [chapter], + media: DataLayer.CourseMedia(image: DataLayer.Image( + raw: "", + small: "", + large: "" + )), + certificate: nil + ) + + let downloadData = DownloadDataTask( + id: "1", + courseId: "course123", + url: "https://example.com/file.mp4", + fileName: "file.mp4", + displayName: "file.mp4", + progress: 0, + resumeData: nil, + state: .inProgress, + type: .video, + fileSize: 1000 + ) + Given(connectivity, .isInternetAvaliable(getter: true)) - + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) - + Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) + Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [downloadData])) + let viewModel = CourseContainerViewModel( interactor: interactor, authInteractor: authInteractor, @@ -344,33 +438,33 @@ final class CourseContainerViewModelTests: XCTestCase { config: config, connectivity: connectivity, manager: downloadManager, + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil ) - - let blockId = "chapter:block:1" - - let chapter = CourseChapter( - blockId: blockId, - id: "1", - displayName: "Chapter 1", - type: .chapter, - childs: [] - ) - - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: blockId, - state: .available - ) - + viewModel.courseStructure = courseStructure + await viewModel.setDownloadsStates() + + await viewModel.onDownloadViewTap( + chapter: chapter, + blockId: blockId, + state: .available + ) + + let exp = expectation(description: "Task Starting") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + XCTAssertEqual(viewModel.sequentialsDownloadState[blockId], .downloading) } - func testOnDownloadViewDownloadingTap() { + func testOnDownloadViewDownloadingTap() async { let interactor = CourseInteractorProtocolMock() let authInteractor = AuthInteractorProtocolMock() let router = CourseRouterMock() @@ -379,10 +473,79 @@ final class CourseContainerViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let downloadManager = DownloadManagerProtocolMock() + let blockId = "chapter:block:1" + + let block = CourseBlock( + blockId: blockId, + id: "1", + courseId: "123", + topicId: "", + graded: false, + completion: 0, + type: .video, + displayName: "", + studentUrl: "", + encodedVideo: .init( + fallback: nil, + youtube: nil, + desktopMP4: .init(url: "test.mp4", fileSize: 1000, streamPriority: 1), + mobileHigh: nil, + mobileLow: nil, + hls: nil + ) + ) + + let vertical = CourseVertical( + blockId: blockId, + id: "vertical1", + courseId: "123", + displayName: "", + type: .vertical, + completion: 0, + childs: [block] + ) + + let sequential = CourseSequential( + blockId: blockId, + id: blockId, + displayName: "", + type: .chapter, + completion: 0, + childs: [vertical] + ) + + let chapter = CourseChapter( + blockId: blockId, + id: "1", + displayName: "Chapter 1", + type: .chapter, + childs: [sequential] + ) + + let courseStructure = CourseStructure( + id: "123", + graded: true, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "", + topicID: nil, + childs: [chapter], + media: DataLayer.CourseMedia(image: DataLayer.Image( + raw: "", + small: "", + large: "" + )), + certificate: nil + ) + Given(connectivity, .isInternetAvaliable(getter: true)) - + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) - + Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) + Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [])) + let viewModel = CourseContainerViewModel( interactor: interactor, authInteractor: authInteractor, @@ -391,33 +554,33 @@ final class CourseContainerViewModelTests: XCTestCase { config: config, connectivity: connectivity, manager: downloadManager, + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil ) - - let blockId = "chapter:block:1" - - let chapter = CourseChapter( - blockId: blockId, - id: "1", - displayName: "Chapter 1", - type: .chapter, - childs: [] - ) - - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: blockId, - state: .downloading - ) - + viewModel.courseStructure = courseStructure + await viewModel.setDownloadsStates() + + await viewModel.onDownloadViewTap( + chapter: chapter, + blockId: blockId, + state: .downloading + ) + + let exp = expectation(description: "Task Starting") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + XCTAssertEqual(viewModel.sequentialsDownloadState[blockId], .available) } - func testOnDownloadViewFinishedTap() { + func testOnDownloadViewFinishedTap() async throws { let interactor = CourseInteractorProtocolMock() let authInteractor = AuthInteractorProtocolMock() let router = CourseRouterMock() @@ -426,10 +589,79 @@ final class CourseContainerViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let downloadManager = DownloadManagerProtocolMock() + let blockId = "chapter:block:1" + + let block = CourseBlock( + blockId: blockId, + id: "1", + courseId: "123", + topicId: "", + graded: false, + completion: 0, + type: .video, + displayName: "", + studentUrl: "", + encodedVideo: .init( + fallback: nil, + youtube: nil, + desktopMP4: .init(url: "test.mp4", fileSize: 1000, streamPriority: 1), + mobileHigh: nil, + mobileLow: nil, + hls: nil + ) + ) + + let vertical = CourseVertical( + blockId: blockId, + id: "vertical1", + courseId: "123", + displayName: "", + type: .vertical, + completion: 0, + childs: [block] + ) + + let sequential = CourseSequential( + blockId: blockId, + id: blockId, + displayName: "", + type: .chapter, + completion: 0, + childs: [vertical] + ) + + let chapter = CourseChapter( + blockId: blockId, + id: "1", + displayName: "Chapter 1", + type: .chapter, + childs: [sequential] + ) + + let courseStructure = CourseStructure( + id: "123", + graded: true, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "", + topicID: nil, + childs: [chapter], + media: DataLayer.CourseMedia(image: DataLayer.Image( + raw: "", + small: "", + large: "" + )), + certificate: nil + ) + Given(connectivity, .isInternetAvaliable(getter: true)) - + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) - + Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) + Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [])) + let viewModel = CourseContainerViewModel( interactor: interactor, authInteractor: authInteractor, @@ -438,33 +670,34 @@ final class CourseContainerViewModelTests: XCTestCase { config: config, connectivity: connectivity, manager: downloadManager, + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil ) - - let blockId = "chapter:block:1" - - let chapter = CourseChapter( - blockId: blockId, - id: "1", - displayName: "Chapter 1", - type: .chapter, - childs: [] - ) - - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: blockId, - state: .finished - ) - + viewModel.courseStructure = courseStructure + await viewModel.setDownloadsStates() + + await viewModel.onDownloadViewTap( + chapter: chapter, + blockId: blockId, + state: .finished + ) + + let exp = expectation(description: "Task Starting") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + XCTAssertEqual(viewModel.sequentialsDownloadState[blockId], .available) + } - func testSetDownloadsStatesAvailable() { + func testSetDownloadsStatesAvailable() async throws { let interactor = CourseInteractorProtocolMock() let authInteractor = AuthInteractorProtocolMock() let router = CourseRouterMock() @@ -473,8 +706,10 @@ final class CourseContainerViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let downloadManager = DownloadManagerProtocolMock() + let blockId = "chapter:block:1" + let block = CourseBlock( - blockId: "block:1", + blockId: blockId, id: "1", courseId: "123", topicId: "", @@ -483,11 +718,18 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", - videoUrl: "https://example.com/file.mp4", - youTubeUrl: nil + encodedVideo: .init( + fallback: nil, + youtube: nil, + desktopMP4: .init(url: "test.mp4", fileSize: 1000, streamPriority: 1), + mobileHigh: nil, + mobileLow: nil, + hls: nil + ) ) + let vertical = CourseVertical( - blockId: "block:vertical1", + blockId: blockId, id: "vertical1", courseId: "123", displayName: "", @@ -495,24 +737,24 @@ final class CourseContainerViewModelTests: XCTestCase { completion: 0, childs: [block] ) + let sequential = CourseSequential( - blockId: "block:sequential1", - id: "sequential1", + blockId: blockId, + id: blockId, displayName: "", type: .chapter, completion: 0, childs: [vertical] ) + let chapter = CourseChapter( - blockId: "", - id: "", - displayName: "", + blockId: blockId, + id: "1", + displayName: "Chapter 1", type: .chapter, childs: [sequential] ) - - let childs = [chapter] - + let courseStructure = CourseStructure( id: "123", graded: true, @@ -521,7 +763,7 @@ final class CourseContainerViewModelTests: XCTestCase { encodedVideo: "", displayName: "", topicID: nil, - childs: childs, + childs: [chapter], media: DataLayer.CourseMedia(image: DataLayer.Image( raw: "", small: "", @@ -529,12 +771,14 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil ) - + Given(connectivity, .isInternetAvaliable(getter: true)) - - Given(downloadManager, .publisher(willReturn: Just(1).eraseToAnyPublisher())) - Given(downloadManager, .getDownloadsForCourse(.any, willReturn: [])) - + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + + Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) + Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) + Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [])) + let viewModel = CourseContainerViewModel( interactor: interactor, authInteractor: authInteractor, @@ -543,6 +787,7 @@ final class CourseContainerViewModelTests: XCTestCase { config: config, connectivity: connectivity, manager: downloadManager, + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, @@ -550,18 +795,19 @@ final class CourseContainerViewModelTests: XCTestCase { enrollmentEnd: nil ) viewModel.courseStructure = courseStructure - - let exp = expectation(description: "DispatchQueue.main.async Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + await viewModel.setDownloadsStates() + + let exp = expectation(description: "Task Starting") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exp.fulfill() } - + wait(for: [exp], timeout: 1) - + XCTAssertEqual(viewModel.sequentialsDownloadState[sequential.id], .available) } - func testSetDownloadsStatesDownloading() { + func testSetDownloadsStatesDownloading() async throws { let interactor = CourseInteractorProtocolMock() let authInteractor = AuthInteractorProtocolMock() let router = CourseRouterMock() @@ -570,8 +816,10 @@ final class CourseContainerViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let downloadManager = DownloadManagerProtocolMock() + let blockId = "chapter:block:1" + let block = CourseBlock( - blockId: "block:1", + blockId: blockId, id: "1", courseId: "123", topicId: "", @@ -580,11 +828,18 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", - videoUrl: "https://example.com/file.mp4", - youTubeUrl: nil + encodedVideo: .init( + fallback: nil, + youtube: nil, + desktopMP4: .init(url: "test.mp4", fileSize: 1000, streamPriority: 1), + mobileHigh: nil, + mobileLow: nil, + hls: nil + ) ) + let vertical = CourseVertical( - blockId: "block:vertical1", + blockId: blockId, id: "vertical1", courseId: "123", displayName: "", @@ -592,24 +847,24 @@ final class CourseContainerViewModelTests: XCTestCase { completion: 0, childs: [block] ) + let sequential = CourseSequential( - blockId: "block:sequential1", - id: "sequential1", + blockId: blockId, + id: blockId, displayName: "", type: .chapter, completion: 0, childs: [vertical] ) + let chapter = CourseChapter( - blockId: "", - id: "", - displayName: "", + blockId: blockId, + id: "1", + displayName: "Chapter 1", type: .chapter, childs: [sequential] ) - let childs = [chapter] - let courseStructure = CourseStructure( id: "123", graded: true, @@ -618,7 +873,7 @@ final class CourseContainerViewModelTests: XCTestCase { encodedVideo: "", displayName: "", topicID: nil, - childs: childs, + childs: [chapter], media: DataLayer.CourseMedia(image: DataLayer.Image( raw: "", small: "", @@ -627,21 +882,25 @@ final class CourseContainerViewModelTests: XCTestCase { certificate: nil ) - let downloadData = DownloadData( + let downloadData = DownloadDataTask( id: "1", courseId: "course123", url: "https://example.com/file.mp4", fileName: "file.mp4", + displayName: "file.mp4", progress: 0, resumeData: nil, - state: .waiting, - type: .video + state: .inProgress, + type: .video, + fileSize: 1000 ) Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) - Given(downloadManager, .publisher(willReturn: Just(1).eraseToAnyPublisher())) - Given(downloadManager, .getDownloadsForCourse(.any, willReturn: [downloadData])) + Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) + Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) + Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [downloadData])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -651,6 +910,7 @@ final class CourseContainerViewModelTests: XCTestCase { config: config, connectivity: connectivity, manager: downloadManager, + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, @@ -658,9 +918,10 @@ final class CourseContainerViewModelTests: XCTestCase { enrollmentEnd: nil ) viewModel.courseStructure = courseStructure + await viewModel.setDownloadsStates() - let exp = expectation(description: "DispatchQueue.main.async Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + let exp = expectation(description: "Task Starting") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exp.fulfill() } @@ -669,7 +930,7 @@ final class CourseContainerViewModelTests: XCTestCase { XCTAssertEqual(viewModel.sequentialsDownloadState[sequential.id], .downloading) } - func testSetDownloadsStatesFinished() { + func testSetDownloadsStatesFinished() async throws { let interactor = CourseInteractorProtocolMock() let authInteractor = AuthInteractorProtocolMock() let router = CourseRouterMock() @@ -678,8 +939,10 @@ final class CourseContainerViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let downloadManager = DownloadManagerProtocolMock() + let blockId = "chapter:block:1" + let block = CourseBlock( - blockId: "block:1", + blockId: blockId, id: "1", courseId: "123", topicId: "", @@ -688,11 +951,18 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", - videoUrl: "https://example.com/file.mp4", - youTubeUrl: nil + encodedVideo: .init( + fallback: nil, + youtube: nil, + desktopMP4: .init(url: "test.mp4", fileSize: 1000, streamPriority: 1), + mobileHigh: nil, + mobileLow: nil, + hls: nil + ) ) + let vertical = CourseVertical( - blockId: "block:vertical1", + blockId: blockId, id: "vertical1", courseId: "123", displayName: "", @@ -700,24 +970,24 @@ final class CourseContainerViewModelTests: XCTestCase { completion: 0, childs: [block] ) + let sequential = CourseSequential( - blockId: "block:sequential1", - id: "sequential1", + blockId: blockId, + id: blockId, displayName: "", type: .chapter, completion: 0, childs: [vertical] ) + let chapter = CourseChapter( - blockId: "", - id: "", - displayName: "", + blockId: blockId, + id: "1", + displayName: "Chapter 1", type: .chapter, childs: [sequential] ) - let childs = [chapter] - let courseStructure = CourseStructure( id: "123", graded: true, @@ -726,7 +996,7 @@ final class CourseContainerViewModelTests: XCTestCase { encodedVideo: "", displayName: "", topicID: nil, - childs: childs, + childs: [chapter], media: DataLayer.CourseMedia(image: DataLayer.Image( raw: "", small: "", @@ -735,21 +1005,25 @@ final class CourseContainerViewModelTests: XCTestCase { certificate: nil ) - let downloadData = DownloadData( + let downloadData = DownloadDataTask( id: "1", courseId: "course123", url: "https://example.com/file.mp4", fileName: "file.mp4", + displayName: "file.mp4", progress: 0, resumeData: nil, state: .finished, - type: .video + type: .video, + fileSize: 1000 ) Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) - Given(downloadManager, .publisher(willReturn: Just(1).eraseToAnyPublisher())) - Given(downloadManager, .getDownloadsForCourse(.any, willReturn: [downloadData])) + Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) + Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) + Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [downloadData])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -759,6 +1033,7 @@ final class CourseContainerViewModelTests: XCTestCase { config: config, connectivity: connectivity, manager: downloadManager, + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, @@ -766,18 +1041,18 @@ final class CourseContainerViewModelTests: XCTestCase { enrollmentEnd: nil ) viewModel.courseStructure = courseStructure + await viewModel.setDownloadsStates() - let exp = expectation(description: "DispatchQueue.main.async Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + let exp = expectation(description: "Task Starting") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exp.fulfill() } wait(for: [exp], timeout: 1) - XCTAssertEqual(viewModel.sequentialsDownloadState[sequential.id], .finished) } - func testSetDownloadsStatesPartiallyFinished() { + func testSetDownloadsStatesPartiallyFinished() async throws { let interactor = CourseInteractorProtocolMock() let authInteractor = AuthInteractorProtocolMock() let router = CourseRouterMock() @@ -786,8 +1061,10 @@ final class CourseContainerViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let downloadManager = DownloadManagerProtocolMock() - let block1 = CourseBlock( - blockId: "block:1", + let blockId = "chapter:block:1" + + let block = CourseBlock( + blockId: blockId, id: "1", courseId: "123", topicId: "", @@ -796,12 +1073,18 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", - videoUrl: "https://example.com/file.mp4", - youTubeUrl: nil + encodedVideo: .init( + fallback: nil, + youtube: nil, + desktopMP4: .init(url: "test.mp4", fileSize: 1000, streamPriority: 1), + mobileHigh: nil, + mobileLow: nil, + hls: nil + ) ) let block2 = CourseBlock( - blockId: "block:2", - id: "2", + blockId: "123", + id: "1213", courseId: "123", topicId: "", graded: false, @@ -809,36 +1092,43 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", - videoUrl: "https://example.com/file2.mp4", - youTubeUrl: nil + encodedVideo: .init( + fallback: nil, + youtube: nil, + desktopMP4: .init(url: "test.mp4", fileSize: 1000, streamPriority: 1), + mobileHigh: nil, + mobileLow: nil, + hls: nil + ) ) + let vertical = CourseVertical( - blockId: "block:vertical1", + blockId: blockId, id: "vertical1", courseId: "123", displayName: "", type: .vertical, completion: 0, - childs: [block1, block2] + childs: [block, block2] ) + let sequential = CourseSequential( - blockId: "block:sequential1", - id: "sequential1", + blockId: blockId, + id: blockId, displayName: "", type: .chapter, completion: 0, childs: [vertical] ) + let chapter = CourseChapter( - blockId: "", - id: "", - displayName: "", + blockId: blockId, + id: "1", + displayName: "Chapter 1", type: .chapter, childs: [sequential] ) - let childs = [chapter] - let courseStructure = CourseStructure( id: "123", graded: true, @@ -847,7 +1137,7 @@ final class CourseContainerViewModelTests: XCTestCase { encodedVideo: "", displayName: "", topicID: nil, - childs: childs, + childs: [chapter], media: DataLayer.CourseMedia(image: DataLayer.Image( raw: "", small: "", @@ -856,21 +1146,25 @@ final class CourseContainerViewModelTests: XCTestCase { certificate: nil ) - let downloadData = DownloadData( + let downloadData = DownloadDataTask( id: "1", courseId: "course123", url: "https://example.com/file.mp4", fileName: "file.mp4", + displayName: "file.mp4", progress: 0, resumeData: nil, state: .finished, - type: .video + type: .video, + fileSize: 1000 ) Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) - Given(downloadManager, .publisher(willReturn: Just(1).eraseToAnyPublisher())) - Given(downloadManager, .getDownloadsForCourse(.any, willReturn: [downloadData])) + Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) + Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) + Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [downloadData])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -880,6 +1174,7 @@ final class CourseContainerViewModelTests: XCTestCase { config: config, connectivity: connectivity, manager: downloadManager, + storage: CourseStorageMock(), isActive: true, courseStart: Date(), courseEnd: nil, @@ -887,9 +1182,10 @@ final class CourseContainerViewModelTests: XCTestCase { enrollmentEnd: nil ) viewModel.courseStructure = courseStructure + await viewModel.setDownloadsStates() - let exp = expectation(description: "DispatchQueue.main.async Starting") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + let exp = expectation(description: "Task Starting") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exp.fulfill() } diff --git a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift index 25d731196..317636bb2 100644 --- a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift @@ -24,8 +24,8 @@ final class CourseUnitViewModelTests: XCTestCase { type: .video, displayName: "Lesson 1", studentUrl: "", - videoUrl: nil, - youTubeUrl: nil), + encodedVideo: nil + ), CourseBlock(blockId: "2", id: "2", courseId: "123", @@ -35,8 +35,8 @@ final class CourseUnitViewModelTests: XCTestCase { type: .video, displayName: "Lesson 2", studentUrl: "2", - videoUrl: nil, - youTubeUrl: nil), + encodedVideo: nil + ), CourseBlock(blockId: "3", id: "3", courseId: "123", @@ -46,8 +46,8 @@ final class CourseUnitViewModelTests: XCTestCase { type: .unknown, displayName: "Lesson 3", studentUrl: "3", - videoUrl: nil, - youTubeUrl: nil), + encodedVideo: nil + ), CourseBlock(blockId: "4", id: "4", courseId: "123", @@ -57,8 +57,8 @@ final class CourseUnitViewModelTests: XCTestCase { type: .unknown, displayName: "4", studentUrl: "4", - videoUrl: nil, - youTubeUrl: nil), + encodedVideo: nil + ), ] let chapters = [ @@ -126,6 +126,7 @@ final class CourseUnitViewModelTests: XCTestCase { router: router, analytics: analytics, connectivity: connectivity, + storage: CourseStorageMock(), manager: DownloadManagerMock() ) @@ -153,7 +154,8 @@ final class CourseUnitViewModelTests: XCTestCase { interactor: interactor, router: router, analytics: analytics, - connectivity: connectivity, + connectivity: connectivity, + storage: CourseStorageMock(), manager: DownloadManagerMock() ) @@ -187,6 +189,7 @@ final class CourseUnitViewModelTests: XCTestCase { router: router, analytics: analytics, connectivity: connectivity, + storage: CourseStorageMock(), manager: DownloadManagerMock() ) @@ -222,6 +225,7 @@ final class CourseUnitViewModelTests: XCTestCase { router: router, analytics: analytics, connectivity: connectivity, + storage: CourseStorageMock(), manager: DownloadManagerMock() ) @@ -256,6 +260,7 @@ final class CourseUnitViewModelTests: XCTestCase { router: router, analytics: analytics, connectivity: connectivity, + storage: CourseStorageMock(), manager: DownloadManagerMock() ) diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index f5c74c668..df1f3f6b9 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1545,6 +1545,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { if scopes.contains(.perform) { methodPerformValues = [] } } + public var currentDownloadTask: DownloadDataTask? { + get { invocations.append(.p_currentDownloadTask_get); return __p_currentDownloadTask ?? optionalGivenGetterValue(.p_currentDownloadTask_get, "DownloadManagerProtocolMock - stub value for currentDownloadTask was not defined") } + } + private var __p_currentDownloadTask: (DownloadDataTask)? + @@ -1563,29 +1568,44 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func addToDownloadQueue(blocks: [CourseBlock]) throws { - addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) + open func eventPublisher() -> AnyPublisher { + addInvocation(.m_eventPublisher) + let perform = methodPerformValue(.m_eventPublisher) as? () -> Void + perform?() + var __value: AnyPublisher do { - _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void - } catch MockError.notStubed { - // do nothing + __value = try methodReturnValue(.m_eventPublisher).casted() } catch { - throw error + onFatalFailure("Stub return value not specified for eventPublisher(). Use given") + Failure("Stub return value not specified for eventPublisher(). Use given") + } + return __value + } + + open func getDownloadTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasks) + let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadTasks(). Use given") + Failure("Stub return value not specified for getDownloadTasks(). Use given") } + return __value } - open func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { - addInvocation(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) - let perform = methodPerformValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + open func getDownloadTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void perform?(`courseId`) - var __value: [DownloadData] + var __value: [DownloadDataTask] do { - __value = try methodReturnValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))).casted() + __value = try methodReturnValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))).casted() } catch { - onFatalFailure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") - Failure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + onFatalFailure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") } return __value } @@ -1603,12 +1623,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } - open func resumeDownloading() throws { - addInvocation(.m_resumeDownloading) - let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void - perform?() + open func cancelDownloading(task: DownloadDataTask) throws { + addInvocation(.m_cancelDownloading__task_task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_cancelDownloading__task_task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) do { - _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + _ = try methodReturnValue(.m_cancelDownloading__task_task(Parameter.value(`task`))).casted() as Void } catch MockError.notStubed { // do nothing } catch { @@ -1616,10 +1636,10 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } - open func pauseDownloading() { - addInvocation(.m_pauseDownloading) - let perform = methodPerformValue(.m_pauseDownloading) as? () -> Void - perform?() + open func cancelDownloading(courseId: String) { + addInvocation(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) } open func deleteFile(blocks: [CourseBlock]) { @@ -1647,28 +1667,72 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func isLargeVideosSize(blocks: [CourseBlock]) -> Bool { + addInvocation(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + Failure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + } + return __value + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + fileprivate enum MethodType { case m_publisher - case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) - case m_getDownloadsForCourse__courseId(Parameter) + case m_eventPublisher + case m_getDownloadTasks + case m_getDownloadTasksForCourse__courseId(Parameter) case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) - case m_resumeDownloading - case m_pauseDownloading + case m_cancelDownloading__task_task(Parameter) + case m_cancelDownloading__courseId_courseId(Parameter) case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_resumeDownloading + case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.m_publisher, .m_publisher): return .match - case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + case (.m_eventPublisher, .m_eventPublisher): return .match - case (.m_getDownloadsForCourse__courseId(let lhsCourseid), .m_getDownloadsForCourse__courseId(let rhsCourseid)): + case (.m_getDownloadTasks, .m_getDownloadTasks): return .match + + case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) return Matcher.ComparisonResult(results) @@ -1679,9 +1743,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) - case (.m_resumeDownloading, .m_resumeDownloading): return .match + case (.m_cancelDownloading__task_task(let lhsTask), .m_cancelDownloading__task_task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "task")) + return Matcher.ComparisonResult(results) - case (.m_pauseDownloading, .m_pauseDownloading): return .match + case (.m_cancelDownloading__courseId_courseId(let lhsCourseid), .m_cancelDownloading__courseId_courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + return Matcher.ComparisonResult(results) case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] @@ -1694,6 +1764,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) + + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_resumeDownloading, .m_resumeDownloading): return .match + case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } } @@ -1701,27 +1784,37 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { func intValue() -> Int { switch self { case .m_publisher: return 0 - case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue - case let .m_getDownloadsForCourse__courseId(p0): return p0.intValue + case .m_eventPublisher: return 0 + case .m_getDownloadTasks: return 0 + case let .m_getDownloadTasksForCourse__courseId(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue - case .m_resumeDownloading: return 0 - case .m_pauseDownloading: return 0 + case let .m_cancelDownloading__task_task(p0): return p0.intValue + case let .m_cancelDownloading__courseId_courseId(p0): return p0.intValue case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_resumeDownloading: return 0 + case .p_currentDownloadTask_get: return 0 } } func assertionName() -> String { switch self { case .m_publisher: return ".publisher()" - case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" - case .m_getDownloadsForCourse__courseId: return ".getDownloadsForCourse(_:)" + case .m_eventPublisher: return ".eventPublisher()" + case .m_getDownloadTasks: return ".getDownloadTasks()" + case .m_getDownloadTasksForCourse__courseId: return ".getDownloadTasksForCourse(_:)" case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" - case .m_resumeDownloading: return ".resumeDownloading()" - case .m_pauseDownloading: return ".pauseDownloading()" + case .m_cancelDownloading__task_task: return ".cancelDownloading(task:)" + case .m_cancelDownloading__courseId_courseId: return ".cancelDownloading(courseId:)" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } } @@ -1734,16 +1827,28 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { super.init(products) } + public static func currentDownloadTask(getter defaultValue: DownloadDataTask?...) -> PropertyStub { + return Given(method: .p_currentDownloadTask_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } public static func publisher(willReturn: AnyPublisher...) -> MethodStub { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getDownloadsForCourse(_ courseId: Parameter, willReturn: [DownloadData]...) -> MethodStub { - return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func eventPublisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { let willReturn: [AnyPublisher] = [] let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -1751,10 +1856,24 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func getDownloadsForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadData]>) -> Void) -> MethodStub { - let willReturn: [[DownloadData]] = [] - let given: Given = { return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([DownloadData]).self) + public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) willProduce(stubber) return given } @@ -1765,13 +1884,10 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) willProduce(stubber) return given } @@ -1785,6 +1901,26 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func cancelDownloading(task: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(task: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } public static func resumeDownloading(willThrow: Error...) -> MethodStub { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) } @@ -1801,14 +1937,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { fileprivate var method: MethodType public static func publisher() -> Verify { return Verify(method: .m_publisher)} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} - public static func getDownloadsForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadsForCourse__courseId(`courseId`))} + public static func eventPublisher() -> Verify { return Verify(method: .m_eventPublisher)} + public static func getDownloadTasks() -> Verify { return Verify(method: .m_getDownloadTasks)} + public static func getDownloadTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadTasksForCourse__courseId(`courseId`))} public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} - public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} - public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} + public static func cancelDownloading(task: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__task_task(`task`))} + public static func cancelDownloading(courseId: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseId(`courseId`))} public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} + public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } public struct Perform { @@ -1818,20 +1959,23 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func publisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_publisher, performs: perform) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + public static func eventPublisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_eventPublisher, performs: perform) } - public static func getDownloadsForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_getDownloadsForCourse__courseId(`courseId`), performs: perform) + public static func getDownloadTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadTasks, performs: perform) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadTasksForCourse__courseId(`courseId`), performs: perform) } public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) } - public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_resumeDownloading, performs: perform) + public static func cancelDownloading(task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__task_task(`task`), performs: perform) } - public static func pauseDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_pauseDownloading, performs: perform) + public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) } public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) @@ -1842,6 +1986,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) + } + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) + } } public func given(_ method: Given) { diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index c1bded8f4..69555a80e 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -143,7 +143,7 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { self?.router.dismiss(animated: true) }, okTapped: { UIApplication.shared.open(url, options: [:]) - }, type: .default(positiveAction: CoreLocalization.Webview.Alert.continue) + }, type: .default(positiveAction: CoreLocalization.Webview.Alert.continue, image: nil) ) } return true diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 2614d53a9..badb0831f 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -1802,6 +1802,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { if scopes.contains(.perform) { methodPerformValues = [] } } + public var currentDownloadTask: DownloadDataTask? { + get { invocations.append(.p_currentDownloadTask_get); return __p_currentDownloadTask ?? optionalGivenGetterValue(.p_currentDownloadTask_get, "DownloadManagerProtocolMock - stub value for currentDownloadTask was not defined") } + } + private var __p_currentDownloadTask: (DownloadDataTask)? + @@ -1820,29 +1825,44 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func addToDownloadQueue(blocks: [CourseBlock]) throws { - addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) + open func eventPublisher() -> AnyPublisher { + addInvocation(.m_eventPublisher) + let perform = methodPerformValue(.m_eventPublisher) as? () -> Void + perform?() + var __value: AnyPublisher do { - _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void - } catch MockError.notStubed { - // do nothing + __value = try methodReturnValue(.m_eventPublisher).casted() } catch { - throw error + onFatalFailure("Stub return value not specified for eventPublisher(). Use given") + Failure("Stub return value not specified for eventPublisher(). Use given") + } + return __value + } + + open func getDownloadTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasks) + let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadTasks(). Use given") + Failure("Stub return value not specified for getDownloadTasks(). Use given") } + return __value } - open func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { - addInvocation(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) - let perform = methodPerformValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + open func getDownloadTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void perform?(`courseId`) - var __value: [DownloadData] + var __value: [DownloadDataTask] do { - __value = try methodReturnValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))).casted() + __value = try methodReturnValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))).casted() } catch { - onFatalFailure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") - Failure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + onFatalFailure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") } return __value } @@ -1860,12 +1880,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } - open func resumeDownloading() throws { - addInvocation(.m_resumeDownloading) - let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void - perform?() + open func cancelDownloading(task: DownloadDataTask) throws { + addInvocation(.m_cancelDownloading__task_task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_cancelDownloading__task_task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) do { - _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + _ = try methodReturnValue(.m_cancelDownloading__task_task(Parameter.value(`task`))).casted() as Void } catch MockError.notStubed { // do nothing } catch { @@ -1873,10 +1893,10 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } - open func pauseDownloading() { - addInvocation(.m_pauseDownloading) - let perform = methodPerformValue(.m_pauseDownloading) as? () -> Void - perform?() + open func cancelDownloading(courseId: String) { + addInvocation(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) } open func deleteFile(blocks: [CourseBlock]) { @@ -1904,28 +1924,72 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func isLargeVideosSize(blocks: [CourseBlock]) -> Bool { + addInvocation(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + Failure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + } + return __value + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + fileprivate enum MethodType { case m_publisher - case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) - case m_getDownloadsForCourse__courseId(Parameter) + case m_eventPublisher + case m_getDownloadTasks + case m_getDownloadTasksForCourse__courseId(Parameter) case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) - case m_resumeDownloading - case m_pauseDownloading + case m_cancelDownloading__task_task(Parameter) + case m_cancelDownloading__courseId_courseId(Parameter) case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_resumeDownloading + case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.m_publisher, .m_publisher): return .match - case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + case (.m_eventPublisher, .m_eventPublisher): return .match - case (.m_getDownloadsForCourse__courseId(let lhsCourseid), .m_getDownloadsForCourse__courseId(let rhsCourseid)): + case (.m_getDownloadTasks, .m_getDownloadTasks): return .match + + case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) return Matcher.ComparisonResult(results) @@ -1936,9 +2000,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) - case (.m_resumeDownloading, .m_resumeDownloading): return .match + case (.m_cancelDownloading__task_task(let lhsTask), .m_cancelDownloading__task_task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "task")) + return Matcher.ComparisonResult(results) - case (.m_pauseDownloading, .m_pauseDownloading): return .match + case (.m_cancelDownloading__courseId_courseId(let lhsCourseid), .m_cancelDownloading__courseId_courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + return Matcher.ComparisonResult(results) case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] @@ -1951,6 +2021,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) + + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_resumeDownloading, .m_resumeDownloading): return .match + case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } } @@ -1958,27 +2041,37 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { func intValue() -> Int { switch self { case .m_publisher: return 0 - case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue - case let .m_getDownloadsForCourse__courseId(p0): return p0.intValue + case .m_eventPublisher: return 0 + case .m_getDownloadTasks: return 0 + case let .m_getDownloadTasksForCourse__courseId(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue - case .m_resumeDownloading: return 0 - case .m_pauseDownloading: return 0 + case let .m_cancelDownloading__task_task(p0): return p0.intValue + case let .m_cancelDownloading__courseId_courseId(p0): return p0.intValue case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_resumeDownloading: return 0 + case .p_currentDownloadTask_get: return 0 } } func assertionName() -> String { switch self { case .m_publisher: return ".publisher()" - case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" - case .m_getDownloadsForCourse__courseId: return ".getDownloadsForCourse(_:)" + case .m_eventPublisher: return ".eventPublisher()" + case .m_getDownloadTasks: return ".getDownloadTasks()" + case .m_getDownloadTasksForCourse__courseId: return ".getDownloadTasksForCourse(_:)" case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" - case .m_resumeDownloading: return ".resumeDownloading()" - case .m_pauseDownloading: return ".pauseDownloading()" + case .m_cancelDownloading__task_task: return ".cancelDownloading(task:)" + case .m_cancelDownloading__courseId_courseId: return ".cancelDownloading(courseId:)" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } } @@ -1991,16 +2084,28 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { super.init(products) } + public static func currentDownloadTask(getter defaultValue: DownloadDataTask?...) -> PropertyStub { + return Given(method: .p_currentDownloadTask_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } public static func publisher(willReturn: AnyPublisher...) -> MethodStub { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getDownloadsForCourse(_ courseId: Parameter, willReturn: [DownloadData]...) -> MethodStub { - return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func eventPublisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { let willReturn: [AnyPublisher] = [] let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2008,10 +2113,24 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func getDownloadsForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadData]>) -> Void) -> MethodStub { - let willReturn: [[DownloadData]] = [] - let given: Given = { return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([DownloadData]).self) + public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) willProduce(stubber) return given } @@ -2022,13 +2141,10 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) willProduce(stubber) return given } @@ -2042,6 +2158,26 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func cancelDownloading(task: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(task: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } public static func resumeDownloading(willThrow: Error...) -> MethodStub { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) } @@ -2058,14 +2194,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { fileprivate var method: MethodType public static func publisher() -> Verify { return Verify(method: .m_publisher)} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} - public static func getDownloadsForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadsForCourse__courseId(`courseId`))} + public static func eventPublisher() -> Verify { return Verify(method: .m_eventPublisher)} + public static func getDownloadTasks() -> Verify { return Verify(method: .m_getDownloadTasks)} + public static func getDownloadTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadTasksForCourse__courseId(`courseId`))} public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} - public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} - public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} + public static func cancelDownloading(task: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__task_task(`task`))} + public static func cancelDownloading(courseId: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseId(`courseId`))} public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} + public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } public struct Perform { @@ -2075,20 +2216,23 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func publisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_publisher, performs: perform) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + public static func eventPublisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_eventPublisher, performs: perform) } - public static func getDownloadsForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_getDownloadsForCourse__courseId(`courseId`), performs: perform) + public static func getDownloadTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadTasks, performs: perform) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadTasksForCourse__courseId(`courseId`), performs: perform) } public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) } - public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_resumeDownloading, performs: perform) + public static func cancelDownloading(task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__task_task(`task`), performs: perform) } - public static func pauseDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_pauseDownloading, performs: perform) + public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) } public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) @@ -2099,6 +2243,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) + } + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) + } } public func given(_ method: Given) { diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 6d998a133..c737a6581 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -2604,6 +2604,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { if scopes.contains(.perform) { methodPerformValues = [] } } + public var currentDownloadTask: DownloadDataTask? { + get { invocations.append(.p_currentDownloadTask_get); return __p_currentDownloadTask ?? optionalGivenGetterValue(.p_currentDownloadTask_get, "DownloadManagerProtocolMock - stub value for currentDownloadTask was not defined") } + } + private var __p_currentDownloadTask: (DownloadDataTask)? + @@ -2622,29 +2627,44 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func addToDownloadQueue(blocks: [CourseBlock]) throws { - addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) + open func eventPublisher() -> AnyPublisher { + addInvocation(.m_eventPublisher) + let perform = methodPerformValue(.m_eventPublisher) as? () -> Void + perform?() + var __value: AnyPublisher do { - _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void - } catch MockError.notStubed { - // do nothing + __value = try methodReturnValue(.m_eventPublisher).casted() } catch { - throw error + onFatalFailure("Stub return value not specified for eventPublisher(). Use given") + Failure("Stub return value not specified for eventPublisher(). Use given") + } + return __value + } + + open func getDownloadTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasks) + let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadTasks(). Use given") + Failure("Stub return value not specified for getDownloadTasks(). Use given") } + return __value } - open func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { - addInvocation(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) - let perform = methodPerformValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + open func getDownloadTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void perform?(`courseId`) - var __value: [DownloadData] + var __value: [DownloadDataTask] do { - __value = try methodReturnValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))).casted() + __value = try methodReturnValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))).casted() } catch { - onFatalFailure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") - Failure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + onFatalFailure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") } return __value } @@ -2662,12 +2682,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } - open func resumeDownloading() throws { - addInvocation(.m_resumeDownloading) - let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void - perform?() + open func cancelDownloading(task: DownloadDataTask) throws { + addInvocation(.m_cancelDownloading__task_task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_cancelDownloading__task_task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) do { - _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + _ = try methodReturnValue(.m_cancelDownloading__task_task(Parameter.value(`task`))).casted() as Void } catch MockError.notStubed { // do nothing } catch { @@ -2675,10 +2695,10 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } - open func pauseDownloading() { - addInvocation(.m_pauseDownloading) - let perform = methodPerformValue(.m_pauseDownloading) as? () -> Void - perform?() + open func cancelDownloading(courseId: String) { + addInvocation(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) } open func deleteFile(blocks: [CourseBlock]) { @@ -2706,28 +2726,72 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func isLargeVideosSize(blocks: [CourseBlock]) -> Bool { + addInvocation(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + Failure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + } + return __value + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + fileprivate enum MethodType { case m_publisher - case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) - case m_getDownloadsForCourse__courseId(Parameter) + case m_eventPublisher + case m_getDownloadTasks + case m_getDownloadTasksForCourse__courseId(Parameter) case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) - case m_resumeDownloading - case m_pauseDownloading + case m_cancelDownloading__task_task(Parameter) + case m_cancelDownloading__courseId_courseId(Parameter) case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_resumeDownloading + case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.m_publisher, .m_publisher): return .match - case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + case (.m_eventPublisher, .m_eventPublisher): return .match - case (.m_getDownloadsForCourse__courseId(let lhsCourseid), .m_getDownloadsForCourse__courseId(let rhsCourseid)): + case (.m_getDownloadTasks, .m_getDownloadTasks): return .match + + case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) return Matcher.ComparisonResult(results) @@ -2738,9 +2802,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) - case (.m_resumeDownloading, .m_resumeDownloading): return .match + case (.m_cancelDownloading__task_task(let lhsTask), .m_cancelDownloading__task_task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "task")) + return Matcher.ComparisonResult(results) - case (.m_pauseDownloading, .m_pauseDownloading): return .match + case (.m_cancelDownloading__courseId_courseId(let lhsCourseid), .m_cancelDownloading__courseId_courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + return Matcher.ComparisonResult(results) case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] @@ -2753,6 +2823,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) + + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_resumeDownloading, .m_resumeDownloading): return .match + case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } } @@ -2760,27 +2843,37 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { func intValue() -> Int { switch self { case .m_publisher: return 0 - case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue - case let .m_getDownloadsForCourse__courseId(p0): return p0.intValue + case .m_eventPublisher: return 0 + case .m_getDownloadTasks: return 0 + case let .m_getDownloadTasksForCourse__courseId(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue - case .m_resumeDownloading: return 0 - case .m_pauseDownloading: return 0 + case let .m_cancelDownloading__task_task(p0): return p0.intValue + case let .m_cancelDownloading__courseId_courseId(p0): return p0.intValue case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_resumeDownloading: return 0 + case .p_currentDownloadTask_get: return 0 } } func assertionName() -> String { switch self { case .m_publisher: return ".publisher()" - case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" - case .m_getDownloadsForCourse__courseId: return ".getDownloadsForCourse(_:)" + case .m_eventPublisher: return ".eventPublisher()" + case .m_getDownloadTasks: return ".getDownloadTasks()" + case .m_getDownloadTasksForCourse__courseId: return ".getDownloadTasksForCourse(_:)" case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" - case .m_resumeDownloading: return ".resumeDownloading()" - case .m_pauseDownloading: return ".pauseDownloading()" + case .m_cancelDownloading__task_task: return ".cancelDownloading(task:)" + case .m_cancelDownloading__courseId_courseId: return ".cancelDownloading(courseId:)" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } } @@ -2793,16 +2886,28 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { super.init(products) } + public static func currentDownloadTask(getter defaultValue: DownloadDataTask?...) -> PropertyStub { + return Given(method: .p_currentDownloadTask_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } public static func publisher(willReturn: AnyPublisher...) -> MethodStub { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getDownloadsForCourse(_ courseId: Parameter, willReturn: [DownloadData]...) -> MethodStub { - return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func eventPublisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { let willReturn: [AnyPublisher] = [] let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2810,10 +2915,24 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func getDownloadsForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadData]>) -> Void) -> MethodStub { - let willReturn: [[DownloadData]] = [] - let given: Given = { return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([DownloadData]).self) + public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) willProduce(stubber) return given } @@ -2824,13 +2943,10 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) willProduce(stubber) return given } @@ -2844,6 +2960,26 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func cancelDownloading(task: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(task: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } public static func resumeDownloading(willThrow: Error...) -> MethodStub { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) } @@ -2860,14 +2996,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { fileprivate var method: MethodType public static func publisher() -> Verify { return Verify(method: .m_publisher)} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} - public static func getDownloadsForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadsForCourse__courseId(`courseId`))} + public static func eventPublisher() -> Verify { return Verify(method: .m_eventPublisher)} + public static func getDownloadTasks() -> Verify { return Verify(method: .m_getDownloadTasks)} + public static func getDownloadTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadTasksForCourse__courseId(`courseId`))} public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} - public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} - public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} + public static func cancelDownloading(task: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__task_task(`task`))} + public static func cancelDownloading(courseId: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseId(`courseId`))} public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} + public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } public struct Perform { @@ -2877,20 +3018,23 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func publisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_publisher, performs: perform) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + public static func eventPublisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_eventPublisher, performs: perform) } - public static func getDownloadsForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_getDownloadsForCourse__courseId(`courseId`), performs: perform) + public static func getDownloadTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadTasks, performs: perform) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadTasksForCourse__courseId(`courseId`), performs: perform) } public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) } - public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_resumeDownloading, performs: perform) + public static func cancelDownloading(task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__task_task(`task`), performs: perform) } - public static func pauseDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_pauseDownloading, performs: perform) + public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) } public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) @@ -2901,6 +3045,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) + } + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) + } } public func given(_ method: Given) { diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 8b6b5bd1f..adca92809 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -118,7 +118,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { lastForceLogoutTime = Date().timeIntervalSince1970 Container.shared.resolve(CoreStorage.self)?.clear() - Container.shared.resolve(DownloadManagerProtocol.self)?.deleteAllFiles() + Task { + await Container.shared.resolve(DownloadManagerProtocol.self)?.deleteAllFiles() + } Container.shared.resolve(CoreDataHandlerProtocol.self)?.clear() window?.rootViewController = RouteController() } diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index e5a491fc7..8015abff3 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -149,7 +149,11 @@ class AppAssembly: Assembly { container.register(WhatsNewStorage.self) { r in r.resolve(AppStorage.self)! }.inObjectScope(.container) - + + container.register(CourseStorage.self) { r in + r.resolve(AppStorage.self)! + }.inObjectScope(.container) + container.register(ProfileStorage.self) { r in r.resolve(AppStorage.self)! }.inObjectScope(.container) diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 5134168b8..e0c4c5be3 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -249,6 +249,7 @@ class ScreenAssembly: Assembly { config: r.resolve(ConfigProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, manager: r.resolve(DownloadManagerProtocol.self)!, + storage: r.resolve(CourseStorage.self)!, isActive: isActive, courseStart: courseStart, courseEnd: courseEnd, @@ -284,6 +285,7 @@ class ScreenAssembly: Assembly { router: r.resolve(CourseRouter.self)!, analytics: r.resolve(CourseAnalytics.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, + storage: r.resolve(CourseStorage.self)!, manager: r.resolve(DownloadManagerProtocol.self)! ) } diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index c0655026a..ff17e4128 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -10,8 +10,9 @@ import KeychainSwift import Core import Profile import WhatsNew +import Course -public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage { +public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseStorage { private let keychain: KeychainSwift private let userDefaults: UserDefaults @@ -150,7 +151,7 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage { public var userSettings: UserSettings? { get { guard let userSettings = userDefaults.data(forKey: KEY_SETTINGS) else { - let defaultSettings = UserSettings(wifiOnly: true, streamingQuality: .auto) + let defaultSettings = UserSettings(wifiOnly: true, streamingQuality: .auto, downloadQuality: .auto) let encoder = JSONEncoder() if let encoded = try? encoder.encode(defaultSettings) { userDefaults.set(encoded, forKey: KEY_SETTINGS) @@ -190,6 +191,19 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage { } } + public var allowedDownloadLargeFile: Bool? { + get { + return userDefaults.bool(forKey: KEY_ALLOWED_DOWNLOAD_LARGE_FILE) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_ALLOWED_DOWNLOAD_LARGE_FILE) + } else { + userDefaults.removeObject(forKey: KEY_ALLOWED_DOWNLOAD_LARGE_FILE) + } + } + } + public func clear() { accessToken = nil refreshToken = nil @@ -208,4 +222,5 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage { private let KEY_WHATSNEW_VERSION = "whatsNewVersion" private let KEY_APPLE_SIGN_FULLNAME = "appleSignFullName" private let KEY_APPLE_SIGN_EMAIL = "appleSignEmail" + private let KEY_ALLOWED_DOWNLOAD_LARGE_FILE = "allowedDownloadLargeFile" } diff --git a/OpenEdX/Data/CorePersistence.swift b/OpenEdX/Data/CorePersistence.swift index 75b136eb5..1070011dd 100644 --- a/OpenEdX/Data/CorePersistence.swift +++ b/OpenEdX/Data/CorePersistence.swift @@ -11,59 +11,68 @@ import CoreData import Combine public class CorePersistence: CorePersistenceProtocol { - + private var context: NSManagedObjectContext - + public init(context: NSManagedObjectContext) { self.context = context } - + public func publisher() -> AnyPublisher { let notification = NSManagedObjectContext.didChangeObjectsNotification return NotificationCenter.default.publisher(for: notification, object: context) .compactMap({ notification in guard let userInfo = notification.userInfo else { return nil } - + if let inserts = userInfo[NSInsertedObjectsKey] as? Set, inserts.count > 0 { return inserts.count } - + if let updates = userInfo[NSUpdatedObjectsKey] as? Set, updates.count > 0 { return updates.count } - + if let deletes = userInfo[NSDeletedObjectsKey] as? Set, deletes.count > 0 { return deletes.count } - + return nil }) .eraseToAnyPublisher() } - - public func getAllDownloadData() -> [DownloadData] { - let request = CDDownloadData.fetchRequest() - guard let downloadData = try? context.fetch(request) else { return [] } - return downloadData.map { - DownloadData( - id: $0.id ?? "", - courseId: $0.courseId ?? "", - url: $0.url ?? "", - fileName: $0.fileName ?? "", - progress: $0.progress, - resumeData: $0.resumeData, - state: DownloadState(rawValue: $0.state ?? "") ?? .waiting, - type: DownloadType(rawValue: $0.type ?? "") ?? .video - ) + + public func getDownloadDataTasks(completion: @escaping ([DownloadDataTask]) -> Void) { + context.performAndWait { + let request = CDDownloadData.fetchRequest() + guard let downloadData = try? context.fetch(request) else { + completion([]) + return + } + let downloads = downloadData.map { + DownloadDataTask( + id: $0.id ?? "", + courseId: $0.courseId ?? "", + url: $0.url ?? "", + fileName: $0.fileName ?? "", + displayName: $0.displayName ?? "", + progress: $0.progress, + resumeData: $0.resumeData, + state: DownloadState(rawValue: $0.state ?? "") ?? .waiting, + type: DownloadType(rawValue: $0.type ?? "") ?? .video, + fileSize: Int($0.fileSize) + ) + } + completion(downloads) } } - - public func addToDownloadQueue(blocks: [CourseBlock]) { + + public func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { for block in blocks { let request = CDDownloadData.fetchRequest() request.predicate = NSPredicate(format: "id = %@", block.id) guard (try? context.fetch(request).first) == nil else { continue } - guard let url = block.videoUrl, + guard let video = block.encodedVideo?.video(downloadQuality: downloadQuality), + let url = video.url, let fileExtension = URL(string: url)?.pathExtension else { continue } let fileName = "\(block.id).\(fileExtension)" @@ -74,96 +83,137 @@ public class CorePersistence: CorePersistenceProtocol { newDownloadData.courseId = block.courseId newDownloadData.url = url newDownloadData.fileName = fileName + newDownloadData.displayName = block.displayName newDownloadData.progress = .zero newDownloadData.resumeData = nil newDownloadData.state = DownloadState.waiting.rawValue newDownloadData.type = DownloadType.video.rawValue + newDownloadData.fileSize = Int32(video.fileSize ?? 0) } } } - - public func getNextBlockForDownloading() -> DownloadData? { + + public func getNextBlockForDownloading() -> DownloadDataTask? { let request = CDDownloadData.fetchRequest() request.predicate = NSPredicate(format: "state != %@", DownloadState.finished.rawValue) request.fetchLimit = 1 guard let data = try? context.fetch(request).first else { return nil } - return DownloadData( + return DownloadDataTask( id: data.id ?? "", courseId: data.courseId ?? "", url: data.url ?? "", fileName: data.fileName ?? "", + displayName: data.displayName ?? "", progress: data.progress, resumeData: data.resumeData, state: DownloadState(rawValue: data.state ?? "") ?? .waiting, - type: DownloadType(rawValue: data.type ?? "" ) ?? .video + type: DownloadType(rawValue: data.type ?? "" ) ?? .video, + fileSize: Int(data.fileSize) ) } - - public func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "courseId = %@", courseId) - guard let downloadData = try? context.fetch(request) else { return [] } - return downloadData.map { - DownloadData( - id: $0.id ?? "", - courseId: $0.courseId ?? "", - url: $0.url ?? "", - fileName: $0.fileName ?? "", - progress: $0.progress, - resumeData: $0.resumeData, - state: DownloadState(rawValue: $0.state ?? "") ?? .waiting, - type: DownloadType(rawValue: $0.type ?? "") ?? .video + + public func getDownloadDataTasksForCourse(_ courseId: String, completion: @escaping ([DownloadDataTask]) -> Void) { + context.performAndWait { + let request = CDDownloadData.fetchRequest() + request.predicate = NSPredicate(format: "courseId = %@", courseId) + guard let downloadData = try? context.fetch(request) else { + completion([]) + return + } + let downloads = downloadData.map { + DownloadDataTask( + id: $0.id ?? "", + courseId: $0.courseId ?? "", + url: $0.url ?? "", + fileName: $0.fileName ?? "", + displayName: $0.displayName ?? "", + progress: $0.progress, + resumeData: $0.resumeData, + state: DownloadState(rawValue: $0.state ?? "") ?? .waiting, + type: DownloadType(rawValue: $0.type ?? "") ?? .video, + fileSize: Int($0.fileSize) + ) + } + completion(downloads) + } + } + + public func downloadDataTask(for blockId: String, completion: @escaping (DownloadDataTask?) -> Void) { + context.performAndWait { + let request = CDDownloadData.fetchRequest() + request.predicate = NSPredicate(format: "id = %@", blockId) + guard let downloadData = try? context.fetch(request).first else { + completion(nil) + return + } + let data = DownloadDataTask( + id: downloadData.id ?? "", + courseId: downloadData.courseId ?? "", + url: downloadData.url ?? "", + fileName: downloadData.fileName ?? "", + displayName: downloadData.displayName ?? "", + progress: downloadData.progress, + resumeData: downloadData.resumeData, + state: DownloadState(rawValue: downloadData.state ?? "") ?? .waiting, + type: DownloadType(rawValue: downloadData.type ?? "" ) ?? .video, + fileSize: Int(downloadData.fileSize) ) + completion(data) } } - - public func downloadData(by blockId: String) -> DownloadData? { + + public func downloadDataTask(for blockId: String) -> DownloadDataTask? { let request = CDDownloadData.fetchRequest() request.predicate = NSPredicate(format: "id = %@", blockId) guard let downloadData = try? context.fetch(request).first else { return nil } - return DownloadData( + return DownloadDataTask( id: downloadData.id ?? "", courseId: downloadData.courseId ?? "", url: downloadData.url ?? "", fileName: downloadData.fileName ?? "", + displayName: downloadData.displayName ?? "", progress: downloadData.progress, resumeData: downloadData.resumeData, - state: DownloadState(rawValue: downloadData.state ?? "") ?? .paused, - type: DownloadType(rawValue: downloadData.type ?? "" ) ?? .video + state: DownloadState(rawValue: downloadData.state ?? "") ?? .waiting, + type: DownloadType(rawValue: downloadData.type ?? "" ) ?? .video, + fileSize: Int(downloadData.fileSize) ) } - + public func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { context.performAndWait { let request = CDDownloadData.fetchRequest() request.predicate = NSPredicate(format: "id = %@", id) guard let downloadData = try? context.fetch(request).first else { return } downloadData.state = state.rawValue + if state == .finished { downloadData.progress = 1 } downloadData.resumeData = resumeData do { try context.save() } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) + debugLog("⛔️⛔️⛔️⛔️⛔️", error) } } } - - public func deleteDownloadData(id: String) throws { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", id) - do { - let records = try context.fetch(request) - for record in records { - context.delete(record) - try context.save() - print("File erased successfully") + + public func deleteDownloadDataTask(id: String) throws { + context.performAndWait { + let request = CDDownloadData.fetchRequest() + request.predicate = NSPredicate(format: "id = %@", id) + do { + let records = try context.fetch(request) + for record in records { + context.delete(record) + try context.save() + debugLog("File erased successfully") + } + } catch { + debugLog("Error fetching records: \(error.localizedDescription)") } - } catch { - print("Error fetching records: \(error.localizedDescription)") } } - public func saveDownloadData(data: DownloadData) { + public func saveDownloadDataTask(data: DownloadDataTask) { context.performAndWait { let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump @@ -174,11 +224,12 @@ public class CorePersistence: CorePersistenceProtocol { newDownloadData.fileName = data.fileName newDownloadData.resumeData = data.resumeData newDownloadData.state = data.state.rawValue - + newDownloadData.fileSize = Int32(data.fileSize) + do { try context.save() } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) + debugLog("⛔️⛔️⛔️⛔️⛔️", error) } } } diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index 077301130..90d9470e6 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -75,24 +75,50 @@ 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: nil, encodedVideo: DataLayer.CourseDetailEncodedVideoData( - youTube: DataLayer.CourseDetailYouTubeData(url: $0.youTubeUrl), - fallback: DataLayer.CourseDetailYouTubeData(url: $0.fallbackUrl) - ), topicID: "") - return DataLayer.CourseBlock(blockId: $0.blockId ?? "", - id: $0.id ?? "", - graded: $0.graded, - completion: $0.completion, - studentUrl: $0.studentUrl ?? "", - type: $0.type ?? "", - displayName: $0.displayName ?? "", - descendants: $0.descendants, - allSources: $0.allSources, - userViewData: userViewData) + youTube: DataLayer.EncodedVideoData( + url: $0.youTube?.url, + fileSize: Int($0.youTube?.fileSize ?? 0) + ), + fallback: DataLayer.EncodedVideoData( + url: $0.fallback?.url, + fileSize: Int($0.fallback?.fileSize ?? 0) + ), + desktopMP4: DataLayer.EncodedVideoData( + url: $0.desktopMP4?.url, + fileSize: Int($0.desktopMP4?.fileSize ?? 0) + ), + mobileHigh: DataLayer.EncodedVideoData( + url: $0.mobileHigh?.url, + fileSize: Int($0.mobileHigh?.fileSize ?? 0) + ), + mobileLow: DataLayer.EncodedVideoData( + url: $0.mobileLow?.url, + fileSize: Int($0.mobileLow?.fileSize ?? 0) + ), + hls: DataLayer.EncodedVideoData( + url: $0.hls?.url, + fileSize: Int($0.hls?.fileSize ?? 0) + ) + ), + topicID: "" + ) + return DataLayer.CourseBlock( + blockId: $0.blockId ?? "", + id: $0.id ?? "", + graded: $0.graded, + completion: $0.completion, + studentUrl: $0.studentUrl ?? "", + type: $0.type ?? "", + displayName: $0.displayName ?? "", + descendants: $0.descendants, + allSources: $0.allSources, + userViewData: userViewData + ) } let dictionary = blocks?.reduce(into: [:]) { result, block in @@ -135,10 +161,56 @@ public class CoursePersistence: CoursePersistenceProtocol { courseDetail.id = block.id courseDetail.studentUrl = block.studentUrl courseDetail.type = block.type - courseDetail.youTubeUrl = block.userViewData?.encodedVideo?.youTube?.url - courseDetail.fallbackUrl = block.userViewData?.encodedVideo?.fallback?.url courseDetail.completion = block.completion ?? 0 - + + if block.userViewData?.encodedVideo?.youTube != nil { + let youTube = CDCourseBlockVideo(context: self.context) + youTube.url = block.userViewData?.encodedVideo?.youTube?.url + youTube.fileSize = Int32(block.userViewData?.encodedVideo?.youTube?.fileSize ?? 0) + youTube.streamPriority = Int32(block.userViewData?.encodedVideo?.youTube?.streamPriority ?? 0) + courseDetail.youTube = youTube + } + + if block.userViewData?.encodedVideo?.fallback != nil { + let fallback = CDCourseBlockVideo(context: self.context) + fallback.url = block.userViewData?.encodedVideo?.fallback?.url + fallback.fileSize = Int32(block.userViewData?.encodedVideo?.fallback?.fileSize ?? 0) + fallback.streamPriority = Int32(block.userViewData?.encodedVideo?.fallback?.streamPriority ?? 0) + courseDetail.fallback = fallback + } + + if block.userViewData?.encodedVideo?.desktopMP4 != nil { + let desktopMP4 = CDCourseBlockVideo(context: self.context) + desktopMP4.url = block.userViewData?.encodedVideo?.desktopMP4?.url + desktopMP4.fileSize = Int32(block.userViewData?.encodedVideo?.desktopMP4?.fileSize ?? 0) + desktopMP4.streamPriority = Int32(block.userViewData?.encodedVideo?.desktopMP4?.streamPriority ?? 0) + courseDetail.desktopMP4 = desktopMP4 + } + + if block.userViewData?.encodedVideo?.mobileHigh != nil { + let mobileHigh = CDCourseBlockVideo(context: self.context) + mobileHigh.url = block.userViewData?.encodedVideo?.mobileHigh?.url + mobileHigh.fileSize = Int32(block.userViewData?.encodedVideo?.mobileHigh?.fileSize ?? 0) + mobileHigh.streamPriority = Int32(block.userViewData?.encodedVideo?.mobileHigh?.streamPriority ?? 0) + courseDetail.mobileHigh = mobileHigh + } + + if block.userViewData?.encodedVideo?.mobileLow != nil { + let mobileLow = CDCourseBlockVideo(context: self.context) + mobileLow.url = block.userViewData?.encodedVideo?.mobileLow?.url + mobileLow.fileSize = Int32(block.userViewData?.encodedVideo?.mobileLow?.fileSize ?? 0) + mobileLow.streamPriority = Int32(block.userViewData?.encodedVideo?.mobileLow?.streamPriority ?? 0) + courseDetail.mobileLow = mobileLow + } + + if block.userViewData?.encodedVideo?.hls != nil { + let hls = CDCourseBlockVideo(context: self.context) + hls.url = block.userViewData?.encodedVideo?.hls?.url + hls.fileSize = Int32(block.userViewData?.encodedVideo?.hls?.fileSize ?? 0) + hls.streamPriority = Int32(block.userViewData?.encodedVideo?.hls?.streamPriority ?? 0) + courseDetail.hls = hls + } + do { try context.save() } catch { diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 9819bf335..e32c1d45a 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -547,7 +547,19 @@ public class Router: AuthorizationRouter, let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } - + + public func showVideoDownloadQualityView( + downloadQuality: DownloadQuality, + didSelect: ((DownloadQuality) -> Void)? + ) { + let view = VideoDownloadQualityView( + downloadQuality: downloadQuality, + didSelect: didSelect + ) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + private func present(transitionStyle: UIModalTransitionStyle, view: ToPresent) { navigationController.present( prepareToPresent(view, transitionStyle: transitionStyle), diff --git a/Podfile.lock b/Podfile.lock index 79fbd8c97..20a87d7f1 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -182,4 +182,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 544edab2f9ecc4ac18973fb8865f1d0613ec8a28 -COCOAPODS: 1.14.3 +COCOAPODS: 1.15.0 diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 788c7c513..83f37215a 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -71,7 +71,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { ProfileEndpoint.logOut(refreshToken: refreshToken, clientID: config.oAuthClientId) ) storage.clear() - downloadManager.deleteAllFiles() + await downloadManager.deleteAllFiles() coreDataHandler.clear() } @@ -144,7 +144,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { if let userSettings = storage.userSettings { return userSettings } else { - return UserSettings(wifiOnly: true, streamingQuality: StreamingQuality.auto) + return UserSettings(wifiOnly: true, streamingQuality: .auto, downloadQuality: .auto) } } @@ -233,7 +233,7 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { public func deleteAccount(password: String) async throws -> Bool { return false } public func getSettings() -> UserSettings { - return UserSettings(wifiOnly: true, streamingQuality: .auto) + return UserSettings(wifiOnly: true, streamingQuality: .auto, downloadQuality: .auto) } public func saveSettings(_ settings: UserSettings) {} } diff --git a/Profile/Profile/Presentation/ProfileRouter.swift b/Profile/Profile/Presentation/ProfileRouter.swift index 38f0de7e4..d449e2a3b 100644 --- a/Profile/Profile/Presentation/ProfileRouter.swift +++ b/Profile/Profile/Presentation/ProfileRouter.swift @@ -21,7 +21,12 @@ public protocol ProfileRouter: BaseRouter { func showSettings() func showVideoQualityView(viewModel: SettingsViewModel) - + + func showVideoDownloadQualityView( + downloadQuality: DownloadQuality, + didSelect: ((DownloadQuality) -> Void)? + ) + func showDeleteProfileView() } @@ -41,7 +46,12 @@ public class ProfileRouterMock: BaseRouterMock, ProfileRouter { public func showSettings() {} public func showVideoQualityView(viewModel: SettingsViewModel) {} - + + public func showVideoDownloadQualityView( + downloadQuality: DownloadQuality, + didSelect: ((DownloadQuality) -> Void)? + ) {} + public func showDeleteProfileView() {} } diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 8c634697a..5ce74d71a 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -42,7 +42,7 @@ public struct SettingsView: View { }.foregroundColor(Theme.Colors.textPrimary) Divider() - // MARK: Download Quality + // MARK: Streaming Quality HStack { Button(action: { viewModel.router.showVideoQualityView(viewModel: viewModel) @@ -56,10 +56,33 @@ public struct SettingsView: View { .frame(width: 10) } Divider() + + // MARK: Download Quality + HStack { + Button { + viewModel.router.showVideoDownloadQualityView( + downloadQuality: viewModel.userSettings.downloadQuality, + didSelect: viewModel.update(downloadQuality:) + ) + } label: { + SettingsCell( + title: CoreLocalization.Settings.videoDownloadQualityTitle, + description: viewModel.userSettings.downloadQuality.settingsDescription + ) + } + // Spacer() + Image(systemName: "chevron.right") + .padding(.trailing, 12) + .frame(width: 10) + } + Divider() } - }.frame(minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading) + } + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading + ) .padding(.horizontal, 24) }.frameLimit(sizePortrait: 420) .padding(.top, 8) diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index 3126c0a00..b24eee494 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -30,8 +30,17 @@ public class SettingsViewModel: ObservableObject { } } } - let quality = Array([StreamingQuality.auto, StreamingQuality.low, StreamingQuality.medium, StreamingQuality.high].enumerated()) - + + let quality = Array( + [ + StreamingQuality.auto, + StreamingQuality.low, + StreamingQuality.medium, + StreamingQuality.high + ] + .enumerated() + ) + var errorMessage: String? { didSet { withAnimation { @@ -40,8 +49,8 @@ public class SettingsViewModel: ObservableObject { } } - private var userSettings: UserSettings - + @Published private(set) var userSettings: UserSettings + private let interactor: ProfileInteractorProtocol let router: ProfileRouter @@ -49,13 +58,19 @@ public class SettingsViewModel: ObservableObject { self.interactor = interactor self.router = router - self.userSettings = interactor.getSettings() + let userSettings = interactor.getSettings() + self.userSettings = userSettings self.wifiOnly = userSettings.wifiOnly self.selectedQuality = userSettings.streamingQuality } + + func update(downloadQuality: DownloadQuality) { + self.userSettings.downloadQuality = downloadQuality + interactor.saveSettings(userSettings) + } } -extension StreamingQuality { +public extension StreamingQuality { func title() -> String { switch self { diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index 80feaed87..9a77fae47 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -73,6 +73,7 @@ "SETTINGS.QUALITY_720_TITLE" = "720p"; "SETTINGS.QUALITY_720_DESCRIPTION" = "Best quality"; + "SETTINGS.VERSION" = "Version:"; "SETTINGS.UP_TO_DATE" = "Up-to-date"; "SETTINGS.TAP_TO_UPDATE" = "Tap to update to version"; diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 5b85e5b1a..b75975aab 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1134,6 +1134,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { if scopes.contains(.perform) { methodPerformValues = [] } } + public var currentDownloadTask: DownloadDataTask? { + get { invocations.append(.p_currentDownloadTask_get); return __p_currentDownloadTask ?? optionalGivenGetterValue(.p_currentDownloadTask_get, "DownloadManagerProtocolMock - stub value for currentDownloadTask was not defined") } + } + private var __p_currentDownloadTask: (DownloadDataTask)? + @@ -1152,29 +1157,44 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func addToDownloadQueue(blocks: [CourseBlock]) throws { - addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) + open func eventPublisher() -> AnyPublisher { + addInvocation(.m_eventPublisher) + let perform = methodPerformValue(.m_eventPublisher) as? () -> Void + perform?() + var __value: AnyPublisher do { - _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void - } catch MockError.notStubed { - // do nothing + __value = try methodReturnValue(.m_eventPublisher).casted() } catch { - throw error + onFatalFailure("Stub return value not specified for eventPublisher(). Use given") + Failure("Stub return value not specified for eventPublisher(). Use given") } + return __value + } + + open func getDownloadTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasks) + let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadTasks(). Use given") + Failure("Stub return value not specified for getDownloadTasks(). Use given") + } + return __value } - open func getDownloadsForCourse(_ courseId: String) -> [DownloadData] { - addInvocation(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) - let perform = methodPerformValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + open func getDownloadTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void perform?(`courseId`) - var __value: [DownloadData] + var __value: [DownloadDataTask] do { - __value = try methodReturnValue(.m_getDownloadsForCourse__courseId(Parameter.value(`courseId`))).casted() + __value = try methodReturnValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))).casted() } catch { - onFatalFailure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") - Failure("Stub return value not specified for getDownloadsForCourse(_ courseId: String). Use given") + onFatalFailure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") } return __value } @@ -1192,12 +1212,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } - open func resumeDownloading() throws { - addInvocation(.m_resumeDownloading) - let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void - perform?() + open func cancelDownloading(task: DownloadDataTask) throws { + addInvocation(.m_cancelDownloading__task_task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_cancelDownloading__task_task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) do { - _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + _ = try methodReturnValue(.m_cancelDownloading__task_task(Parameter.value(`task`))).casted() as Void } catch MockError.notStubed { // do nothing } catch { @@ -1205,10 +1225,10 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } - open func pauseDownloading() { - addInvocation(.m_pauseDownloading) - let perform = methodPerformValue(.m_pauseDownloading) as? () -> Void - perform?() + open func cancelDownloading(courseId: String) { + addInvocation(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) } open func deleteFile(blocks: [CourseBlock]) { @@ -1236,28 +1256,72 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func isLargeVideosSize(blocks: [CourseBlock]) -> Bool { + addInvocation(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + Failure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + } + return __value + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + fileprivate enum MethodType { case m_publisher - case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) - case m_getDownloadsForCourse__courseId(Parameter) + case m_eventPublisher + case m_getDownloadTasks + case m_getDownloadTasksForCourse__courseId(Parameter) case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) - case m_resumeDownloading - case m_pauseDownloading + case m_cancelDownloading__task_task(Parameter) + case m_cancelDownloading__courseId_courseId(Parameter) case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_resumeDownloading + case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.m_publisher, .m_publisher): return .match - case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + case (.m_eventPublisher, .m_eventPublisher): return .match + + case (.m_getDownloadTasks, .m_getDownloadTasks): return .match - case (.m_getDownloadsForCourse__courseId(let lhsCourseid), .m_getDownloadsForCourse__courseId(let rhsCourseid)): + case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) return Matcher.ComparisonResult(results) @@ -1268,9 +1332,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) - case (.m_resumeDownloading, .m_resumeDownloading): return .match + case (.m_cancelDownloading__task_task(let lhsTask), .m_cancelDownloading__task_task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "task")) + return Matcher.ComparisonResult(results) - case (.m_pauseDownloading, .m_pauseDownloading): return .match + case (.m_cancelDownloading__courseId_courseId(let lhsCourseid), .m_cancelDownloading__courseId_courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + return Matcher.ComparisonResult(results) case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] @@ -1283,6 +1353,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) + + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_resumeDownloading, .m_resumeDownloading): return .match + case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } } @@ -1290,27 +1373,37 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { func intValue() -> Int { switch self { case .m_publisher: return 0 - case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue - case let .m_getDownloadsForCourse__courseId(p0): return p0.intValue + case .m_eventPublisher: return 0 + case .m_getDownloadTasks: return 0 + case let .m_getDownloadTasksForCourse__courseId(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue - case .m_resumeDownloading: return 0 - case .m_pauseDownloading: return 0 + case let .m_cancelDownloading__task_task(p0): return p0.intValue + case let .m_cancelDownloading__courseId_courseId(p0): return p0.intValue case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_resumeDownloading: return 0 + case .p_currentDownloadTask_get: return 0 } } func assertionName() -> String { switch self { case .m_publisher: return ".publisher()" - case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" - case .m_getDownloadsForCourse__courseId: return ".getDownloadsForCourse(_:)" + case .m_eventPublisher: return ".eventPublisher()" + case .m_getDownloadTasks: return ".getDownloadTasks()" + case .m_getDownloadTasksForCourse__courseId: return ".getDownloadTasksForCourse(_:)" case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" - case .m_resumeDownloading: return ".resumeDownloading()" - case .m_pauseDownloading: return ".pauseDownloading()" + case .m_cancelDownloading__task_task: return ".cancelDownloading(task:)" + case .m_cancelDownloading__courseId_courseId: return ".cancelDownloading(courseId:)" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } } @@ -1323,16 +1416,28 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { super.init(products) } + public static func currentDownloadTask(getter defaultValue: DownloadDataTask?...) -> PropertyStub { + return Given(method: .p_currentDownloadTask_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } public static func publisher(willReturn: AnyPublisher...) -> MethodStub { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getDownloadsForCourse(_ courseId: Parameter, willReturn: [DownloadData]...) -> MethodStub { - return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func eventPublisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { let willReturn: [AnyPublisher] = [] let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -1340,10 +1445,24 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func getDownloadsForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadData]>) -> Void) -> MethodStub { - let willReturn: [[DownloadData]] = [] - let given: Given = { return Given(method: .m_getDownloadsForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([DownloadData]).self) + public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) willProduce(stubber) return given } @@ -1354,13 +1473,10 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) willProduce(stubber) return given } @@ -1374,6 +1490,26 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func cancelDownloading(task: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(task: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } public static func resumeDownloading(willThrow: Error...) -> MethodStub { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) } @@ -1390,14 +1526,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { fileprivate var method: MethodType public static func publisher() -> Verify { return Verify(method: .m_publisher)} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} - public static func getDownloadsForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadsForCourse__courseId(`courseId`))} + public static func eventPublisher() -> Verify { return Verify(method: .m_eventPublisher)} + public static func getDownloadTasks() -> Verify { return Verify(method: .m_getDownloadTasks)} + public static func getDownloadTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadTasksForCourse__courseId(`courseId`))} public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} - public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} - public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} + public static func cancelDownloading(task: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__task_task(`task`))} + public static func cancelDownloading(courseId: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseId(`courseId`))} public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} + public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } public struct Perform { @@ -1407,20 +1548,23 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func publisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_publisher, performs: perform) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + public static func eventPublisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_eventPublisher, performs: perform) + } + public static func getDownloadTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadTasks, performs: perform) } - public static func getDownloadsForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_getDownloadsForCourse__courseId(`courseId`), performs: perform) + public static func getDownloadTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadTasksForCourse__courseId(`courseId`), performs: perform) } public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) } - public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_resumeDownloading, performs: perform) + public static func cancelDownloading(task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__task_task(`task`), performs: perform) } - public static func pauseDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_pauseDownloading, performs: perform) + public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) } public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) @@ -1431,6 +1575,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) + } + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) + } } public func given(_ method: Given) { @@ -2420,6 +2573,12 @@ open class ProfileRouterMock: ProfileRouter, Mock { perform?(`viewModel`) } + open func showVideoDownloadQualityView(downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?) { + addInvocation(.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(Parameter.value(`downloadQuality`), Parameter<((DownloadQuality) -> Void)?>.value(`didSelect`))) + let perform = methodPerformValue(.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(Parameter.value(`downloadQuality`), Parameter<((DownloadQuality) -> Void)?>.value(`didSelect`))) as? (DownloadQuality, ((DownloadQuality) -> Void)?) -> Void + perform?(`downloadQuality`, `didSelect`) + } + open func showDeleteProfileView() { addInvocation(.m_showDeleteProfileView) let perform = methodPerformValue(.m_showDeleteProfileView) as? () -> Void @@ -2521,6 +2680,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit(Parameter, Parameter, Parameter<((UserProfile?, UIImage?)) -> Void>) case m_showSettings case m_showVideoQualityView__viewModel_viewModel(Parameter) + case m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(Parameter, Parameter<((DownloadQuality) -> Void)?>) case m_showDeleteProfileView case m_backToRoot__animated_animated(Parameter) case m_back__animated_animated(Parameter) @@ -2554,6 +2714,12 @@ open class ProfileRouterMock: ProfileRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsViewmodel, rhs: rhsViewmodel, with: matcher), lhsViewmodel, rhsViewmodel, "viewModel")) return Matcher.ComparisonResult(results) + case (.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(let lhsDownloadquality, let lhsDidselect), .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(let rhsDownloadquality, let rhsDidselect)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDidselect, rhs: rhsDidselect, with: matcher), lhsDidselect, rhsDidselect, "didSelect")) + return Matcher.ComparisonResult(results) + case (.m_showDeleteProfileView, .m_showDeleteProfileView): return .match case (.m_backToRoot__animated_animated(let lhsAnimated), .m_backToRoot__animated_animated(let rhsAnimated)): @@ -2645,6 +2811,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case let .m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case .m_showSettings: return 0 case let .m_showVideoQualityView__viewModel_viewModel(p0): return p0.intValue + case let .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(p0, p1): return p0.intValue + p1.intValue case .m_showDeleteProfileView: return 0 case let .m_backToRoot__animated_animated(p0): return p0.intValue case let .m_back__animated_animated(p0): return p0.intValue @@ -2668,6 +2835,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit: return ".showEditProfile(userModel:avatar:profileDidEdit:)" case .m_showSettings: return ".showSettings()" case .m_showVideoQualityView__viewModel_viewModel: return ".showVideoQualityView(viewModel:)" + case .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect: return ".showVideoDownloadQualityView(downloadQuality:didSelect:)" case .m_showDeleteProfileView: return ".showDeleteProfileView()" case .m_backToRoot__animated_animated: return ".backToRoot(animated:)" case .m_back__animated_animated: return ".back(animated:)" @@ -2705,6 +2873,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showEditProfile(userModel: Parameter, avatar: Parameter, profileDidEdit: Parameter<((UserProfile?, UIImage?)) -> Void>) -> Verify { return Verify(method: .m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit(`userModel`, `avatar`, `profileDidEdit`))} public static func showSettings() -> Verify { return Verify(method: .m_showSettings)} public static func showVideoQualityView(viewModel: Parameter) -> Verify { return Verify(method: .m_showVideoQualityView__viewModel_viewModel(`viewModel`))} + public static func showVideoDownloadQualityView(downloadQuality: Parameter, didSelect: Parameter<((DownloadQuality) -> Void)?>) -> Verify { return Verify(method: .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(`downloadQuality`, `didSelect`))} public static func showDeleteProfileView() -> Verify { return Verify(method: .m_showDeleteProfileView)} public static func backToRoot(animated: Parameter) -> Verify { return Verify(method: .m_backToRoot__animated_animated(`animated`))} public static func back(animated: Parameter) -> Verify { return Verify(method: .m_back__animated_animated(`animated`))} @@ -2736,6 +2905,9 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showVideoQualityView(viewModel: Parameter, perform: @escaping (SettingsViewModel) -> Void) -> Perform { return Perform(method: .m_showVideoQualityView__viewModel_viewModel(`viewModel`), performs: perform) } + public static func showVideoDownloadQualityView(downloadQuality: Parameter, didSelect: Parameter<((DownloadQuality) -> Void)?>, perform: @escaping (DownloadQuality, ((DownloadQuality) -> Void)?) -> Void) -> Perform { + return Perform(method: .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(`downloadQuality`, `didSelect`), performs: perform) + } public static func showDeleteProfileView(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showDeleteProfileView, performs: perform) }