From ec5a7bc2a630ef4cddb68cda7e728640a1152580 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 21 Jul 2023 11:37:22 +0300 Subject: [PATCH 01/19] bug fix (#57) --- .../Presentation/Comments/Responses/ResponsesView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 3df3ebdcf..0b0ba637b 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -156,7 +156,7 @@ public struct ResponsesView: View { await viewModel.postComment( threadID: threadID, rawBody: commentText, - parentID: viewModel.postComments?.parentID + parentID: commentID ) } } From 11f2aada079988141a833aab4a0fb446d38246b5 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 21 Jul 2023 16:30:05 +0300 Subject: [PATCH 02/19] Fix DisclosureGroup fields bug on SignUpView (#58) --- .../Authorization/Presentation/Registration/SignUpView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 6cace79ba..e1c852df4 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -80,7 +80,7 @@ public struct SignUpView: View { router: viewModel.router, configuration: viewModel.config, cssInjector: viewModel.cssInjector, - proxy: proxy) + proxy: proxy).padding(.horizontal, 1) }, label: { Text(disclosureGroupOpen ? AuthLocalization.SignUp.hideFields From f731d6d6887456ab6dad4e82e9be66727c9190d6 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 21 Jul 2023 17:48:50 +0300 Subject: [PATCH 03/19] Hide no discussion while update (#59) --- .../Discussion/Presentation/Posts/PostsView.swift | 2 +- .../Presentation/Posts/PostsViewModel.swift | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index e3936c3b3..954891545 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -157,7 +157,7 @@ public struct PostsView: View { Spacer(minLength: 84) } } else { - if !viewModel.isShowProgress { + if !viewModel.fetchInProgress { VStack(spacing: 0) { CoreAssets.discussionIcon.swiftUIImage .renderingMode(.template) diff --git a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift index 7d93b50cd..a618bdff9 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift @@ -176,9 +176,11 @@ public class PostsViewModel: ObservableObject { if index == filteredPosts.count - 3 { if totalPages != 1 { if nextPage <= totalPages { - _ = await getPosts(courseID: courseID, - pageNumber: self.nextPage, - withProgress: withProgress) + _ = await getPosts( + courseID: courseID, + pageNumber: self.nextPage, + withProgress: withProgress + ) } } } @@ -202,7 +204,6 @@ public class PostsViewModel: ObservableObject { if threads.threads.indices.contains(0) { self.totalPages = threads.threads[0].numPages self.nextPage += 1 - fetchInProgress = false } case .followingPosts: threads.threads += try await interactor @@ -214,7 +215,6 @@ public class PostsViewModel: ObservableObject { if threads.threads.indices.contains(0) { self.totalPages = threads.threads[0].numPages self.nextPage += 1 - fetchInProgress = false } case .nonCourseTopics: threads.threads += try await interactor @@ -226,7 +226,6 @@ public class PostsViewModel: ObservableObject { if threads.threads.indices.contains(0) { self.totalPages = threads.threads[0].numPages self.nextPage += 1 - fetchInProgress = false } case .courseTopics(topicID: let topicID): threads.threads += try await interactor @@ -238,7 +237,6 @@ public class PostsViewModel: ObservableObject { if threads.threads.indices.contains(0) { self.totalPages = threads.threads[0].numPages self.nextPage += 1 - fetchInProgress = false } case .none: isShowProgress = false @@ -248,9 +246,11 @@ public class PostsViewModel: ObservableObject { filteredPosts = discussionPosts self.filteredPosts = self.discussionPosts isShowProgress = false + fetchInProgress = false return true } catch let error { isShowProgress = false + fetchInProgress = false if error.isInternetError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection } else { From 80c2e67b7254fac4d859ada3f8a3ae695cf0d4a0 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Tue, 25 Jul 2023 17:11:07 +0300 Subject: [PATCH 04/19] Consolidating Databases and Refactoring Module Logic (#60) This merge request aims to consolidate multiple databases into a single one for improved efficiency and maintainability in the OpenEdx project. The changes involve creating a new class, DatabaseManager, which handles CoreData operations and moving CoreData logic from specific modules to this central manager. Additionally, a new method, deleteAllFiles(), is added to the DownloadManager class for convenient file deletion upon user logout. --- .../AuthorizationMock.generated.swift | 15 +++ Core/Core.xcodeproj/project.pbxproj | 8 +- .../Persistence/CorePersistenceProtocol.swift | 24 ++++ Core/Core/Network/DownloadManager.swift | 35 +++++ Course/Course.xcodeproj/project.pbxproj | 8 +- .../Model/Data_CourseOutlineResponse.swift | 94 ++++++++++---- .../CoursePersistenceProtocol.swift | 25 ++++ .../Course/Domain/Model/CourseDetails.swift | 24 ++-- Course/CourseTests/CourseMock.generated.swift | 15 +++ Dashboard/Dashboard.xcodeproj/project.pbxproj | 8 +- .../DashboardCoreModel.xcdatamodel/contents | 4 +- .../Persistence/DashboardPersistence.swift | 120 ----------------- .../DashboardPersistenceProtocol.swift | 18 +++ .../DashboardMock.generated.swift | 15 +++ Discovery/Discovery.xcodeproj/project.pbxproj | 8 +- .../DiscoveryCoreModel.xcdatamodel/contents | 4 +- .../Persistence/DiscoveryPersistence.swift | 122 ------------------ .../DiscoveryPersistenceProtocol.swift | 18 +++ .../DiscoveryMock.generated.swift | 15 +++ .../DiscussionMock.generated.swift | 15 +++ OpenEdX.xcodeproj/project.pbxproj | 32 ++++- OpenEdX/AppDelegate.swift | 1 + OpenEdX/CoreDataHandler.swift | 35 ----- OpenEdX/DI/AppAssembly.swift | 12 +- OpenEdX/DI/ScreenAssembly.swift | 22 +--- .../Data}/CorePersistence.swift | 55 ++------ .../Data}/CoursePersistence.swift | 82 ++---------- OpenEdX/Data/DashboardPersistence.swift | 66 ++++++++++ OpenEdX/Data/DatabaseManager.swift | 103 +++++++++++++++ OpenEdX/Data/DiscoveryPersistence.swift | 69 ++++++++++ Profile/Profile/Data/ProfileRepository.swift | 11 +- .../ProfileTests/ProfileMock.generated.swift | 15 +++ 32 files changed, 626 insertions(+), 472 deletions(-) create mode 100644 Core/Core/Data/Persistence/CorePersistenceProtocol.swift create mode 100644 Course/Course/Data/Persistence/CoursePersistenceProtocol.swift delete mode 100644 Dashboard/Dashboard/Data/Persistence/DashboardPersistence.swift create mode 100644 Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift delete mode 100644 Discovery/Discovery/Data/Persistence/DiscoveryPersistence.swift create mode 100644 Discovery/Discovery/Data/Persistence/DiscoveryPersistenceProtocol.swift delete mode 100644 OpenEdX/CoreDataHandler.swift rename {Core/Core/Data/Persistence => OpenEdX/Data}/CorePersistence.swift (77%) rename {Course/Course/Data/Persistence => OpenEdX/Data}/CoursePersistence.swift (76%) create mode 100644 OpenEdX/Data/DashboardPersistence.swift create mode 100644 OpenEdX/Data/DatabaseManager.swift create mode 100644 OpenEdX/Data/DiscoveryPersistence.swift diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index b47ea117a..ddb4ac259 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -1786,6 +1786,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { perform?(`blocks`) } + open func deleteAllFiles() { + addInvocation(.m_deleteAllFiles) + let perform = methodPerformValue(.m_deleteAllFiles) as? () -> Void + perform?() + } + open func fileUrl(for blockId: String) -> URL? { addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void @@ -1808,6 +1814,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_resumeDownloading case m_pauseDownloading case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) + case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -1839,6 +1846,8 @@ 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_deleteAllFiles, .m_deleteAllFiles): return .match + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) @@ -1856,6 +1865,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_resumeDownloading: return 0 case .m_pauseDownloading: return 0 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 } } @@ -1868,6 +1878,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_resumeDownloading: return ".resumeDownloading()" case .m_pauseDownloading: return ".pauseDownloading()" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" + case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" } } @@ -1954,6 +1965,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} 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`))} } @@ -1982,6 +1994,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) } + public static func deleteAllFiles(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllFiles, performs: perform) + } public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index a23953164..11e7938c4 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -63,7 +63,7 @@ 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028F9F38293A452B00DE65D0 /* ResetPassword.swift */; }; 0295B1DC297FF114003B0C65 /* SF-Pro.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0295B1DA297FF0E9003B0C65 /* SF-Pro.ttf */; }; 0295C885299B99DD00ABE571 /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */; }; - 02A4833529B8A73400D33F33 /* CorePersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833429B8A73400D33F33 /* CorePersistence.swift */; }; + 02A4833529B8A73400D33F33 /* CorePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */; }; 02A4833829B8A8F900D33F33 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833629B8A8F800D33F33 /* CoreDataModel.xcdatamodeld */; }; 02A4833A29B8A9AB00D33F33 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */; }; 02A4833C29B8C57800D33F33 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833B29B8C57800D33F33 /* DownloadView.swift */; }; @@ -182,7 +182,7 @@ 028F9F38293A452B00DE65D0 /* ResetPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPassword.swift; sourceTree = ""; }; 0295B1DA297FF0E9003B0C65 /* SF-Pro.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SF-Pro.ttf"; sourceTree = ""; }; 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; - 02A4833429B8A73400D33F33 /* CorePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePersistence.swift; sourceTree = ""; }; + 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePersistenceProtocol.swift; sourceTree = ""; }; 02A4833729B8A8F800D33F33 /* CoreDataModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreDataModel.xcdatamodel; sourceTree = ""; }; 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; 02A4833B29B8C57800D33F33 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; @@ -357,7 +357,7 @@ isa = PBXGroup; children = ( 02A4833629B8A8F800D33F33 /* CoreDataModel.xcdatamodeld */, - 02A4833429B8A73400D33F33 /* CorePersistence.swift */, + 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */, 02CF46C729546AA200A698EE /* NoCachedDataError.swift */, ); path = Persistence; @@ -775,7 +775,7 @@ 028F9F37293A44C700DE65D0 /* Data_ResetPassword.swift in Sources */, 022C64E429AE0191000F532B /* TextWithUrls.swift in Sources */, 0283348028D4DCD200C828FC /* ViewExtension.swift in Sources */, - 02A4833529B8A73400D33F33 /* CorePersistence.swift in Sources */, + 02A4833529B8A73400D33F33 /* CorePersistenceProtocol.swift in Sources */, 02512FF0299533DF0024D438 /* CoreDataHandlerProtocol.swift in Sources */, 0260E58028FD792800BBBE18 /* WebUnitViewModel.swift in Sources */, 02A4833A29B8A9AB00D33F33 /* DownloadManager.swift in Sources */, diff --git a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift new file mode 100644 index 000000000..b93c8a428 --- /dev/null +++ b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift @@ -0,0 +1,24 @@ +// +// CorePersistence.swift +// Core +// +// Created by  Stepanok Ivan on 08.03.2023. +// + +import CoreData +import Combine + +public protocol CorePersistenceProtocol { + func publisher() -> AnyPublisher + func addToDownloadQueue(blocks: [CourseBlock]) + func getNextBlockForDownloading() -> DownloadData? + func getDownloadsForCourse(_ courseId: String) -> [DownloadData] + func downloadData(by blockId: String) -> DownloadData? + func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) + func deleteDownloadData(id: String) throws + func saveDownloadData(data: DownloadData) +} + +public final class CoreBundle { + private init() {} +} diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index e635d1541..33a455cdb 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -29,6 +29,26 @@ public struct DownloadData { public let resumeData: Data? public let state: DownloadState public let type: DownloadType + + public init( + id: String, + courseId: String, + url: String, + fileName: String, + progress: Double, + resumeData: Data?, + state: DownloadState, + type: DownloadType + ) { + self.id = id + self.courseId = courseId + self.url = url + self.fileName = fileName + self.progress = progress + self.resumeData = resumeData + self.state = state + self.type = type + } } public class NoWiFiError: LocalizedError { @@ -44,6 +64,7 @@ public protocol DownloadManagerProtocol { func resumeDownloading() throws func pauseDownloading() func deleteFile(blocks: [CourseBlock]) + func deleteAllFiles() func fileUrl(for blockId: String) -> URL? } @@ -183,6 +204,16 @@ public class DownloadManager: DownloadManagerProtocol { } } + public func deleteAllFiles() { + if let folderUrl = videosFolderUrl() { + do { + try FileManager.default.removeItem(at: folderUrl) + } catch { + NSLog("Error deleting All files: \(error.localizedDescription)") + } + } + } + public func fileUrl(for blockId: String) -> URL? { guard let data = persistence.downloadData(by: blockId), data.url.count > 0, @@ -260,6 +291,10 @@ public class DownloadManagerMock: DownloadManagerProtocol { } + public func deleteAllFiles() { + + } + public func fileUrl(for blockId: String) -> URL? { return nil } diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 9baaaedb5..910e26671 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5C294B4FDA0032823A /* CourseCoreModel.xcdatamodeld */; }; - 02280F60294B50030032823A /* CoursePersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5F294B50030032823A /* CoursePersistence.swift */; }; + 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5F294B50030032823A /* CoursePersistenceProtocol.swift */; }; 022C64D829ACEC48000F532B /* HandoutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64D729ACEC48000F532B /* HandoutsView.swift */; }; 022C64DA29ACEC50000F532B /* HandoutsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64D929ACEC50000F532B /* HandoutsViewModel.swift */; }; 022C64DC29ACFDEE000F532B /* Data_HandoutsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64DB29ACFDEE000F532B /* Data_HandoutsResponse.swift */; }; @@ -78,7 +78,7 @@ /* Begin PBXFileReference section */ 02280F5D294B4FDA0032823A /* CourseCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CourseCoreModel.xcdatamodel; sourceTree = ""; }; - 02280F5F294B50030032823A /* CoursePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoursePersistence.swift; sourceTree = ""; }; + 02280F5F294B50030032823A /* CoursePersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoursePersistenceProtocol.swift; sourceTree = ""; }; 022C64D729ACEC48000F532B /* HandoutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandoutsView.swift; sourceTree = ""; }; 022C64D929ACEC50000F532B /* HandoutsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandoutsViewModel.swift; sourceTree = ""; }; 022C64DB29ACFDEE000F532B /* Data_HandoutsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_HandoutsResponse.swift; sourceTree = ""; }; @@ -186,7 +186,7 @@ 0208666A29CC6D8000BC05B2 /* Persistence */ = { isa = PBXGroup; children = ( - 02280F5F294B50030032823A /* CoursePersistence.swift */, + 02280F5F294B50030032823A /* CoursePersistenceProtocol.swift */, 02280F5C294B4FDA0032823A /* CourseCoreModel.xcdatamodeld */, ); path = Persistence; @@ -683,7 +683,7 @@ 02454CA02A2618E70043052A /* YouTubeView.swift in Sources */, 02454CA22A26190A0043052A /* EncodedVideoView.swift in Sources */, 02B6B3BC28E1D14F00232911 /* CourseRepository.swift in Sources */, - 02280F60294B50030032823A /* CoursePersistence.swift in Sources */, + 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */, 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */, 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */, 0766DFCE299AB26D00EBEF6A /* EncodedVideoPlayer.swift in Sources */, diff --git a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift index 8340e86ff..4e67cdfc3 100644 --- a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift +++ b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift @@ -10,13 +10,15 @@ import CoreData import Core public extension DataLayer { + + typealias Blocks = [String: CourseBlock] + struct CourseStructure: Decodable { - let rootItem: String - typealias Blocks = [String: CourseBlock] - var dict: Blocks - let id: String - let media: DataLayer.CourseMedia - let certificate: Certificate? + public let rootItem: String + public var dict: Blocks + public let id: String + public let media: DataLayer.CourseMedia + public let certificate: Certificate? enum CodingKeys: String, CodingKey { case blocks @@ -26,7 +28,7 @@ public extension DataLayer { case certificate } - init(rootItem: String, dict: Blocks, id: String, media: DataLayer.CourseMedia, certificate: Certificate?) { + public init(rootItem: String, dict: Blocks, id: String, media: DataLayer.CourseMedia, certificate: Certificate?) { self.rootItem = rootItem self.dict = dict self.id = id @@ -47,16 +49,40 @@ public extension DataLayer { } public extension DataLayer { struct CourseBlock: Decodable { - let blockId: String - let id: String - let graded: Bool - let completion: Double? - let studentUrl: String - let type: String - let displayName: String - let descendants: [String]? - let allSources: [String]? - let userViewData: CourseDetailUserViewData? + public let blockId: String + public let id: String + public let graded: Bool + public let completion: Double? + public let studentUrl: String + public let type: String + public let displayName: String + public let descendants: [String]? + public let allSources: [String]? + public let userViewData: CourseDetailUserViewData? + + public init( + blockId: String, + id: String, + graded: Bool, + completion: Double?, + studentUrl: String, + type: String, + displayName: String, + descendants: [String]?, + allSources: [String]?, + userViewData: CourseDetailUserViewData? + ) { + self.blockId = blockId + self.id = id + self.graded = graded + self.completion = completion + self.studentUrl = studentUrl + self.type = type + self.displayName = displayName + self.descendants = descendants + self.allSources = allSources + self.userViewData = userViewData + } public enum CodingKeys: String, CodingKey { case id, type, descendants, graded, completion @@ -81,9 +107,19 @@ public extension DataLayer { } struct CourseDetailUserViewData: Decodable { - let transcripts: [String: String]? - let encodedVideo: CourseDetailEncodedVideoData? - let topicID: String? + public let transcripts: [String: String]? + public let encodedVideo: CourseDetailEncodedVideoData? + public let topicID: String? + + public init( + transcripts: [String: String]?, + encodedVideo: CourseDetailEncodedVideoData?, + topicID: String? + ) { + self.transcripts = transcripts + self.encodedVideo = encodedVideo + self.topicID = topicID + } public enum CodingKeys: String, CodingKey { case encodedVideo = "encoded_videos" @@ -93,8 +129,16 @@ public extension DataLayer { } struct CourseDetailEncodedVideoData: Decodable { - let youTube: CourseDetailYouTubeData? - let fallback: CourseDetailYouTubeData? + public let youTube: CourseDetailYouTubeData? + public let fallback: CourseDetailYouTubeData? + + public init( + youTube: CourseDetailYouTubeData?, + fallback: CourseDetailYouTubeData? + ) { + self.youTube = youTube + self.fallback = fallback + } enum CodingKeys: String, CodingKey { case youTube = "youtube" @@ -103,7 +147,11 @@ public extension DataLayer { } struct CourseDetailYouTubeData: Decodable { - let url: String? + public let url: String? + + public init(url: String?) { + self.url = url + } } } diff --git a/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift b/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift new file mode 100644 index 000000000..b17874645 --- /dev/null +++ b/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift @@ -0,0 +1,25 @@ +// +// CoursePersistence.swift +// Course +// +// Created by  Stepanok Ivan on 15.12.2022. +// + +import CoreData +import Core + +public protocol CoursePersistenceProtocol { + func loadCourseDetails(courseID: String) throws -> CourseDetails + func saveCourseDetails(course: CourseDetails) + func loadEnrollments() throws -> [Core.CourseItem] + func saveEnrollments(items: [Core.CourseItem]) + func loadCourseStructure(courseID: String) throws -> DataLayer.CourseStructure + func saveCourseStructure(structure: DataLayer.CourseStructure) + func saveSubtitles(url: String, subtitlesString: String) + func loadSubtitles(url: String) -> String? +} + +public final class CourseBundle { + private init() {} +} + \ No newline at end of file diff --git a/Course/Course/Domain/Model/CourseDetails.swift b/Course/Course/Domain/Model/CourseDetails.swift index b7adedbe8..0edb58854 100644 --- a/Course/Course/Domain/Model/CourseDetails.swift +++ b/Course/Course/Domain/Model/CourseDetails.swift @@ -8,18 +8,18 @@ import Foundation public struct CourseDetails { - let courseID: String - let org: String - let courseTitle: String - let courseDescription: String - let courseStart: Date? - let courseEnd: Date? - let enrollmentStart: Date? - let enrollmentEnd: Date? - var isEnrolled: Bool - var overviewHTML: String - let courseBannerURL: String - let courseVideoURL: String? + public let courseID: String + public let org: String + public let courseTitle: String + public let courseDescription: String + public let courseStart: Date? + public let courseEnd: Date? + public let enrollmentStart: Date? + public let enrollmentEnd: Date? + public var isEnrolled: Bool + public var overviewHTML: String + public let courseBannerURL: String + public let courseVideoURL: String? public init(courseID: String, org: String, diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index bdf04ef71..a4cf6b418 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -2178,6 +2178,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { perform?(`blocks`) } + open func deleteAllFiles() { + addInvocation(.m_deleteAllFiles) + let perform = methodPerformValue(.m_deleteAllFiles) as? () -> Void + perform?() + } + open func fileUrl(for blockId: String) -> URL? { addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void @@ -2200,6 +2206,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_resumeDownloading case m_pauseDownloading case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) + case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2231,6 +2238,8 @@ 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_deleteAllFiles, .m_deleteAllFiles): return .match + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) @@ -2248,6 +2257,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_resumeDownloading: return 0 case .m_pauseDownloading: return 0 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 } } @@ -2260,6 +2270,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_resumeDownloading: return ".resumeDownloading()" case .m_pauseDownloading: return ".pauseDownloading()" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" + case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" } } @@ -2346,6 +2357,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} 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`))} } @@ -2374,6 +2386,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) } + public static func deleteAllFiles(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllFiles, performs: perform) + } public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index 4aee39a87..7f943cdf7 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -14,7 +14,7 @@ 027DB34328D8E89B002B6862 /* DashboardInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB34228D8E89B002B6862 /* DashboardInteractor.swift */; }; 027DB34528D8E9D2002B6862 /* DashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB34428D8E9D2002B6862 /* DashboardViewModel.swift */; }; 02A48B18295ACE200033D5E0 /* DashboardCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02A48B16295ACE200033D5E0 /* DashboardCoreModel.xcdatamodeld */; }; - 02A48B1A295ACE3D0033D5E0 /* DashboardPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A48B19295ACE3D0033D5E0 /* DashboardPersistence.swift */; }; + 02A48B1A295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A48B19295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift */; }; 02A9A90B2978194100B55797 /* DashboardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9A90A2978194100B55797 /* DashboardViewModelTests.swift */; }; 02A9A90C2978194100B55797 /* Dashboard.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02EF39E728D89F560058F6BD /* Dashboard.framework */; platformFilter = ios; }; 02A9A92929781A4D00B55797 /* DashboardMock.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9A92829781A4D00B55797 /* DashboardMock.generated.swift */; }; @@ -45,7 +45,7 @@ 027DB34228D8E89B002B6862 /* DashboardInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardInteractor.swift; sourceTree = ""; }; 027DB34428D8E9D2002B6862 /* DashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModel.swift; sourceTree = ""; }; 02A48B17295ACE200033D5E0 /* DashboardCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DashboardCoreModel.xcdatamodel; sourceTree = ""; }; - 02A48B19295ACE3D0033D5E0 /* DashboardPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPersistence.swift; sourceTree = ""; }; + 02A48B19295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPersistenceProtocol.swift; sourceTree = ""; }; 02A9A9082978194100B55797 /* DashboardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DashboardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 02A9A90A2978194100B55797 /* DashboardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModelTests.swift; sourceTree = ""; }; 02A9A92829781A4D00B55797 /* DashboardMock.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DashboardMock.generated.swift; sourceTree = ""; }; @@ -101,7 +101,7 @@ 0208666929CC6D0F00BC05B2 /* Persistence */ = { isa = PBXGroup; children = ( - 02A48B19295ACE3D0033D5E0 /* DashboardPersistence.swift */, + 02A48B19295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift */, 02A48B16295ACE200033D5E0 /* DashboardCoreModel.xcdatamodeld */, ); path = Persistence; @@ -443,7 +443,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 02A48B1A295ACE3D0033D5E0 /* DashboardPersistence.swift in Sources */, + 02A48B1A295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift in Sources */, 027DB33D28D8DB5E002B6862 /* DashboardRepository.swift in Sources */, 02A48B18295ACE200033D5E0 /* DashboardCoreModel.xcdatamodeld in Sources */, 02F175332A4DABBF0019CD70 /* DashboardAnalytics.swift in Sources */, diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents index 60c42556d..eeee515fe 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents +++ b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents @@ -1,6 +1,6 @@ - - + + diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardPersistence.swift b/Dashboard/Dashboard/Data/Persistence/DashboardPersistence.swift deleted file mode 100644 index f2b109d76..000000000 --- a/Dashboard/Dashboard/Data/Persistence/DashboardPersistence.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// DashboardPersistence.swift -// Dashboard -// -// Created by  Stepanok Ivan on 27.12.2022. -// - -import CoreData -import Core - -public protocol DashboardPersistenceProtocol { - func loadMyCourses() throws -> [CourseItem] - func saveMyCourses(items: [CourseItem]) - func clear() -} - -public class DashboardPersistence: DashboardPersistenceProtocol { - - private let model = "DashboardCoreModel" - - private lazy var persistentContainer: NSPersistentContainer = { - return createContainer() - }() - - private lazy var context: NSManagedObjectContext = { - return createContext() - }() - - public init() {} - - public func loadMyCourses() throws -> [CourseItem] { - let result = try? context.fetch(CDCourseItem.fetchRequest()) - .map { CourseItem(name: $0.name ?? "", - org: $0.org ?? "", - shortDescription: $0.desc ?? "", - imageURL: $0.imageURL ?? "", - isActive: nil, - courseStart: $0.courseStart, - courseEnd: $0.courseEnd, - enrollmentStart: $0.enrollmentStart, - enrollmentEnd: $0.enrollmentEnd, - courseID: $0.courseID ?? "", - numPages: Int($0.numPages), - coursesCount: Int($0.courseCount))} - if let result, !result.isEmpty { - return result - } else { - throw NoCachedDataError() - } - } - - public func saveMyCourses(items: [CourseItem]) { - for item in items { - context.performAndWait { - let newItem = CDCourseItem(context: context) - context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - newItem.name = item.name - newItem.org = item.org - newItem.desc = item.shortDescription - newItem.imageURL = item.imageURL - newItem.courseStart = item.courseStart - newItem.courseEnd = item.courseEnd - newItem.enrollmentStart = item.enrollmentStart - newItem.enrollmentEnd = item.enrollmentEnd - newItem.numPages = Int32(item.numPages) - newItem.courseID = item.courseID - - do { - try context.save() - } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) - } - } - } - } - - public func clear() { - let storeContainer = persistentContainer.persistentStoreCoordinator - for store in storeContainer.persistentStores { - do { - try storeContainer.destroyPersistentStore( - at: store.url!, - ofType: store.type, - options: nil - ) - } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) - } - } - - // Re-create the persistent container - persistentContainer = createContainer() - context = createContext() - } - - private func createContainer() -> NSPersistentContainer { - let bundle = Bundle(for: Self.self) - let url = bundle.url(forResource: model, withExtension: "momd") - let managedObjectModel = NSManagedObjectModel(contentsOf: url!) - let container = NSPersistentContainer(name: model, managedObjectModel: managedObjectModel!) - container.loadPersistentStores(completionHandler: { (_, error) in - if let error = error as NSError? { - fatalError("Unresolved error \(error), \(error.userInfo)") - } - }) - let description = NSPersistentStoreDescription() - description.shouldInferMappingModelAutomatically = true - description.shouldMigrateStoreAutomatically = true - container.persistentStoreDescriptions = [description] - - return container - } - - private func createContext() -> NSManagedObjectContext { - let context = persistentContainer.newBackgroundContext() - context.automaticallyMergesChangesFromParent = true - return context - } - -} diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift b/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift new file mode 100644 index 000000000..14bad2aaa --- /dev/null +++ b/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift @@ -0,0 +1,18 @@ +// +// DashboardPersistence.swift +// Dashboard +// +// Created by  Stepanok Ivan on 27.12.2022. +// + +import CoreData +import Core + +public protocol DashboardPersistenceProtocol { + func loadMyCourses() throws -> [CourseItem] + func saveMyCourses(items: [CourseItem]) +} + +public final class DashboardBundle { + private init() {} +} diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index b703eace7..27aebe250 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1536,6 +1536,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { perform?(`blocks`) } + open func deleteAllFiles() { + addInvocation(.m_deleteAllFiles) + let perform = methodPerformValue(.m_deleteAllFiles) as? () -> Void + perform?() + } + open func fileUrl(for blockId: String) -> URL? { addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void @@ -1558,6 +1564,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_resumeDownloading case m_pauseDownloading case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) + case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -1589,6 +1596,8 @@ 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_deleteAllFiles, .m_deleteAllFiles): return .match + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) @@ -1606,6 +1615,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_resumeDownloading: return 0 case .m_pauseDownloading: return 0 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 } } @@ -1618,6 +1628,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_resumeDownloading: return ".resumeDownloading()" case .m_pauseDownloading: return ".pauseDownloading()" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" + case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" } } @@ -1704,6 +1715,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} 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`))} } @@ -1732,6 +1744,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) } + public static func deleteAllFiles(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllFiles, performs: perform) + } public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index 59b0d96ae..632cd869e 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -15,7 +15,7 @@ 0284DBFC28D4856A00830893 /* DiscoveryEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0284DBFB28D4856A00830893 /* DiscoveryEndpoint.swift */; }; 0284DC0328D4922900830893 /* DiscoveryRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0284DC0228D4922900830893 /* DiscoveryRepository.swift */; }; 029737402949FB070051696B /* DiscoveryCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 0297373E2949FB070051696B /* DiscoveryCoreModel.xcdatamodeld */; }; - 029737422949FB3B0051696B /* DiscoveryPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029737412949FB3B0051696B /* DiscoveryPersistence.swift */; }; + 029737422949FB3B0051696B /* DiscoveryPersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029737412949FB3B0051696B /* DiscoveryPersistenceProtocol.swift */; }; 02EF39D128D867690058F6BD /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 02EF39D028D867690058F6BD /* swiftgen.yml */; }; 02EF39D728D86A380058F6BD /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 02EF39D928D86A380058F6BD /* Localizable.strings */; }; 02EF39DC28D86BEF0058F6BD /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EF39DB28D86BEF0058F6BD /* Strings.swift */; }; @@ -50,7 +50,7 @@ 0284DBFB28D4856A00830893 /* DiscoveryEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryEndpoint.swift; sourceTree = ""; }; 0284DC0228D4922900830893 /* DiscoveryRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryRepository.swift; sourceTree = ""; }; 0297373F2949FB070051696B /* DiscoveryCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DiscoveryCoreModel.xcdatamodel; sourceTree = ""; }; - 029737412949FB3B0051696B /* DiscoveryPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPersistence.swift; sourceTree = ""; }; + 029737412949FB3B0051696B /* DiscoveryPersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPersistenceProtocol.swift; sourceTree = ""; }; 02ED50C729A649C9008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02ED50C829A649C9008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = ""; }; 02EF39D028D867690058F6BD /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; @@ -110,7 +110,7 @@ 0208666829CC6CD600BC05B2 /* Persistence */ = { isa = PBXGroup; children = ( - 029737412949FB3B0051696B /* DiscoveryPersistence.swift */, + 029737412949FB3B0051696B /* DiscoveryPersistenceProtocol.swift */, 0297373E2949FB070051696B /* DiscoveryCoreModel.xcdatamodeld */, ); path = Persistence; @@ -466,7 +466,7 @@ 02F3BFDF29252F2F0051930C /* DiscoveryRouter.swift in Sources */, 0283347928D49A8700C828FC /* DiscoveryViewModel.swift in Sources */, 072787B428D34D91002E9142 /* DiscoveryView.swift in Sources */, - 029737422949FB3B0051696B /* DiscoveryPersistence.swift in Sources */, + 029737422949FB3B0051696B /* DiscoveryPersistenceProtocol.swift in Sources */, 0284DC0328D4922900830893 /* DiscoveryRepository.swift in Sources */, 02EF39DC28D86BEF0058F6BD /* Strings.swift in Sources */, 02F1752F2A4DA3B60019CD70 /* DiscoveryAnalytics.swift in Sources */, diff --git a/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents b/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents index 548b2b57d..dc2f9ce96 100644 --- a/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents +++ b/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents @@ -1,6 +1,6 @@ - - + + diff --git a/Discovery/Discovery/Data/Persistence/DiscoveryPersistence.swift b/Discovery/Discovery/Data/Persistence/DiscoveryPersistence.swift deleted file mode 100644 index 55b0346f0..000000000 --- a/Discovery/Discovery/Data/Persistence/DiscoveryPersistence.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// DiscoveryPersistence.swift -// Discovery -// -// Created by  Stepanok Ivan on 14.12.2022. -// - -import CoreData -import Core - -public protocol DiscoveryPersistenceProtocol { - func loadDiscovery() throws -> [CourseItem] - func saveDiscovery(items: [CourseItem]) - func clear() -} - -public class DiscoveryPersistence: DiscoveryPersistenceProtocol { - - private let model = "DiscoveryCoreModel" - - private lazy var persistentContainer: NSPersistentContainer = { - return createContainer() - }() - - private lazy var context: NSManagedObjectContext = { - return createContext() - }() - - public init() {} - - public func loadDiscovery() throws -> [CourseItem] { - let result = try? context.fetch(CDCourseItem.fetchRequest()) - .map { CourseItem(name: $0.name ?? "", - org: $0.org ?? "", - shortDescription: $0.desc ?? "", - imageURL: $0.imageURL ?? "", - isActive: $0.isActive, - courseStart: $0.courseStart, - courseEnd: $0.courseEnd, - enrollmentStart: $0.enrollmentStart, - enrollmentEnd: $0.enrollmentEnd, - courseID: $0.courseID ?? "", - numPages: Int($0.numPages), - coursesCount: Int($0.courseCount))} - if let result, !result.isEmpty { - return result - } else { - throw NoCachedDataError() - } - } - - public func saveDiscovery(items: [CourseItem]) { - for item in items { - context.performAndWait { - let newItem = CDCourseItem(context: context) - context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - newItem.name = item.name - newItem.org = item.org - newItem.desc = item.shortDescription - newItem.imageURL = item.imageURL - if let isActive = item.isActive { - newItem.isActive = isActive - } - newItem.courseStart = item.courseStart - newItem.courseEnd = item.courseEnd - newItem.enrollmentStart = item.enrollmentStart - newItem.enrollmentEnd = item.enrollmentEnd - newItem.numPages = Int32(item.numPages) - newItem.courseID = item.courseID - - do { - try context.save() - } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) - } - } - } - } - - public func clear() { - let storeContainer = persistentContainer.persistentStoreCoordinator - for store in storeContainer.persistentStores { - do { - try storeContainer.destroyPersistentStore( - at: store.url!, - ofType: store.type, - options: nil - ) - } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) - } - } - - // Re-create the persistent container - persistentContainer = createContainer() - context = createContext() - } - - private func createContainer() -> NSPersistentContainer { - let bundle = Bundle(for: Self.self) - let url = bundle.url(forResource: model, withExtension: "momd") - let managedObjectModel = NSManagedObjectModel(contentsOf: url!) - let container = NSPersistentContainer(name: model, managedObjectModel: managedObjectModel!) - container.loadPersistentStores(completionHandler: { (_, error) in - if let error = error as NSError? { - fatalError("Unresolved error \(error), \(error.userInfo)") - } - }) - let description = NSPersistentStoreDescription() - description.shouldInferMappingModelAutomatically = true - description.shouldMigrateStoreAutomatically = true - container.persistentStoreDescriptions = [description] - - return container - } - - private func createContext() -> NSManagedObjectContext { - let context = persistentContainer.newBackgroundContext() - context.automaticallyMergesChangesFromParent = true - return context - } -} diff --git a/Discovery/Discovery/Data/Persistence/DiscoveryPersistenceProtocol.swift b/Discovery/Discovery/Data/Persistence/DiscoveryPersistenceProtocol.swift new file mode 100644 index 000000000..a18338e3b --- /dev/null +++ b/Discovery/Discovery/Data/Persistence/DiscoveryPersistenceProtocol.swift @@ -0,0 +1,18 @@ +// +// DiscoveryPersistence.swift +// Discovery +// +// Created by  Stepanok Ivan on 14.12.2022. +// + +import CoreData +import Core + +public protocol DiscoveryPersistenceProtocol { + func loadDiscovery() throws -> [CourseItem] + func saveDiscovery(items: [CourseItem]) +} + +public final class DiscoveryBundle { + private init() {} +} diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 034c7c761..1eb44a322 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -1613,6 +1613,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { perform?(`blocks`) } + open func deleteAllFiles() { + addInvocation(.m_deleteAllFiles) + let perform = methodPerformValue(.m_deleteAllFiles) as? () -> Void + perform?() + } + open func fileUrl(for blockId: String) -> URL? { addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void @@ -1635,6 +1641,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_resumeDownloading case m_pauseDownloading case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) + case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -1666,6 +1673,8 @@ 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_deleteAllFiles, .m_deleteAllFiles): return .match + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) @@ -1683,6 +1692,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_resumeDownloading: return 0 case .m_pauseDownloading: return 0 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 } } @@ -1695,6 +1705,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_resumeDownloading: return ".resumeDownloading()" case .m_pauseDownloading: return ".pauseDownloading()" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" + case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" } } @@ -1781,6 +1792,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} 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`))} } @@ -1809,6 +1821,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) } + public static func deleteAllFiles(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllFiles, performs: perform) + } public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 6335fb469..bcb9ac4df 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -2534,6 +2534,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { perform?(`blocks`) } + open func deleteAllFiles() { + addInvocation(.m_deleteAllFiles) + let perform = methodPerformValue(.m_deleteAllFiles) as? () -> Void + perform?() + } + open func fileUrl(for blockId: String) -> URL? { addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void @@ -2556,6 +2562,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_resumeDownloading case m_pauseDownloading case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) + case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2587,6 +2594,8 @@ 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_deleteAllFiles, .m_deleteAllFiles): return .match + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) @@ -2604,6 +2613,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_resumeDownloading: return 0 case .m_pauseDownloading: return 0 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 } } @@ -2616,6 +2626,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_resumeDownloading: return ".resumeDownloading()" case .m_pauseDownloading: return ".pauseDownloading()" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" + case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" } } @@ -2702,6 +2713,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} 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`))} } @@ -2730,6 +2742,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) } + public static func deleteAllFiles(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllFiles, performs: perform) + } public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index a1df4ef51..101ae2cf5 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -11,11 +11,15 @@ 0218196528F734FA00202564 /* Discussion.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0218196328F734FA00202564 /* Discussion.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0219C67728F4347600D64452 /* Course.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0219C67628F4347600D64452 /* Course.framework */; }; 0219C67828F4347600D64452 /* Course.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0219C67628F4347600D64452 /* Course.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 02512FF2299534300024D438 /* CoreDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02512FF1299534300024D438 /* CoreDataHandler.swift */; }; + 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025AD4AB2A6FB95C00AB8FA7 /* DatabaseManager.swift */; }; 025DE1A428DB4DAE0053E0F4 /* Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 025DE1A328DB4DAE0053E0F4 /* Profile.framework */; }; 025DE1A528DB4DAE0053E0F4 /* Profile.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 025DE1A328DB4DAE0053E0F4 /* Profile.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 027DB32F28D8A063002B6862 /* Dashboard.framework */; }; 027DB33128D8A063002B6862 /* Dashboard.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 027DB32F28D8A063002B6862 /* Dashboard.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 0293A2032A6FCA590090A336 /* CorePersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0293A2022A6FCA590090A336 /* CorePersistence.swift */; }; + 0293A2052A6FCD430090A336 /* CoursePersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0293A2042A6FCD430090A336 /* CoursePersistence.swift */; }; + 0293A2072A6FCDA30090A336 /* DiscoveryPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0293A2062A6FCDA30090A336 /* DiscoveryPersistence.swift */; }; + 0293A2092A6FCDE50090A336 /* DashboardPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0293A2082A6FCDE50090A336 /* DashboardPersistence.swift */; }; 0298DF302A4EF7230023A257 /* AnalyticsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */; }; 02ED50D429A6554E008341CD /* сountries.json in Resources */ = {isa = PBXBuildFile; fileRef = 02ED50D629A6554E008341CD /* сountries.json */; }; 02ED50D829A66007008341CD /* languages.json in Resources */ = {isa = PBXBuildFile; fileRef = 02ED50DA29A66007008341CD /* languages.json */; }; @@ -65,11 +69,15 @@ 0218196328F734FA00202564 /* Discussion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discussion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0219C67628F4347600D64452 /* Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 02450ABD29C35FF20094E2D0 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 02512FF1299534300024D438 /* CoreDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHandler.swift; sourceTree = ""; }; + 025AD4AB2A6FB95C00AB8FA7 /* DatabaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseManager.swift; sourceTree = ""; }; 025C77A028E463E900B3DFA3 /* CourseOutline.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CourseOutline.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 025DE1A328DB4DAE0053E0F4 /* Profile.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Profile.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 025EF2F7297177F300B838AB /* OpenEdX.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenEdX.entitlements; sourceTree = ""; }; 027DB32F28D8A063002B6862 /* Dashboard.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Dashboard.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0293A2022A6FCA590090A336 /* CorePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePersistence.swift; sourceTree = ""; }; + 0293A2042A6FCD430090A336 /* CoursePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoursePersistence.swift; sourceTree = ""; }; + 0293A2062A6FCDA30090A336 /* DiscoveryPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPersistence.swift; sourceTree = ""; }; + 0293A2082A6FCDE50090A336 /* DashboardPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPersistence.swift; sourceTree = ""; }; 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsManager.swift; sourceTree = ""; }; 02B6B3C428E1E61400232911 /* CourseDetails.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CourseDetails.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 02ED50CA29A64AAA008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; @@ -123,6 +131,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0293A2012A6FC9E30090A336 /* Data */ = { + isa = PBXGroup; + children = ( + 025AD4AB2A6FB95C00AB8FA7 /* DatabaseManager.swift */, + 0293A2022A6FCA590090A336 /* CorePersistence.swift */, + 0293A2042A6FCD430090A336 /* CoursePersistence.swift */, + 0293A2062A6FCDA30090A336 /* DiscoveryPersistence.swift */, + 0293A2082A6FCDE50090A336 /* DashboardPersistence.swift */, + ); + path = Data; + sourceTree = ""; + }; 0727878C28D347B2002E9142 /* View */ = { isa = PBXGroup; children = ( @@ -168,7 +188,7 @@ 0770DE1F28D0858A006D8A5D /* Router.swift */, 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */, 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */, - 02512FF1299534300024D438 /* CoreDataHandler.swift */, + 0293A2012A6FC9E30090A336 /* Data */, 0770DE2628D09209006D8A5D /* SwiftUIHostController.swift */, 0727876C28D23312002E9142 /* Environment.swift */, 0727878C28D347B2002E9142 /* View */, @@ -353,15 +373,19 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0293A2052A6FCD430090A336 /* CoursePersistence.swift in Sources */, 0298DF302A4EF7230023A257 /* AnalyticsManager.swift in Sources */, + 0293A2072A6FCDA30090A336 /* DiscoveryPersistence.swift in Sources */, 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */, 02F175312A4DA95B0019CD70 /* MainScreenAnalytics.swift in Sources */, - 02512FF2299534300024D438 /* CoreDataHandler.swift in Sources */, 0727878E28D347C7002E9142 /* MainScreenView.swift in Sources */, 0770DE5028D0A707006D8A5D /* NetworkAssembly.swift in Sources */, + 0293A2032A6FCA590090A336 /* CorePersistence.swift in Sources */, 0770DE1E28D084E8006D8A5D /* AppAssembly.swift in Sources */, + 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */, 0770DE2028D0858A006D8A5D /* Router.swift in Sources */, 0727876D28D23312002E9142 /* Environment.swift in Sources */, + 0293A2092A6FCDE50090A336 /* DashboardPersistence.swift in Sources */, 0770DE1728D080A1006D8A5D /* RouteController.swift in Sources */, 0770DE2728D09209006D8A5D /* SwiftUIHostController.swift in Sources */, 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */, diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 931f65420..6e847a5ed 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -91,6 +91,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { lastForceLogoutTime = Date().timeIntervalSince1970 Container.shared.resolve(AppStorage.self)?.clear() + Container.shared.resolve(DownloadManagerProtocol.self)?.deleteAllFiles() Container.shared.resolve(CoreDataHandlerProtocol.self)?.clear() window?.rootViewController = RouteController() } diff --git a/OpenEdX/CoreDataHandler.swift b/OpenEdX/CoreDataHandler.swift deleted file mode 100644 index 9746c78fd..000000000 --- a/OpenEdX/CoreDataHandler.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// CoreDataHandler.swift -// OpenEdX -// -// Created by  Stepanok Ivan on 09.02.2023. -// - -import Foundation -import Core -import Dashboard -import Discovery -import Course - -class CoreDataHandler: CoreDataHandlerProtocol { - - private let dashboardPersistence: DashboardPersistenceProtocol - private let discoveryPersistence: DiscoveryPersistenceProtocol - private let coursePersistence: CoursePersistenceProtocol - - init( - dashboardPersistence: DashboardPersistenceProtocol, - discoveryPersistence: DiscoveryPersistenceProtocol, - coursePersistence: CoursePersistenceProtocol - ) { - self.dashboardPersistence = dashboardPersistence - self.discoveryPersistence = discoveryPersistence - self.coursePersistence = coursePersistence - } - - func clear() { - dashboardPersistence.clear() - discoveryPersistence.clear() - coursePersistence.clear() - } -} diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 2e1dabda0..978a5b6b4 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -70,8 +70,16 @@ class AppAssembly: Assembly { Connectivity() } - container.register(CorePersistenceProtocol.self) { _ in - CorePersistence() + container.register(DatabaseManager.self) { _ in + DatabaseManager(databaseName: "Database") + }.inObjectScope(.container) + + container.register(CoreDataHandlerProtocol.self) { r in + r.resolve(DatabaseManager.self)! + }.inObjectScope(.container) + + container.register(CorePersistenceProtocol.self) { r in + CorePersistence(context: r.resolve(DatabaseManager.self)!.context) }.inObjectScope(.container) container.register(DownloadManagerProtocol.self, factory: { r in diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 4e3d37103..618945106 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -19,15 +19,6 @@ import Discussion class ScreenAssembly: Assembly { func assemble(container: Container) { - // MARK: CoreDataHandler - container.register(CoreDataHandlerProtocol.self) { r in - CoreDataHandler( - dashboardPersistence: r.resolve(DashboardPersistenceProtocol.self)!, - discoveryPersistence: r.resolve(DiscoveryPersistenceProtocol.self)!, - coursePersistence: r.resolve(CoursePersistenceProtocol.self)! - ) - } - // MARK: Auth container.register(AuthRepositoryProtocol.self) { r in AuthRepository( @@ -71,8 +62,8 @@ class ScreenAssembly: Assembly { } // MARK: Discovery - container.register(DiscoveryPersistenceProtocol.self) { _ in - DiscoveryPersistence() + container.register(DiscoveryPersistenceProtocol.self) { r in + DiscoveryPersistence(context: r.resolve(DatabaseManager.self)!.context) } container.register(DiscoveryRepositoryProtocol.self) { r in @@ -107,8 +98,8 @@ class ScreenAssembly: Assembly { } // MARK: Dashboard - container.register(DashboardPersistenceProtocol.self) { _ in - DashboardPersistence() + container.register(DashboardPersistenceProtocol.self) { r in + DashboardPersistence(context: r.resolve(DatabaseManager.self)!.context) } container.register(DashboardRepositoryProtocol.self) { r in @@ -139,6 +130,7 @@ class ScreenAssembly: Assembly { api: r.resolve(API.self)!, appStorage: r.resolve(AppStorage.self)!, coreDataHandler: r.resolve(CoreDataHandlerProtocol.self)!, + downloadManager: r.resolve(DownloadManagerProtocol.self)!, config: r.resolve(Config.self)! ) } @@ -182,8 +174,8 @@ class ScreenAssembly: Assembly { } // MARK: Course - container.register(CoursePersistenceProtocol.self) { _ in - CoursePersistence() + container.register(CoursePersistenceProtocol.self) { r in + CoursePersistence(context: r.resolve(DatabaseManager.self)!.context) } container.register(CourseRepositoryProtocol.self) { r in diff --git a/Core/Core/Data/Persistence/CorePersistence.swift b/OpenEdX/Data/CorePersistence.swift similarity index 77% rename from Core/Core/Data/Persistence/CorePersistence.swift rename to OpenEdX/Data/CorePersistence.swift index d465caabe..ce2a4e31e 100644 --- a/Core/Core/Data/Persistence/CorePersistence.swift +++ b/OpenEdX/Data/CorePersistence.swift @@ -1,37 +1,22 @@ // // CorePersistence.swift -// Core +// OpenEdX // -// Created by  Stepanok Ivan on 08.03.2023. +// Created by  Stepanok Ivan on 25.07.2023. // +import Core +import Foundation import CoreData import Combine -public protocol CorePersistenceProtocol { - func publisher() -> AnyPublisher - func addToDownloadQueue(blocks: [CourseBlock]) - func getNextBlockForDownloading() -> DownloadData? - func getDownloadsForCourse(_ courseId: String) -> [DownloadData] - func downloadData(by blockId: String) -> DownloadData? - func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) - func deleteDownloadData(id: String) throws - func saveDownloadData(data: DownloadData) -} - public class CorePersistence: CorePersistenceProtocol { - public init() {} - - private let model = "CoreDataModel" + private var context: NSManagedObjectContext - private lazy var persistentContainer: NSPersistentContainer = { - return createContainer() - }() - - private lazy var context: NSManagedObjectContext = { - return createContext() - }() + public init(context: NSManagedObjectContext) { + self.context = context + } public func publisher() -> AnyPublisher { let notification = NSManagedObjectContext.didChangeObjectsNotification @@ -178,28 +163,4 @@ public class CorePersistence: CorePersistenceProtocol { } } } - - private func createContainer() -> NSPersistentContainer { - let bundle = Bundle(for: Self.self) - let url = bundle.url(forResource: model, withExtension: "momd") - let managedObjectModel = NSManagedObjectModel(contentsOf: url!) - let container = NSPersistentContainer(name: model, managedObjectModel: managedObjectModel!) - container.loadPersistentStores(completionHandler: { (_, error) in - if let error = error as NSError? { - fatalError("Unresolved error \(error), \(error.userInfo)") - } - }) - let description = NSPersistentStoreDescription() - description.shouldInferMappingModelAutomatically = true - description.shouldMigrateStoreAutomatically = true - container.persistentStoreDescriptions = [description] - - return container - } - - private func createContext() -> NSManagedObjectContext { - let context = persistentContainer.newBackgroundContext() - context.automaticallyMergesChangesFromParent = true - return context - } } diff --git a/Course/Course/Data/Persistence/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift similarity index 76% rename from Course/Course/Data/Persistence/CoursePersistence.swift rename to OpenEdX/Data/CoursePersistence.swift index 1a9731e12..cd61e5e6e 100644 --- a/Course/Course/Data/Persistence/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -1,38 +1,22 @@ // // CoursePersistence.swift -// Course +// OpenEdX // -// Created by  Stepanok Ivan on 15.12.2022. +// Created by  Stepanok Ivan on 25.07.2023. // +import Foundation import CoreData +import Course import Core -public protocol CoursePersistenceProtocol { - func loadCourseDetails(courseID: String) throws -> CourseDetails - func saveCourseDetails(course: CourseDetails) - func loadEnrollments() throws -> [Core.CourseItem] - func saveEnrollments(items: [Core.CourseItem]) - func loadCourseStructure(courseID: String) throws -> DataLayer.CourseStructure - func saveCourseStructure(structure: DataLayer.CourseStructure) - func saveSubtitles(url: String, subtitlesString: String) - func loadSubtitles(url: String) -> String? - func clear() -} - public class CoursePersistence: CoursePersistenceProtocol { - public init() {} - - private let model = "CourseCoreModel" + private var context: NSManagedObjectContext - private lazy var persistentContainer: NSPersistentContainer = { - return createContainer() - }() - - private lazy var context: NSManagedObjectContext = { - return createContext() - }() + public init(context: NSManagedObjectContext) { + self.context = context + } public func loadCourseDetails(courseID: String) throws -> CourseDetails { let request = CDCourseDetails.fetchRequest() @@ -54,7 +38,7 @@ public class CoursePersistence: CoursePersistenceProtocol { public func saveCourseDetails(course: CourseDetails) { context.performAndWait { - let newCourseDetails = CDCourseDetails(context: context) + let newCourseDetails = CDCourseDetails(context: self.context) newCourseDetails.courseID = course.courseID newCourseDetails.org = course.org newCourseDetails.courseTitle = course.courseTitle @@ -173,7 +157,7 @@ public class CoursePersistence: CoursePersistenceProtocol { public func saveCourseStructure(structure: DataLayer.CourseStructure) { context.performAndWait { - let newStructure = CDCourseStructure(context: context) + let newStructure = CDCourseStructure(context: self.context) newStructure.certificate = structure.certificate?.url newStructure.mediaSmall = structure.media.image.small newStructure.mediaLarge = structure.media.image.large @@ -182,7 +166,7 @@ public class CoursePersistence: CoursePersistenceProtocol { newStructure.rootItem = structure.rootItem for block in Array(structure.dict.values) { - let courseDetail = CDCourseBlock(context: context) + let courseDetail = CDCourseBlock(context: self.context) courseDetail.allSources = block.allSources courseDetail.descendants = block.descendants courseDetail.graded = block.graded @@ -231,48 +215,4 @@ public class CoursePersistence: CoursePersistenceProtocol { } return nil } - - public func clear() { - let storeContainer = persistentContainer.persistentStoreCoordinator - for store in storeContainer.persistentStores { - do { - try storeContainer.destroyPersistentStore( - at: store.url!, - ofType: store.type, - options: nil - ) - } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) - } - } - - // Re-create the persistent container - persistentContainer = createContainer() - context = createContext() - } - - private func createContainer() -> NSPersistentContainer { - let bundle = Bundle(for: Self.self) - let url = bundle.url(forResource: model, withExtension: "momd") - let managedObjectModel = NSManagedObjectModel(contentsOf: url!) - let container = NSPersistentContainer(name: model, managedObjectModel: managedObjectModel!) - container.loadPersistentStores(completionHandler: { (_, error) in - if let error = error as NSError? { - fatalError("Unresolved error \(error), \(error.userInfo)") - } - }) - let description = NSPersistentStoreDescription() - description.shouldInferMappingModelAutomatically = true - description.shouldMigrateStoreAutomatically = true - container.persistentStoreDescriptions = [description] - - return container - } - - private func createContext() -> NSManagedObjectContext { - let context = persistentContainer.newBackgroundContext() - context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - context.automaticallyMergesChangesFromParent = true - return context - } } diff --git a/OpenEdX/Data/DashboardPersistence.swift b/OpenEdX/Data/DashboardPersistence.swift new file mode 100644 index 000000000..06241c00f --- /dev/null +++ b/OpenEdX/Data/DashboardPersistence.swift @@ -0,0 +1,66 @@ +// +// DashboardPersistence.swift +// OpenEdX +// +// Created by  Stepanok Ivan on 25.07.2023. +// + +import Dashboard +import Core +import Foundation +import CoreData + +public class DashboardPersistence: DashboardPersistenceProtocol { + + private var context: NSManagedObjectContext + + public init(context: NSManagedObjectContext) { + self.context = context + } + + public func loadMyCourses() throws -> [CourseItem] { + let result = try? context.fetch(CDDashboardCourse.fetchRequest()) + .map { CourseItem(name: $0.name ?? "", + org: $0.org ?? "", + shortDescription: $0.desc ?? "", + imageURL: $0.imageURL ?? "", + isActive: nil, + courseStart: $0.courseStart, + courseEnd: $0.courseEnd, + enrollmentStart: $0.enrollmentStart, + enrollmentEnd: $0.enrollmentEnd, + courseID: $0.courseID ?? "", + numPages: Int($0.numPages), + coursesCount: Int($0.courseCount))} + if let result, !result.isEmpty { + return result + } else { + throw NoCachedDataError() + } + } + + public func saveMyCourses(items: [CourseItem]) { + for item in items { + context.performAndWait { + let newItem = CDDashboardCourse(context: context) + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + newItem.name = item.name + newItem.org = item.org + newItem.desc = item.shortDescription + newItem.imageURL = item.imageURL + newItem.courseStart = item.courseStart + newItem.courseEnd = item.courseEnd + newItem.enrollmentStart = item.enrollmentStart + newItem.enrollmentEnd = item.enrollmentEnd + newItem.numPages = Int32(item.numPages) + newItem.courseID = item.courseID + + do { + try context.save() + } catch { + print("⛔️⛔️⛔️⛔️⛔️", error) + } + } + } + } +} diff --git a/OpenEdX/Data/DatabaseManager.swift b/OpenEdX/Data/DatabaseManager.swift new file mode 100644 index 000000000..d6280c43c --- /dev/null +++ b/OpenEdX/Data/DatabaseManager.swift @@ -0,0 +1,103 @@ +// +// Persistence.swift +// OpenEdX +// +// Created by  Stepanok Ivan on 25.07.2023. +// + +import Foundation +import CoreData +import Core +import Discovery +import Dashboard +import Course + +class DatabaseManager: CoreDataHandlerProtocol { + + private let databaseName: String + + private let bundles: [Bundle] = [ + Bundle(for: CoreBundle.self), + Bundle(for: DiscoveryBundle.self), + Bundle(for: DashboardBundle.self), + Bundle(for: CourseBundle.self) + ] + + private lazy var persistentContainer: NSPersistentContainer = { + return createContainer() + }() + + public lazy var context: NSManagedObjectContext = { + return createContext() + }() + + init(databaseName: String) { + self.databaseName = databaseName + } + + public func saveCourseDetails() { + context.performAndWait { + let newCourseDetails = CDCourseDetails(context: context) + newCourseDetails.courseID = UUID().uuidString + newCourseDetails.org = "course.org" + newCourseDetails.courseTitle = "course.courseTitle" + newCourseDetails.courseDescription = "course.courseDescription" + newCourseDetails.courseStart = Date() + newCourseDetails.courseEnd = Date() + newCourseDetails.enrollmentStart = Date() + newCourseDetails.enrollmentEnd = Date() + newCourseDetails.isEnrolled = false + newCourseDetails.overviewHTML = "course.overviewHTML" + newCourseDetails.courseBannerURL = "course.courseBannerURL" + + do { + try context.save() + } catch { + print("⛔️⛔️⛔️⛔️⛔️", error) + } + } + } + + private func createContainer() -> NSPersistentContainer { + let model = NSManagedObjectModel.mergedModel(from: bundles)! + let container = NSPersistentContainer(name: databaseName, managedObjectModel: model) + container.loadPersistentStores { _, error in + if let error = error { + print("Unresolved error \(error)") + fatalError() + } + } + + let description = NSPersistentStoreDescription() + description.shouldInferMappingModelAutomatically = true + description.shouldMigrateStoreAutomatically = true + container.persistentStoreDescriptions = [description] + + return container + } + + private func createContext() -> NSManagedObjectContext { + let context = persistentContainer.newBackgroundContext() + context.automaticallyMergesChangesFromParent = true + return context + } + + public func clear() { + let storeContainer = persistentContainer.persistentStoreCoordinator + for store in storeContainer.persistentStores { + do { + try storeContainer.destroyPersistentStore( + at: store.url!, + ofType: store.type, + options: nil + ) + } catch { + print("⛔️⛔️⛔️⛔️⛔️", error) + } + } + + // Re-create the persistent container + persistentContainer = createContainer() + context = createContext() + } +} diff --git a/OpenEdX/Data/DiscoveryPersistence.swift b/OpenEdX/Data/DiscoveryPersistence.swift new file mode 100644 index 000000000..7547d0c37 --- /dev/null +++ b/OpenEdX/Data/DiscoveryPersistence.swift @@ -0,0 +1,69 @@ +// +// DiscoveryPersistence.swift +// OpenEdX +// +// Created by  Stepanok Ivan on 25.07.2023. +// + +import Foundation +import Discovery +import CoreData +import Core + +public class DiscoveryPersistence: DiscoveryPersistenceProtocol { + + private var context: NSManagedObjectContext + + public init(context: NSManagedObjectContext) { + self.context = context + } + + public func loadDiscovery() throws -> [CourseItem] { + let result = try? context.fetch(CDDiscoveryCourse.fetchRequest()) + .map { CourseItem(name: $0.name ?? "", + org: $0.org ?? "", + shortDescription: $0.desc ?? "", + imageURL: $0.imageURL ?? "", + isActive: $0.isActive, + courseStart: $0.courseStart, + courseEnd: $0.courseEnd, + enrollmentStart: $0.enrollmentStart, + enrollmentEnd: $0.enrollmentEnd, + courseID: $0.courseID ?? "", + numPages: Int($0.numPages), + coursesCount: Int($0.courseCount))} + if let result, !result.isEmpty { + return result + } else { + throw NoCachedDataError() + } + } + + public func saveDiscovery(items: [CourseItem]) { + for item in items { + context.performAndWait { + let newItem = CDDiscoveryCourse(context: context) + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + newItem.name = item.name + newItem.org = item.org + newItem.desc = item.shortDescription + newItem.imageURL = item.imageURL + if let isActive = item.isActive { + newItem.isActive = isActive + } + newItem.courseStart = item.courseStart + newItem.courseEnd = item.courseEnd + newItem.enrollmentStart = item.enrollmentStart + newItem.enrollmentEnd = item.enrollmentEnd + newItem.numPages = Int32(item.numPages) + newItem.courseID = item.courseID + + do { + try context.save() + } catch { + print("⛔️⛔️⛔️⛔️⛔️", error) + } + } + } + } +} diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 0d3d52798..1295ff44e 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -27,13 +27,21 @@ public class ProfileRepository: ProfileRepositoryProtocol { private let api: API private let appStorage: AppStorage + private let downloadManager: DownloadManagerProtocol private let coreDataHandler: CoreDataHandlerProtocol private let config: Config - public init(api: API, appStorage: AppStorage, coreDataHandler: CoreDataHandlerProtocol, config: Config) { + public init( + api: API, + appStorage: AppStorage, + coreDataHandler: CoreDataHandlerProtocol, + downloadManager: DownloadManagerProtocol, + config: Config + ) { self.api = api self.appStorage = appStorage self.coreDataHandler = coreDataHandler + self.downloadManager = downloadManager self.config = config } @@ -60,6 +68,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { ProfileEndpoint.logOut(refreshToken: refreshToken, clientID: config.oAuthClientId) ) appStorage.clear() + downloadManager.deleteAllFiles() coreDataHandler.clear() } diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 5c111a812..8ebac614f 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1125,6 +1125,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { perform?(`blocks`) } + open func deleteAllFiles() { + addInvocation(.m_deleteAllFiles) + let perform = methodPerformValue(.m_deleteAllFiles) as? () -> Void + perform?() + } + open func fileUrl(for blockId: String) -> URL? { addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void @@ -1147,6 +1153,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_resumeDownloading case m_pauseDownloading case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) + case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -1178,6 +1185,8 @@ 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_deleteAllFiles, .m_deleteAllFiles): return .match + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) @@ -1195,6 +1204,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_resumeDownloading: return 0 case .m_pauseDownloading: return 0 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 } } @@ -1207,6 +1217,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_resumeDownloading: return ".resumeDownloading()" case .m_pauseDownloading: return ".pauseDownloading()" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" + case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" } } @@ -1293,6 +1304,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func pauseDownloading() -> Verify { return Verify(method: .m_pauseDownloading)} 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`))} } @@ -1321,6 +1333,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) } + public static func deleteAllFiles(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllFiles, performs: perform) + } public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } From 5e0f3f7ca081ed39fa868907fe99b00229c9f976 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 31 Jul 2023 12:33:25 +0300 Subject: [PATCH 05/19] Migrate all colors to Theme (#61) * Added support for multiple color schemes to quickly switch between educational platforms. Updated variables throughout the project accordingly. --- .../Presentation/Login/SignInView.swift | 26 +++---- .../Registration/SignUpView.swift | 10 +-- .../Reset Password/ResetPasswordView.swift | 20 ++--- Core/Core/Extensions/ViewExtension.swift | 20 ++--- Core/Core/Theme.swift | 73 +++++++++++++++++++ Core/Core/View/Base/AlertView.swift | 14 ++-- Core/Core/View/Base/CourseButton.swift | 4 +- Core/Core/View/Base/CourseCellView.swift | 14 ++-- Core/Core/View/Base/DownloadView.swift | 6 +- .../View/Base/FlexibleKeyboardInputView.swift | 12 +-- Core/Core/View/Base/NavigationBar.swift | 10 +-- Core/Core/View/Base/OfflineSnackBarView.swift | 2 +- Core/Core/View/Base/PickerMenu.swift | 12 +-- Core/Core/View/Base/PickerView.swift | 10 +-- Core/Core/View/Base/ProgressBar.swift | 6 +- .../View/Base/RegistrationTextField.swift | 16 ++-- Core/Core/View/Base/SnackBarView.swift | 2 +- Core/Core/View/Base/StyledButton.swift | 8 +- Core/Core/View/Base/UnitButtonView.swift | 36 ++++----- Core/Core/View/Base/WebUnitView.swift | 6 +- .../Details/CourseDetailsView.swift | 4 +- .../Handouts/HandoutsUpdatesDetailView.swift | 2 +- .../Presentation/Handouts/HandoutsView.swift | 10 +-- .../Outline/ContinueWithView.swift | 6 +- .../Outline/CourseOutlineView.swift | 12 +-- .../Outline/CourseVerticalView.swift | 6 +- .../Presentation/Unit/CourseUnitView.swift | 4 +- .../Unit/Subviews/LessonProgressView.swift | 2 +- .../Unit/Subviews/YouTubeView.swift | 2 +- .../Video/EncodedVideoPlayer.swift | 2 +- .../Presentation/Video/SubtittlesView.swift | 6 +- .../Video/YouTubeVideoPlayer.swift | 2 +- .../Presentation/DashboardView.swift | 10 +-- .../Presentation/DiscoveryView.swift | 12 +-- .../Discovery/Presentation/SearchView.swift | 22 +++--- .../Presentation/CheckBoxView.swift | 4 +- .../Comments/Base/CommentCell.swift | 20 ++--- .../Comments/Base/ParentCommentView.swift | 18 ++--- .../Comments/Responses/ResponsesView.swift | 2 +- .../Comments/Thread/ThreadView.swift | 4 +- .../CreateNewThread/CreateNewThreadView.swift | 20 ++--- .../DiscussionSearchTopicsView.swift | 22 +++--- .../DiscussionTopicsView.swift | 20 ++--- .../Presentation/Posts/PostsView.swift | 22 +++--- OpenEdX/View/MainScreenView.swift | 6 +- .../DeleteAccount/DeleteAccountView.swift | 20 ++--- .../EditProfile/EditProfileView.swift | 14 ++-- .../EditProfile/ProfileBottomSheet.swift | 16 ++-- .../Presentation/Profile/ProfileView.swift | 24 +++--- .../Presentation/Settings/SettingsView.swift | 8 +- .../Settings/VideoQualityView.swift | 4 +- 51 files changed, 353 insertions(+), 280 deletions(-) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 98fc3305a..d7434f57c 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -39,16 +39,16 @@ public struct SignInView: View { VStack(alignment: .leading) { Text(AuthLocalization.SignIn.logInTitle) .font(Theme.Fonts.displaySmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 4) Text(AuthLocalization.SignIn.welcomeBack) .font(Theme.Fonts.titleSmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 20) Text(AuthLocalization.SignIn.email) .font(Theme.Fonts.labelLarge) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) TextField(AuthLocalization.SignIn.email, text: $email) .keyboardType(.emailAddress) .textContentType(.emailAddress) @@ -57,42 +57,42 @@ public struct SignInView: View { .padding(.all, 14) .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) - .fill(CoreAssets.textInputStroke.swiftUIColor) + .fill(Theme.Colors.textInputStroke) ) Text(AuthLocalization.SignIn.password) .font(Theme.Fonts.labelLarge) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.top, 18) SecureField(AuthLocalization.SignIn.password, text: $password) .padding(.all, 14) .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) - .fill(CoreAssets.textInputStroke.swiftUIColor) + .fill(Theme.Colors.textInputStroke) ) HStack { Button(AuthLocalization.SignIn.registerBtn) { viewModel.analytics.signUpClicked() viewModel.router.showRegisterScreen() - }.foregroundColor(CoreAssets.accentColor.swiftUIColor) + }.foregroundColor(Theme.Colors.accentColor) Spacer() Button(AuthLocalization.SignIn.forgotPassBtn) { viewModel.analytics.forgotPasswordClicked() viewModel.router.showForgotPasswordScreen() - }.foregroundColor(CoreAssets.accentColor.swiftUIColor) + }.foregroundColor(Theme.Colors.accentColor) } .padding(.top, 10) if viewModel.isShowProgress { @@ -113,7 +113,7 @@ public struct SignInView: View { } .padding(.horizontal, 24) .padding(.top, 50) - }.roundedBackground(CoreAssets.background.swiftUIColor) + }.roundedBackground(Theme.Colors.background) .scrollAvoidKeyboard(dismissKeyboardByTap: true) } @@ -122,7 +122,7 @@ public struct SignInView: View { if viewModel.showAlert { VStack { Text(viewModel.alertMessage ?? "") - .shadowCardStyle(bgColor: CoreAssets.accentColor.swiftUIColor, + .shadowCardStyle(bgColor: Theme.Colors.accentColor, textColor: .white) .padding(.top, 80) Spacer() @@ -149,7 +149,7 @@ public struct SignInView: View { } } } - .background(CoreAssets.background.swiftUIColor.ignoresSafeArea(.all)) + .background(Theme.Colors.background.ignoresSafeArea(.all)) } } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index e1c852df4..65df060e8 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -43,7 +43,7 @@ public struct SignUpView: View { CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) .backButtonStyle(color: .white) }) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) }.frame(minWidth: 0, maxWidth: .infinity, @@ -58,11 +58,11 @@ public struct SignUpView: View { Text(AuthLocalization.SignUp.title) .font(Theme.Fonts.displaySmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 4) Text(AuthLocalization.SignUp.subtitle) .font(Theme.Fonts.titleSmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 20) let requiredFields = viewModel.fields.filter {$0.field.required} @@ -109,7 +109,7 @@ public struct SignUpView: View { .padding(.horizontal, 24) .padding(.top, 24) - }.roundedBackground(CoreAssets.background.swiftUIColor) + }.roundedBackground(Theme.Colors.background) .onRightSwipeGesture { viewModel.router.back() } @@ -136,7 +136,7 @@ public struct SignUpView: View { } } } - .background(CoreAssets.background.swiftUIColor.ignoresSafeArea(.all)) + .background(Theme.Colors.background.ignoresSafeArea(.all)) } } diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index 17d81921d..b65238f69 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -51,12 +51,12 @@ public struct ResetPasswordView: View { Text(AuthLocalization.Forgot.checkTitle) .font(Theme.Fonts.titleLarge) .multilineTextAlignment(.center) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 4) Text(AuthLocalization.Forgot.checkDescription + email) .font(Theme.Fonts.bodyMedium) .multilineTextAlignment(.center) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 20) StyledButton(AuthLocalization.SignIn.logInBtn) { viewModel.router.backToRoot(animated: true) @@ -70,15 +70,15 @@ public struct ResetPasswordView: View { VStack(alignment: .leading) { Text(AuthLocalization.Forgot.title) .font(Theme.Fonts.displaySmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 4) Text(AuthLocalization.Forgot.description) .font(Theme.Fonts.titleSmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 20) Text(AuthLocalization.SignIn.email) .font(Theme.Fonts.labelLarge) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) TextField(AuthLocalization.SignIn.email, text: $email) .keyboardType(.emailAddress) .textContentType(.emailAddress) @@ -87,12 +87,12 @@ public struct ResetPasswordView: View { .padding(.all, 14) .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) - .fill(CoreAssets.textInputStroke.swiftUIColor) + .fill(Theme.Colors.textInputStroke) ) if viewModel.isShowProgress { HStack(alignment: .center) { @@ -113,7 +113,7 @@ public struct ResetPasswordView: View { } .padding(.horizontal, 24) .padding(.top, 50) - }.roundedBackground(CoreAssets.background.swiftUIColor) + }.roundedBackground(Theme.Colors.background) .scrollAvoidKeyboard(dismissKeyboardByTap: true) } @@ -122,7 +122,7 @@ public struct ResetPasswordView: View { if viewModel.showAlert { VStack { Text(viewModel.alertMessage ?? "") - .shadowCardStyle(bgColor: CoreAssets.accentColor.swiftUIColor, + .shadowCardStyle(bgColor: Theme.Colors.accentColor, textColor: .white) .padding(.top, 80) Spacer() @@ -149,7 +149,7 @@ public struct ResetPasswordView: View { } } } - .background(CoreAssets.background.swiftUIColor.ignoresSafeArea(.all)) + .background(Theme.Colors.background.ignoresSafeArea(.all)) } } diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 9dc0a8818..0d37801b3 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -15,9 +15,9 @@ public extension View { top: CGFloat? = 0, bottom: CGFloat? = 0, leftLineEnabled: Bool = false, - bgColor: Color = CoreAssets.background.swiftUIColor, - strokeColor: Color = CoreAssets.cardViewStroke.swiftUIColor, - textColor: Color = CoreAssets.textPrimary.swiftUIColor + bgColor: Color = Theme.Colors.background, + strokeColor: Color = Theme.Colors.cardViewStroke, + textColor: Color = Theme.Colors.textPrimary ) -> some View { return self .padding(.all, 20) @@ -53,8 +53,8 @@ public extension View { func shadowCardStyle( top: CGFloat? = 0, bottom: CGFloat? = 0, - bgColor: Color = CoreAssets.cardViewBackground.swiftUIColor, - textColor: Color = CoreAssets.textPrimary.swiftUIColor + bgColor: Color = Theme.Colors.cardViewBackground, + textColor: Color = Theme.Colors.textPrimary ) -> some View { return self .padding(.all, 16) @@ -65,7 +65,7 @@ public extension View { alignment: .topLeading) .background(Theme.Shapes.cardShape .fill(bgColor) - .shadow(color: CoreAssets.shadowColor.swiftUIColor, + .shadow(color: Theme.Colors.shadowColor, radius: 12, y: 4)) .foregroundColor(textColor) .padding(.horizontal, 24) @@ -77,7 +77,7 @@ public extension View { func titleSettings( top: CGFloat? = 10, bottom: CGFloat? = 20, - color: Color = CoreAssets.textPrimary.swiftUIColor + color: Color = Theme.Colors.textPrimary ) -> some View { return self .lineLimit(1) @@ -98,8 +98,8 @@ public extension View { } func roundedBackground( - _ color: Color = CoreAssets.background.swiftUIColor, - strokeColor: Color = CoreAssets.backgroundStroke.swiftUIColor, + _ color: Color = Theme.Colors.background, + strokeColor: Color = Theme.Colors.backgroundStroke, ipadMaxHeight: CGFloat = .infinity, maxIpadWidth: CGFloat = 420 ) -> some View { @@ -196,7 +196,7 @@ private struct FirstAppear: ViewModifier { } public extension Image { - func backButtonStyle(topPadding: CGFloat = -10, color: Color = CoreAssets.accentColor.swiftUIColor) -> some View { + func backButtonStyle(topPadding: CGFloat = -10, color: Color = Theme.Colors.accentColor) -> some View { return self .renderingMode(.template) .resizable() diff --git a/Core/Core/Theme.swift b/Core/Core/Theme.swift index 56db63501..b63ad99f9 100644 --- a/Core/Core/Theme.swift +++ b/Core/Core/Theme.swift @@ -10,6 +10,79 @@ import SwiftUI public struct Theme { + public struct Colors { + public private(set) static var accentColor = CoreAssets.accentColor.swiftUIColor + public private(set) static var alert = CoreAssets.alert.swiftUIColor + public private(set) static var avatarStroke = CoreAssets.avatarStroke.swiftUIColor + public private(set) static var background = CoreAssets.background.swiftUIColor + public private(set) static var backgroundStroke = CoreAssets.backgroundStroke.swiftUIColor + public private(set) static var cardViewBackground = CoreAssets.cardViewBackground.swiftUIColor + public private(set) static var cardViewStroke = CoreAssets.cardViewStroke.swiftUIColor + public private(set) static var certificateForeground = CoreAssets.certificateForeground.swiftUIColor + public private(set) static var commentCellBackground = CoreAssets.commentCellBackground.swiftUIColor + public private(set) static var shadowColor = CoreAssets.shadowColor.swiftUIColor + public private(set) static var snackbarErrorColor = CoreAssets.snackbarErrorColor.swiftUIColor + public private(set) static var snackbarErrorTextColor = CoreAssets.snackbarErrorTextColor.swiftUIColor + public private(set) static var snackbarInfoAlert = CoreAssets.snackbarInfoAlert.swiftUIColor + public private(set) static var styledButtonBackground = CoreAssets.styledButtonBackground.swiftUIColor + public private(set) static var styledButtonText = CoreAssets.styledButtonText.swiftUIColor + public private(set) static var textPrimary = CoreAssets.textPrimary.swiftUIColor + public private(set) static var textSecondary = CoreAssets.textSecondary.swiftUIColor + public private(set) static var textInputBackground = CoreAssets.textInputBackground.swiftUIColor + public private(set) static var textInputStroke = CoreAssets.textInputStroke.swiftUIColor + public private(set) static var textInputUnfocusedBackground = CoreAssets.textInputUnfocusedBackground.swiftUIColor + public private(set) static var textInputUnfocusedStroke = CoreAssets.textInputUnfocusedStroke.swiftUIColor + public private(set) static var warning = CoreAssets.warning.swiftUIColor + + public static func update( + accentColor: Color = CoreAssets.accentColor.swiftUIColor, + alert: Color = CoreAssets.alert.swiftUIColor, + avatarStroke: Color = CoreAssets.avatarStroke.swiftUIColor, + background: Color = CoreAssets.background.swiftUIColor, + backgroundStroke: Color = CoreAssets.backgroundStroke.swiftUIColor, + cardViewBackground: Color = CoreAssets.cardViewBackground.swiftUIColor, + cardViewStroke: Color = CoreAssets.cardViewStroke.swiftUIColor, + certificateForeground: Color = CoreAssets.certificateForeground.swiftUIColor, + commentCellBackground: Color = CoreAssets.commentCellBackground.swiftUIColor, + shadowColor: Color = CoreAssets.shadowColor.swiftUIColor, + snackbarErrorColor: Color = CoreAssets.snackbarErrorColor.swiftUIColor, + snackbarErrorTextColor: Color = CoreAssets.snackbarErrorTextColor.swiftUIColor, + snackbarInfoAlert: Color = CoreAssets.snackbarInfoAlert.swiftUIColor, + styledButtonBackground: Color = CoreAssets.styledButtonBackground.swiftUIColor, + styledButtonText: Color = CoreAssets.styledButtonText.swiftUIColor, + textPrimary: Color = CoreAssets.textPrimary.swiftUIColor, + textSecondary: Color = CoreAssets.textSecondary.swiftUIColor, + textInputBackground: Color = CoreAssets.textInputBackground.swiftUIColor, + textInputStroke: Color = CoreAssets.textInputStroke.swiftUIColor, + textInputUnfocusedBackground: Color = CoreAssets.textInputUnfocusedBackground.swiftUIColor, + textInputUnfocusedStroke: Color = CoreAssets.textInputUnfocusedStroke.swiftUIColor, + warning: Color = CoreAssets.warning.swiftUIColor + ) { + self.accentColor = accentColor + self.alert = alert + self.avatarStroke = avatarStroke + self.background = background + self.backgroundStroke = backgroundStroke + self.cardViewBackground = cardViewBackground + self.cardViewStroke = cardViewStroke + self.certificateForeground = certificateForeground + self.commentCellBackground = commentCellBackground + self.shadowColor = shadowColor + self.snackbarErrorColor = snackbarErrorColor + self.snackbarErrorTextColor = snackbarErrorTextColor + self.snackbarInfoAlert = snackbarInfoAlert + self.styledButtonBackground = styledButtonBackground + self.styledButtonText = styledButtonText + self.textPrimary = textPrimary + self.textSecondary = textSecondary + self.textInputBackground = textInputBackground + self.textInputStroke = textInputStroke + self.textInputUnfocusedBackground = textInputUnfocusedBackground + self.textInputUnfocusedStroke = textInputUnfocusedStroke + self.warning = warning + } + } + public struct Fonts { public static let displayLarge: Font = .custom("SFPro-Regular", size: 57) diff --git a/Core/Core/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index e4dd061a2..f230eba9b 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -135,7 +135,7 @@ public struct AlertView: View { .padding(.horizontal, 40) .multilineTextAlignment(.center) .font(Theme.Fonts.labelSmall) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) } } @@ -157,7 +157,7 @@ public struct AlertView: View { }) .background( Theme.Shapes.buttonShape - .fill(CoreAssets.warning.swiftUIColor) + .fill(Theme.Colors.warning) ) .overlay( RoundedRectangle(cornerRadius: 8) @@ -186,7 +186,7 @@ public struct AlertView: View { }) .background( Theme.Shapes.buttonShape - .fill(CoreAssets.warning.swiftUIColor) + .fill(Theme.Colors.warning) ) .overlay( RoundedRectangle(cornerRadius: 8) @@ -205,7 +205,7 @@ public struct AlertView: View { }, label: { ZStack { Text(CoreLocalization.Alert.keepEditing) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .font(Theme.Fonts.labelLarge) .frame(maxWidth: .infinity) .padding(.horizontal, 16) @@ -224,7 +224,7 @@ public struct AlertView: View { lineJoin: .round, miterLimit: 1 )) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) ) .frame(maxWidth: 215) } @@ -235,7 +235,7 @@ public struct AlertView: View { } .background( Theme.Shapes.cardShape - .fill(CoreAssets.cardViewBackground.swiftUIColor) + .fill(Theme.Colors.cardViewBackground) .shadow(radius: 24) .frame(width: reader.size.width < 420 ? reader.size.width - 80 @@ -244,7 +244,7 @@ public struct AlertView: View { .overlay( RoundedRectangle(cornerRadius: 12) .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) - .foregroundColor(CoreAssets.backgroundStroke.swiftUIColor) + .foregroundColor(Theme.Colors.backgroundStroke) .frame(width: reader.size.width < 420 ? reader.size.width - 80 : 360) diff --git a/Core/Core/View/Base/CourseButton.swift b/Core/Core/View/Base/CourseButton.swift index a7473e36e..4d9a468d9 100644 --- a/Core/Core/View/Base/CourseButton.swift +++ b/Core/Core/View/Base/CourseButton.swift @@ -31,12 +31,12 @@ public struct CourseButton: View { .foregroundColor(.accentColor) } else { image - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) } Text(displayName) .font(Theme.Fonts.titleMedium) .multilineTextAlignment(.leading) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) Spacer() Image(systemName: "chevron.right") .padding(.vertical, 8) diff --git a/Core/Core/View/Base/CourseCellView.swift b/Core/Core/View/Base/CourseCellView.swift index cc5e83022..ae21b2e7f 100644 --- a/Core/Core/View/Base/CourseCellView.swift +++ b/Core/Core/View/Base/CourseCellView.swift @@ -52,12 +52,12 @@ public struct CourseCellView: View { VStack(alignment: .leading) { Text(courseOrg) .font(Theme.Fonts.labelMedium) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) .multilineTextAlignment(.leading) Text(courseName) .font(Theme.Fonts.titleSmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .lineLimit(type == .discovery ? 3 : 2) .multilineTextAlignment(.leading) .padding(.top, 1) @@ -67,18 +67,18 @@ public struct CourseCellView: View { if courseEnd != "" { Text(courseEnd) .font(Theme.Fonts.labelMedium) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) } else { Text(courseStart) .font(Theme.Fonts.labelMedium) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) } Spacer() CoreAssets.arrowRight16.swiftUIImage.renderingMode(.template) .resizable() .frame(width: 16, height: 16) .offset(x: 15) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) } } }.padding(.horizontal, 10) @@ -87,7 +87,7 @@ public struct CourseCellView: View { } }.frame(height: 105) - .background(CoreAssets.background.swiftUIColor) + .background(Theme.Colors.background) .opacity(showView ? 1 : 0) .offset(y: showView ? 0 : 20) .onAppear { @@ -102,7 +102,7 @@ public struct CourseCellView: View { if Int(index) != cellsCount { Divider() .frame(height: 1) - .overlay(CoreAssets.cardViewStroke.swiftUIColor) + .overlay(Theme.Colors.cardViewStroke) .padding(.vertical, 18) .padding(.horizontal, 3) } diff --git a/Core/Core/View/Base/DownloadView.swift b/Core/Core/View/Base/DownloadView.swift index aa9b1187f..9956cc947 100644 --- a/Core/Core/View/Base/DownloadView.swift +++ b/Core/Core/View/Base/DownloadView.swift @@ -22,7 +22,7 @@ public struct DownloadAvailableView: View { .resizable() .scaledToFit() .frame(width: 24, height: 24) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) } } @@ -37,7 +37,7 @@ public struct DownloadProgressView: View { .resizable() .scaledToFit() .frame(width: 20, height: 20) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(6) } } @@ -52,6 +52,6 @@ public struct DownloadFinishedView: View { .resizable() .scaledToFit() .frame(width: 24, height: 24) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) } } diff --git a/Core/Core/View/Base/FlexibleKeyboardInputView.swift b/Core/Core/View/Base/FlexibleKeyboardInputView.swift index 83360f42c..97bc11401 100644 --- a/Core/Core/View/Base/FlexibleKeyboardInputView.swift +++ b/Core/Core/View/Base/FlexibleKeyboardInputView.swift @@ -46,15 +46,15 @@ public struct FlexibleKeyboardInputView: View { .overlay( TextEditor(text: $commentText) .padding(.horizontal, 8) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .hideScrollContentBackground() .frame(maxHeight: commentSize) .background( ZStack(alignment: .leading) { Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) Text(commentText.count == 0 ? hint : "") - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) .font(Theme.Fonts.labelLarge) .padding(.leading, 14) } @@ -63,7 +63,7 @@ public struct FlexibleKeyboardInputView: View { Theme.Shapes.textInputShape .stroke(lineWidth: 1) .fill( - CoreAssets.textInputStroke.swiftUIColor + Theme.Colors.textInputStroke ) ) ).padding(8) @@ -87,14 +87,14 @@ public struct FlexibleKeyboardInputView: View { .padding(.trailing, 14) }.frame(maxWidth: .infinity, maxHeight: commentSize + 16) .background( - CoreAssets.commentCellBackground.swiftUIColor + Theme.Colors.commentCellBackground .ignoresSafeArea() ) .overlay( GeometryReader { proxy in Rectangle() .size(width: proxy.size.width, height: 1) - .foregroundColor(CoreAssets.cardViewStroke.swiftUIColor) + .foregroundColor(Theme.Colors.cardViewStroke) } ) } diff --git a/Core/Core/View/Base/NavigationBar.swift b/Core/Core/View/Base/NavigationBar.swift index 2b589d030..c616a7dea 100644 --- a/Core/Core/View/Base/NavigationBar.swift +++ b/Core/Core/View/Base/NavigationBar.swift @@ -24,8 +24,8 @@ public struct NavigationBar: View { @Binding private var rightButtonIsActive: Bool public init(title: String, - titleColor: Color = CoreAssets.textPrimary.swiftUIColor, - leftButtonColor: Color = CoreAssets.accentColor.swiftUIColor, + titleColor: Color = Theme.Colors.textPrimary, + leftButtonColor: Color = Theme.Colors.accentColor, leftButtonAction: (() -> Void)? = nil, rightButtonType: ButtonType? = nil, rightButtonAction: (() -> Void)? = nil, @@ -56,7 +56,7 @@ public struct NavigationBar: View { CoreAssets.arrowLeft.swiftUIImage .backButtonStyle(color: leftButtonColor) }) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) }.frame(minWidth: 0, maxWidth: .infinity, @@ -76,7 +76,7 @@ public struct NavigationBar: View { .backButtonStyle(topPadding: 0) Text(CoreLocalization.done) .font(Theme.Fonts.labelLarge) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) }.offset(y: -6) case .edit: CoreAssets.edit.swiftUIImage @@ -91,7 +91,7 @@ public struct NavigationBar: View { }) .opacity(rightButtonIsActive ? 1 : 0.3) .padding(.trailing, 16) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) }.frame(minWidth: 0, maxWidth: .infinity, alignment: .topTrailing) diff --git a/Core/Core/View/Base/OfflineSnackBarView.swift b/Core/Core/View/Base/OfflineSnackBarView.swift index 153a96bcb..bc7a01279 100644 --- a/Core/Core/View/Base/OfflineSnackBarView.swift +++ b/Core/Core/View/Base/OfflineSnackBarView.swift @@ -46,7 +46,7 @@ public struct OfflineSnackBarView: View { }.padding(.horizontal, 16) .font(Theme.Fonts.titleSmall) .frame(maxWidth: .infinity, maxHeight: OfflineSnackBarView.height) - .background(CoreAssets.warning.swiftUIColor.ignoresSafeArea()) + .background(Theme.Colors.warning.ignoresSafeArea()) } } .onAppear { diff --git a/Core/Core/View/Base/PickerMenu.swift b/Core/Core/View/Base/PickerMenu.swift index 14da74cdd..a967ffdde 100644 --- a/Core/Core/View/Base/PickerMenu.swift +++ b/Core/Core/View/Base/PickerMenu.swift @@ -78,21 +78,21 @@ public struct PickerMenu: View { Spacer() VStack { Text(titleText) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) TextField(CoreLocalization.Picker.search, text: $search) .padding(.all, 8) - .background(CoreAssets.textInputStroke.swiftUIColor.cornerRadius(6)) + .background(Theme.Colors.textInputStroke.cornerRadius(6)) Picker("", selection: $selectedItem) { ForEach(filteredItems, id: \.self) { item in Text(item.value) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) } } .pickerStyle(.wheel) } .frame(minWidth: 0, maxWidth: idiom == .pad ? ipadPickerWidth : .infinity) .padding() - .background(CoreAssets.textInputBackground.swiftUIColor.cornerRadius(16)) + .background(Theme.Colors.textInputBackground.cornerRadius(16)) .padding(.horizontal, 16) .onChange(of: search, perform: { _ in if let first = filteredItems.first { @@ -105,10 +105,10 @@ public struct PickerMenu: View { router.dismiss(animated: true) }) { Text(CoreLocalization.Picker.accept) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .frame(minWidth: 0, maxWidth: idiom == .pad ? ipadPickerWidth : .infinity) .padding() - .background(CoreAssets.textInputBackground.swiftUIColor.cornerRadius(16)) + .background(Theme.Colors.textInputBackground.cornerRadius(16)) .padding(.horizontal, 16) } .padding(.bottom, 4) diff --git a/Core/Core/View/Base/PickerView.swift b/Core/Core/View/Base/PickerView.swift index d19d73116..fe8f10adc 100644 --- a/Core/Core/View/Base/PickerView.swift +++ b/Core/Core/View/Base/PickerView.swift @@ -23,7 +23,7 @@ public struct PickerView: View { Group { Text(config.field.label) .font(Theme.Fonts.labelLarge) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.top, 18) HStack { Button(action: { @@ -48,16 +48,16 @@ public struct PickerView: View { Image(systemName: "chevron.down") }) }.padding(.all, 14) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) .fill(config.error == "" ? - CoreAssets.textInputStroke.swiftUIColor + Theme.Colors.textInputStroke : Color.red) ) .shake($config.shake) @@ -65,7 +65,7 @@ public struct PickerView: View { : config.error) .font(Theme.Fonts.labelMedium) .foregroundColor(config.error == "" - ? CoreAssets.textPrimary.swiftUIColor + ? Theme.Colors.textPrimary : Color.red) } } diff --git a/Core/Core/View/Base/ProgressBar.swift b/Core/Core/View/Base/ProgressBar.swift index 7091194f8..9e75e7985 100644 --- a/Core/Core/View/Base/ProgressBar.swift +++ b/Core/Core/View/Base/ProgressBar.swift @@ -22,9 +22,9 @@ public struct ProgressBar: View { private let gradient = AngularGradient( gradient: Gradient(colors: [ - CoreAssets.accentColor.swiftUIColor.opacity(0.7), - CoreAssets.accentColor.swiftUIColor.opacity(0.35), - CoreAssets.accentColor.swiftUIColor.opacity(0.01)]), + Theme.Colors.accentColor.opacity(0.7), + Theme.Colors.accentColor.opacity(0.35), + Theme.Colors.accentColor.opacity(0.01)]), center: .center, startAngle: .degrees(270), endAngle: .degrees(0)) diff --git a/Core/Core/View/Base/RegistrationTextField.swift b/Core/Core/View/Base/RegistrationTextField.swift index 8fa28a7a6..1d68e8022 100644 --- a/Core/Core/View/Base/RegistrationTextField.swift +++ b/Core/Core/View/Base/RegistrationTextField.swift @@ -35,7 +35,7 @@ public struct RegistrationTextField: View { if config.field.label != "" { Text(config.field.label) .font(Theme.Fonts.labelLarge) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.top, 18) } if isTextArea { @@ -46,7 +46,7 @@ public struct RegistrationTextField: View { .hideScrollContentBackground() .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) ) .overlay( @@ -54,7 +54,7 @@ public struct RegistrationTextField: View { .stroke(lineWidth: 1) .fill( config.error == "" ? - CoreAssets.textInputStroke.swiftUIColor + Theme.Colors.textInputStroke : Color.red ) ) @@ -69,14 +69,14 @@ public struct RegistrationTextField: View { .padding(.all, 14) .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) .fill( config.error == "" ? - CoreAssets.textInputStroke.swiftUIColor + Theme.Colors.textInputStroke : Color.red ) ) @@ -90,14 +90,14 @@ public struct RegistrationTextField: View { .padding(.all, 14) .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) .fill( config.error == "" ? - CoreAssets.textInputStroke.swiftUIColor + Theme.Colors.textInputStroke : Color.red ) ) @@ -108,7 +108,7 @@ public struct RegistrationTextField: View { Text(config.error == "" ? config.field.instructions : config.error) .font(Theme.Fonts.bodySmall) .foregroundColor(config.error == "" - ? CoreAssets.textSecondary.swiftUIColor + ? Theme.Colors.textSecondary : Color.red) } } diff --git a/Core/Core/View/Base/SnackBarView.swift b/Core/Core/View/Base/SnackBarView.swift index 0469124d4..0272c15ba 100644 --- a/Core/Core/View/Base/SnackBarView.swift +++ b/Core/Core/View/Base/SnackBarView.swift @@ -36,7 +36,7 @@ public struct SnackBarView: View { .font(Theme.Fonts.titleSmall) } - }.shadowCardStyle(bgColor: CoreAssets.snackbarErrorColor.swiftUIColor, + }.shadowCardStyle(bgColor: Theme.Colors.snackbarErrorColor, textColor: .white) .padding(.bottom, 10) } diff --git a/Core/Core/View/Base/StyledButton.swift b/Core/Core/View/Base/StyledButton.swift index 65d223447..deb704595 100644 --- a/Core/Core/View/Base/StyledButton.swift +++ b/Core/Core/View/Base/StyledButton.swift @@ -19,17 +19,17 @@ public struct StyledButton: View { public init(_ title: String, action: @escaping () -> Void, isTransparent: Bool = false, - color: Color = CoreAssets.accentColor.swiftUIColor, + color: Color = Theme.Colors.accentColor, isActive: Bool = true) { self.title = title self.action = action self.isTransparent = isTransparent if isActive { self.buttonColor = color - self.textColor = CoreAssets.styledButtonText.swiftUIColor + self.textColor = Theme.Colors.styledButtonText } else { - self.buttonColor = CoreAssets.cardViewStroke.swiftUIColor - self.textColor = CoreAssets.textPrimary.swiftUIColor + self.buttonColor = Theme.Colors.cardViewStroke + self.textColor = Theme.Colors.textPrimary } } diff --git a/Core/Core/View/Base/UnitButtonView.swift b/Core/Core/View/Base/UnitButtonView.swift index 52b3900c9..67a49d0da 100644 --- a/Core/Core/View/Base/UnitButtonView.swift +++ b/Core/Core/View/Base/UnitButtonView.swift @@ -63,71 +63,71 @@ public struct UnitButtonView: View { case .first: HStack { Text(type.stringValue()) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) .font(Theme.Fonts.labelLarge) CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) .rotationEffect(Angle.degrees(-90)) }.padding(.horizontal, 16) case .next, .nextBig: HStack { Text(type.stringValue()) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) .padding(.leading, 20) .font(Theme.Fonts.labelLarge) if type != .nextBig { Spacer() } CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) .rotationEffect(Angle.degrees(-90)) .padding(.trailing, 20) } case .previous: HStack { Text(type.stringValue()) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) .font(Theme.Fonts.labelLarge) .padding(.leading, 20) CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) .rotationEffect(Angle.degrees(90)) .padding(.trailing, 20) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) } case .last: HStack { Text(type.stringValue()) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) .padding(.leading, 16) .font(Theme.Fonts.labelLarge) Spacer() CoreAssets.check.swiftUIImage.renderingMode(.template) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) .padding(.trailing, 16) } case .finish: HStack { Text(type.stringValue()) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) .font(Theme.Fonts.labelLarge) CoreAssets.check.swiftUIImage.renderingMode(.template) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) }.padding(.horizontal, 16) case .reload, .custom: VStack(alignment: .center) { Text(type.stringValue()) - .foregroundColor(bgColor == nil ? .white : CoreAssets.accentColor.swiftUIColor) + .foregroundColor(bgColor == nil ? .white : Theme.Colors.accentColor) .font(Theme.Fonts.labelLarge) }.padding(.horizontal, 16) case .continueLesson, .nextSection: HStack { Text(type.stringValue()) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) .padding(.leading, 20) .font(Theme.Fonts.labelLarge) CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) .rotationEffect(Angle.degrees(180)) .padding(.trailing, 20) } @@ -140,8 +140,8 @@ public struct UnitButtonView: View { case .first, .next, .nextBig, .previous, .last: Theme.Shapes.buttonShape .fill(type == .previous - ? CoreAssets.background.swiftUIColor - : CoreAssets.accentColor.swiftUIColor) + ? Theme.Colors.background + : Theme.Colors.accentColor) .shadow(color: Color.black.opacity(0.25), radius: 21, y: 4) .overlay( RoundedRectangle(cornerRadius: 8) @@ -151,12 +151,12 @@ public struct UnitButtonView: View { lineJoin: .round, miterLimit: 1) ) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) ) case .continueLesson, .nextSection, .reload, .finish, .custom: Theme.Shapes.buttonShape - .fill(bgColor ?? CoreAssets.accentColor.swiftUIColor) + .fill(bgColor ?? Theme.Colors.accentColor) .shadow(color: (type == .first || type == .next @@ -173,7 +173,7 @@ public struct UnitButtonView: View { lineJoin: .round, miterLimit: 1 )) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) ) } } diff --git a/Core/Core/View/Base/WebUnitView.swift b/Core/Core/View/Base/WebUnitView.swift index 7b4ed8157..ce8a9e098 100644 --- a/Core/Core/View/Base/WebUnitView.swift +++ b/Core/Core/View/Base/WebUnitView.swift @@ -28,9 +28,9 @@ public struct WebUnitView: View { .resizable() .scaledToFit() .frame(width: 64) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) Text(viewModel.errorMessage ?? "") - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .multilineTextAlignment(.center) .padding(.horizontal, 20) Button(action: { @@ -43,7 +43,7 @@ public struct WebUnitView: View { .background(Theme.Shapes.buttonShape.fill(.clear)) .overlay(RoundedRectangle(cornerRadius: 8) .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) ) }) .frame(width: 100) diff --git a/Course/Course/Presentation/Details/CourseDetailsView.swift b/Course/Course/Presentation/Details/CourseDetailsView.swift index c6ba3b2e1..876dc3086 100644 --- a/Course/Course/Presentation/Details/CourseDetailsView.swift +++ b/Course/Course/Presentation/Details/CourseDetailsView.swift @@ -175,7 +175,7 @@ public struct CourseDetailsView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } @@ -256,7 +256,7 @@ private struct CourseTitleView: View { Text(courseDetails.org) .font(Theme.Fonts.labelMedium) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) .padding(.horizontal, 26) .padding(.top, 10) } diff --git a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift index d16f4d8e5..b9087b1e6 100644 --- a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift @@ -127,7 +127,7 @@ public struct HandoutsUpdatesDetailView: View { Spacer(minLength: 84) }.background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index bbc0752e9..36c937d29 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -96,7 +96,7 @@ struct HandoutsView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } @@ -165,20 +165,20 @@ struct HandoutsItemCell: View { }, label: { HStack(spacing: 12) { type.image.renderingMode(.template) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .frame(width: 24, height: 24) VStack(alignment: .leading) { Text(type.title) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .font(Theme.Fonts.titleSmall) Text(type.description) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) .font(Theme.Fonts.labelSmall) } Spacer() Image(systemName: "chevron.right").resizable() .frame(width: 7, height: 12) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) } }).padding(.vertical, 16) diff --git a/Course/Course/Presentation/Outline/ContinueWithView.swift b/Course/Course/Presentation/Outline/ContinueWithView.swift index 14bc3b874..610d9fee0 100644 --- a/Course/Course/Presentation/Outline/ContinueWithView.swift +++ b/Course/Course/Presentation/Outline/ContinueWithView.swift @@ -35,7 +35,7 @@ struct ContinueWithView: View { HStack(alignment: .top) { VStack(alignment: .leading) { ContinueTitle(vertical: vertical) - }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) + }.foregroundColor(Theme.Colors.textPrimary) Spacer() UnitButtonView(type: .continueLesson, action: action) .frame(width: 200) @@ -44,7 +44,7 @@ struct ContinueWithView: View { } else { VStack(alignment: .leading) { ContinueTitle(vertical: vertical) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) } UnitButtonView(type: .continueLesson, action: action) } @@ -62,7 +62,7 @@ private struct ContinueTitle: View { var body: some View { Text(CoreLocalization.Courseware.continueWith) .font(Theme.Fonts.labelMedium) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) HStack { vertical.type.image Text(vertical.displayName) diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 9a31759c9..a08479293 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -60,7 +60,7 @@ public struct CourseOutlineView: View { // MARK: - Course Certificate if let certificate = viewModel.courseStructure?.certificate { if let url = certificate.url, url.count > 0 { - CoreAssets.certificateForeground.swiftUIColor + Theme.Colors.certificateForeground VStack(alignment: .center, spacing: 8) { CoreAssets.certificate.swiftUIImage Text(CourseLocalization.Outline.congratulations) @@ -181,7 +181,7 @@ public struct CourseOutlineView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } @@ -207,7 +207,7 @@ struct CourseStructureView: View { Text(chapter.displayName) .font(Theme.Fonts.titleMedium) .multilineTextAlignment(.leading) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) .padding(.horizontal, 24) .padding(.top, 40) ForEach(chapter.childs, id: \.id) { child in @@ -240,7 +240,7 @@ struct CourseStructureView: View { : proxy.size.width * 0.6, alignment: .leading ) - }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) + }.foregroundColor(Theme.Colors.textPrimary) Spacer() if let state = viewModel.downloadState[child.id] { switch state { @@ -280,13 +280,13 @@ struct CourseStructureView: View { } } Image(systemName: "chevron.right") - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) }).padding(.horizontal, 36) .padding(.vertical, 20) if chapterIndex != chapters.count - 1 { Divider() .frame(height: 1) - .overlay(CoreAssets.cardViewStroke.swiftUIColor) + .overlay(Theme.Colors.cardViewStroke) .padding(.horizontal, 24) } } diff --git a/Course/Course/Presentation/Outline/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVerticalView.swift index 40ccd5153..a89728eb3 100644 --- a/Course/Course/Presentation/Outline/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVerticalView.swift @@ -82,7 +82,7 @@ public struct CourseVerticalView: View { alignment: .leading) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) - }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) + }.foregroundColor(Theme.Colors.textPrimary) Spacer() if let state = viewModel.downloadState[vertical.id] { switch state { @@ -126,7 +126,7 @@ public struct CourseVerticalView: View { if index != viewModel.verticals.count - 1 { Divider() .frame(height: 1) - .overlay(CoreAssets.cardViewStroke.swiftUIColor) + .overlay(Theme.Colors.cardViewStroke) .padding(.horizontal, 24) } } @@ -161,7 +161,7 @@ public struct CourseVerticalView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 1479844a4..478457fa2 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -169,7 +169,7 @@ public struct CourseUnitView: View { alertMessage = CourseLocalization.Alert.rotateDevice } Text(alertMessage ?? "") - }.shadowCardStyle(bgColor: CoreAssets.accentColor.swiftUIColor, + }.shadowCardStyle(bgColor: Theme.Colors.accentColor, textColor: .white) .transition(.move(edge: .bottom)) .onAppear { @@ -204,7 +204,7 @@ public struct CourseUnitView: View { } }.ignoresSafeArea() .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } diff --git a/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift b/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift index 37dcb67d3..57a881589 100644 --- a/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift +++ b/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift @@ -31,7 +31,7 @@ struct LessonProgressView: View { .foregroundColor( selected == viewModel.selectedLesson() ? .accentColor - : CoreAssets.textSecondary.swiftUIColor + : Theme.Colors.textSecondary ) } Spacer() diff --git a/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift b/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift index 8aeab7f13..94080fc32 100644 --- a/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift +++ b/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift @@ -37,7 +37,7 @@ struct YouTubeView: View { )! YouTubeVideoPlayer(viewModel: vm, isOnScreen: isOnScreen) Spacer(minLength: 100) - }.background(CoreAssets.background.swiftUIColor) + }.background(Theme.Colors.background) } } } diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index a3ddda18f..251b59417 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -107,7 +107,7 @@ public struct EncodedVideoPlayer: View { HStack(spacing: 6) { CoreAssets.rotateDevice.swiftUIImage.renderingMode(.template) Text(alertMessage) - }.shadowCardStyle(bgColor: CoreAssets.snackbarInfoAlert.swiftUIColor, + }.shadowCardStyle(bgColor: Theme.Colors.snackbarInfoAlert, textColor: .white) .transition(.move(edge: .bottom)) .onAppear { diff --git a/Course/Course/Presentation/Video/SubtittlesView.swift b/Course/Course/Presentation/Video/SubtittlesView.swift index befc34f68..6501cb409 100644 --- a/Course/Course/Presentation/Video/SubtittlesView.swift +++ b/Course/Course/Presentation/Video/SubtittlesView.swift @@ -45,7 +45,7 @@ public struct SubtittlesView: View { Group { CoreAssets.sub.swiftUIImage.renderingMode(.template) Text(viewModel.generateLanguageName(code: viewModel.selectedLanguage ?? "")) - }.foregroundColor(CoreAssets.accentColor.swiftUIColor) + }.foregroundColor(Theme.Colors.accentColor) .font(Theme.Fonts.labelLarge) }) } @@ -60,8 +60,8 @@ public struct SubtittlesView: View { .padding(.vertical, 16) .font(Theme.Fonts.bodyMedium) .foregroundColor(subtitle.fromTo.contains(Date(milliseconds: currentTime)) - ? CoreAssets.textPrimary.swiftUIColor - : CoreAssets.textSecondary.swiftUIColor) + ? Theme.Colors.textPrimary + : Theme.Colors.textSecondary) .onChange(of: currentTime, perform: { _ in if subtitle.fromTo.contains(Date(milliseconds: currentTime)) { if id != subtitle.id { diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index b8cc5d335..f3a886c72 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -84,7 +84,7 @@ public struct YouTubeVideoPlayer: View { HStack(spacing: 6) { CoreAssets.rotateDevice.swiftUIImage.renderingMode(.template) Text(alertMessage) - }.shadowCardStyle(bgColor: CoreAssets.snackbarInfoAlert.swiftUIColor, + }.shadowCardStyle(bgColor: Theme.Colors.snackbarInfoAlert, textColor: .white) .transition(.move(edge: .bottom)) .onAppear { diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/DashboardView.swift index 274a77ecb..2cfeb25a6 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/DashboardView.swift @@ -12,10 +12,10 @@ public struct DashboardView: View { private let dashboardCourses: some View = VStack(alignment: .leading) { Text(DashboardLocalization.Header.courses) .font(Theme.Fonts.displaySmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) Text(DashboardLocalization.Header.welcomeBack) .font(Theme.Fonts.titleSmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) }.listRowBackground(Color.clear) .padding(.top, 24) @@ -125,7 +125,7 @@ public struct DashboardView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } @@ -159,11 +159,11 @@ struct EmptyPageIcon: View { .padding(.bottom, 16) Text(DashboardLocalization.Empty.title) .font(Theme.Fonts.titleMedium) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 8) Text(DashboardLocalization.Empty.subtitle) .font(Theme.Fonts.bodySmall) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) } .padding(.top, 200) } diff --git a/Discovery/Discovery/Presentation/DiscoveryView.swift b/Discovery/Discovery/Presentation/DiscoveryView.swift index 043170fc7..b82e915a9 100644 --- a/Discovery/Discovery/Presentation/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/DiscoveryView.swift @@ -18,10 +18,10 @@ public struct DiscoveryView: View { private let discoveryNew: some View = VStack(alignment: .leading) { Text(DiscoveryLocalization.Header.title1) .font(Theme.Fonts.displaySmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) Text(DiscoveryLocalization.Header.title2) .font(Theme.Fonts.titleSmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) }.listRowBackground(Color.clear) public init(viewModel: DiscoveryViewModel, router: DiscoveryRouter) { @@ -48,7 +48,7 @@ public struct DiscoveryView: View { .padding(.leading, 16) .padding(.top, 1) Text(DiscoveryLocalization.search) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) Spacer() } .onTapGesture { @@ -59,12 +59,12 @@ public struct DiscoveryView: View { .frame(maxWidth: 532) .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputUnfocusedBackground.swiftUIColor) + .fill(Theme.Colors.textInputUnfocusedBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) - .fill(CoreAssets.textInputUnfocusedStroke.swiftUIColor) + .fill(Theme.Colors.textInputUnfocusedStroke) ).onTapGesture { router.showDiscoverySearch() viewModel.discoverySearchBarClicked() @@ -146,7 +146,7 @@ public struct DiscoveryView: View { } } } - .background(CoreAssets.background.swiftUIColor.ignoresSafeArea()) + .background(Theme.Colors.background.ignoresSafeArea()) } } diff --git a/Discovery/Discovery/Presentation/SearchView.swift b/Discovery/Discovery/Presentation/SearchView.swift index b330d8c5e..e8dbc5e79 100644 --- a/Discovery/Discovery/Presentation/SearchView.swift +++ b/Discovery/Discovery/Presentation/SearchView.swift @@ -35,8 +35,8 @@ public struct SearchView: View { .padding(.top, -1) .foregroundColor( viewModel.isSearchActive - ? CoreAssets.accentColor.swiftUIColor - : CoreAssets.textPrimary.swiftUIColor + ? Theme.Colors.accentColor + : Theme.Colors.textPrimary ) TextField( @@ -54,7 +54,7 @@ public struct SearchView: View { self.becomeFirstResponderRunOnce = true } }) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) Spacer() if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { Button(action: { viewModel.searchText.removeAll() }, label: { @@ -64,7 +64,7 @@ public struct SearchView: View { .frame(height: 24) .padding(.horizontal) }) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) } } .padding(.top, 3) @@ -73,15 +73,15 @@ public struct SearchView: View { .background( Theme.Shapes.textInputShape .fill(viewModel.isSearchActive - ? CoreAssets.textInputBackground.swiftUIColor - : CoreAssets.textInputUnfocusedBackground.swiftUIColor) + ? Theme.Colors.textInputBackground + : Theme.Colors.textInputUnfocusedBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) .fill(viewModel.isSearchActive - ? CoreAssets.accentColor.swiftUIColor - : CoreAssets.textInputUnfocusedStroke.swiftUIColor) + ? Theme.Colors.accentColor + : Theme.Colors.textInputUnfocusedStroke) ) .padding(.horizontal, 24) .padding(.bottom, 20) @@ -155,7 +155,7 @@ public struct SearchView: View { } } } - .background(CoreAssets.background.swiftUIColor.ignoresSafeArea()) + .background(Theme.Colors.background.ignoresSafeArea()) .addTapToEndEditing(isForced: true) } @@ -163,10 +163,10 @@ public struct SearchView: View { return VStack(alignment: .leading) { Text(DiscoveryLocalization.Search.title) .font(Theme.Fonts.displaySmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) Text(searchDescription(viewModel: viewModel)) .font(Theme.Fonts.titleSmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) }.listRowBackground(Color.clear) } diff --git a/Discussion/Discussion/Presentation/CheckBoxView.swift b/Discussion/Discussion/Presentation/CheckBoxView.swift index bef86eacb..61af59732 100644 --- a/Discussion/Discussion/Presentation/CheckBoxView.swift +++ b/Discussion/Discussion/Presentation/CheckBoxView.swift @@ -16,8 +16,8 @@ public struct CheckBoxView: View { HStack(spacing: 10) { Image(systemName: checked ? "checkmark.square.fill" : "square") .foregroundColor(checked - ? CoreAssets.accentColor.swiftUIColor - : CoreAssets.textPrimary.swiftUIColor) + ? Theme.Colors.accentColor + : Theme.Colors.textPrimary) Text(text) .font(Theme.Fonts.labelLarge) } diff --git a/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift b/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift index d86eb4794..f818590d2 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift @@ -53,7 +53,7 @@ public struct CommentCell: View { .font(Theme.Fonts.titleSmall) Text(comment.postDate.dateToString(style: .lastPost)) .font(Theme.Fonts.labelSmall) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) } Spacer() Button(action: { @@ -67,8 +67,8 @@ public struct CommentCell: View { : DiscussionLocalization.Comment.report) .font(Theme.Fonts.labelMedium) }).foregroundColor(comment.abuseFlagged - ? CoreAssets.alert.swiftUIColor - : CoreAssets.textSecondary.swiftUIColor) + ? Theme.Colors.alert + : Theme.Colors.textSecondary) } Text(comment.postBodyHtml.hideHtmlTagsAndUrls()) .font(Theme.Fonts.bodyMedium) @@ -90,7 +90,7 @@ public struct CommentCell: View { Text(url.absoluteString) .multilineTextAlignment(.leading) } - }.foregroundColor(CoreAssets.accentColor.swiftUIColor) + }.foregroundColor(Theme.Colors.accentColor) .font(Theme.Fonts.bodyMedium) } } @@ -98,7 +98,7 @@ public struct CommentCell: View { LazyVStack { VStack {} .frame(height: 1) - .overlay(CoreAssets.cardViewStroke.swiftUIColor) + .overlay(Theme.Colors.cardViewStroke) .padding(.horizontal, 24) .onAppear { onFetchMore() @@ -115,8 +115,8 @@ public struct CommentCell: View { Text(DiscussionLocalization.votesCount(comment.votesCount)) .font(Theme.Fonts.labelLarge) }).foregroundColor(comment.voted - ? CoreAssets.accentColor.swiftUIColor - : CoreAssets.textSecondary.swiftUIColor) + ? Theme.Colors.accentColor + : Theme.Colors.textSecondary) Spacer() if addCommentAvailable { @@ -124,14 +124,14 @@ public struct CommentCell: View { Image(systemName: "message.fill") Text("\(comment.responsesCount)") Text(DiscussionLocalization.commentsCount(comment.responsesCount)) - }.foregroundColor(CoreAssets.textSecondary.swiftUIColor) + }.foregroundColor(Theme.Colors.textSecondary) .font(Theme.Fonts.labelLarge) } - }.foregroundColor(CoreAssets.accentColor.swiftUIColor) + }.foregroundColor(Theme.Colors.accentColor) .font(Theme.Fonts.labelMedium) }.cardStyle(top: leftLineEnabled ? 0 : 8, leftLineEnabled: leftLineEnabled, - bgColor: CoreAssets.commentCellBackground.swiftUIColor) + bgColor: Theme.Colors.commentCellBackground) .onTapGesture { if addCommentAvailable { onCommentsTap() diff --git a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift index 9becb7b63..ab6aa3455 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift @@ -48,7 +48,7 @@ public struct ParentCommentView: View { Text(comments.postDate .dateToString(style: .lastPost)) .font(Theme.Fonts.labelSmall) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) } Spacer() if isThread { @@ -60,8 +60,8 @@ public struct ParentCommentView: View { ? DiscussionLocalization.Comment.unfollow : DiscussionLocalization.Comment.follow) }).foregroundColor(comments.followed - ? CoreAssets.accentColor.swiftUIColor - : CoreAssets.textSecondary.swiftUIColor) + ? Theme.Colors.accentColor + : Theme.Colors.textSecondary) } }.padding(.top, 31) Text(comments.postTitle) @@ -85,7 +85,7 @@ public struct ParentCommentView: View { Text(url.absoluteString) .multilineTextAlignment(.leading) } - }.foregroundColor(CoreAssets.accentColor.swiftUIColor) + }.foregroundColor(Theme.Colors.accentColor) .font(Theme.Fonts.bodyMedium) } } @@ -100,8 +100,8 @@ public struct ParentCommentView: View { Text(DiscussionLocalization.votesCount(comments.votesCount)) .font(Theme.Fonts.labelLarge) }).foregroundColor(comments.voted - ? CoreAssets.accentColor.swiftUIColor - : CoreAssets.textSecondary.swiftUIColor) + ? Theme.Colors.accentColor + : Theme.Colors.textSecondary) Spacer() Button(action: { onReportTap() @@ -115,8 +115,8 @@ public struct ParentCommentView: View { }) } .accentColor(comments.abuseFlagged - ? CoreAssets.snackbarErrorColor.swiftUIColor - : CoreAssets.textSecondary.swiftUIColor) + ? Theme.Colors.snackbarErrorColor + : Theme.Colors.textSecondary) .font(Theme.Fonts.labelLarge) .padding(.top, 8) } @@ -124,7 +124,7 @@ public struct ParentCommentView: View { if isThread { Divider() .frame(height: 1) - .overlay(CoreAssets.cardViewStroke.swiftUIColor) + .overlay(Theme.Colors.cardViewStroke) .padding(.horizontal, 24) } } diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 0b0ba637b..92e5b8ac4 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -197,7 +197,7 @@ public struct ResponsesView: View { } }.edgesIgnoringSafeArea(.bottom) .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index acafd1bd9..7519ec6c3 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -218,7 +218,7 @@ public struct ThreadView: View { VStack { Text(viewModel.alertMessage ?? "") .shadowCardStyle( - bgColor: CoreAssets.accentColor.swiftUIColor, + bgColor: Theme.Colors.accentColor, textColor: .white ) .padding(.top, 80) @@ -234,7 +234,7 @@ public struct ThreadView: View { } }.edgesIgnoringSafeArea(.bottom) .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift index e21e8cf3c..a19201649 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift @@ -36,7 +36,7 @@ public struct CreateNewThreadView: View { Task { await viewModel.getTopics(courseID: courseID) } - UISegmentedControl.appearance().selectedSegmentTintColor = CoreAssets.accentColor.color + UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(Theme.Colors.accentColor) UISegmentedControl.appearance().setTitleTextAttributes([.foregroundColor: UIColor.white], for: .selected) } @@ -60,7 +60,7 @@ public struct CreateNewThreadView: View { HStack { Text(DiscussionLocalization.CreateThread.selectPostType) .font(Theme.Fonts.titleMedium) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) .padding(.top, 32) Spacer() } @@ -95,14 +95,14 @@ public struct CreateNewThreadView: View { Spacer() Image(systemName: "chevron.down") }.padding(.horizontal, 14) - .accentColor(CoreAssets.textPrimary.swiftUIColor) + .accentColor(Theme.Colors.textPrimary) .background(Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) - .fill(CoreAssets.textInputStroke.swiftUIColor) + .fill(Theme.Colors.textInputStroke) ) } } @@ -119,13 +119,13 @@ public struct CreateNewThreadView: View { .frame(height: 40) .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) .fill( - CoreAssets.textInputStroke.swiftUIColor + Theme.Colors.textInputStroke ) ) @@ -142,13 +142,13 @@ public struct CreateNewThreadView: View { .hideScrollContentBackground() .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) .fill( - CoreAssets.textInputStroke.swiftUIColor + Theme.Colors.textInputStroke ) ) @@ -189,7 +189,7 @@ public struct CreateNewThreadView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift index e7f925c02..f3513503a 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift @@ -32,8 +32,8 @@ public struct DiscussionSearchTopicsView: View { .padding(.top, -1) .foregroundColor( viewModel.isSearchActive - ? CoreAssets.accentColor.swiftUIColor - : CoreAssets.textPrimary.swiftUIColor + ? Theme.Colors.accentColor + : Theme.Colors.textPrimary ) TextField( @@ -51,7 +51,7 @@ public struct DiscussionSearchTopicsView: View { self.becomeFirstResponderRunOnce = true } }) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) Spacer() if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { Button(action: { viewModel.searchText.removeAll() }, label: { @@ -61,7 +61,7 @@ public struct DiscussionSearchTopicsView: View { .frame(height: 24) .padding(.horizontal) }) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .foregroundColor(Theme.Colors.styledButtonText) } } .padding(.top, 3) @@ -70,15 +70,15 @@ public struct DiscussionSearchTopicsView: View { .background( Theme.Shapes.textInputShape .fill(viewModel.isSearchActive - ? CoreAssets.textInputBackground.swiftUIColor - : CoreAssets.textInputUnfocusedBackground.swiftUIColor) + ? Theme.Colors.textInputBackground + : Theme.Colors.textInputUnfocusedBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) .fill(viewModel.isSearchActive - ? CoreAssets.accentColor.swiftUIColor - : CoreAssets.textInputUnfocusedStroke.swiftUIColor) + ? Theme.Colors.accentColor + : Theme.Colors.textInputUnfocusedStroke) ) .padding(.horizontal, 24) .padding(.bottom, 20) @@ -148,7 +148,7 @@ public struct DiscussionSearchTopicsView: View { } } } - .background(CoreAssets.background.swiftUIColor.ignoresSafeArea()) + .background(Theme.Colors.background.ignoresSafeArea()) .addTapToEndEditing(isForced: true) } @@ -156,10 +156,10 @@ public struct DiscussionSearchTopicsView: View { return VStack(alignment: .leading) { Text(DiscussionLocalization.Search.title) .font(Theme.Fonts.displaySmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) Text(searchDescription(viewModel: viewModel)) .font(Theme.Fonts.titleSmall) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) }.listRowBackground(Color.clear) } diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index acb81523d..2a7948857 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -38,19 +38,19 @@ public struct DiscussionTopicsView: View { .padding(.leading, 16) .padding(.top, 1) Text(DiscussionLocalization.Topics.search) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) Spacer() } .frame(maxWidth: 532) .frame(minHeight: 48) .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputUnfocusedBackground.swiftUIColor) + .fill(Theme.Colors.textInputUnfocusedBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) - .fill(CoreAssets.textInputUnfocusedStroke.swiftUIColor) + .fill(Theme.Colors.textInputUnfocusedStroke) ) .onTapGesture { viewModel.router.showDiscussionsSearch(courseID: courseID) @@ -75,7 +75,7 @@ public struct DiscussionTopicsView: View { HStack { Text(DiscussionLocalization.Topics.mainCategories) .font(Theme.Fonts.titleMedium) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) .padding(.horizontal, 24) .padding(.top, 40) Spacer() @@ -94,7 +94,7 @@ public struct DiscussionTopicsView: View { Spacer(minLength: 0) } .frame(maxWidth: .infinity) - }).cardStyle(bgColor: CoreAssets.textInputUnfocusedBackground.swiftUIColor) + }).cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground) .padding(.trailing, -20) } if let followed = topics.first(where: { @@ -110,7 +110,7 @@ public struct DiscussionTopicsView: View { Spacer(minLength: 0) } .frame(maxWidth: .infinity) - }).cardStyle(bgColor: CoreAssets.textInputUnfocusedBackground.swiftUIColor) + }).cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground) .padding(.leading, -20) } @@ -123,7 +123,7 @@ public struct DiscussionTopicsView: View { HStack { Text("\(topic.name):") .font(Theme.Fonts.titleMedium) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) Spacer() }.padding(.top, 32) .padding(.bottom, 8) @@ -150,7 +150,7 @@ public struct DiscussionTopicsView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } @@ -201,10 +201,10 @@ public struct TopicCell: View { HStack { Text(topic.name) .font(Theme.Fonts.titleMedium) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) Spacer() Image(systemName: "chevron.right") - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) } }) diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 954891545..8f3008aa1 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -85,14 +85,14 @@ public struct PostsView: View { CoreAssets.sort.swiftUIImage Text(viewModel.sortTitle.localizedValue) }) - }.foregroundColor(CoreAssets.accentColor.swiftUIColor) + }.foregroundColor(Theme.Colors.accentColor) } .font(Theme.Fonts.labelMedium) .padding(.horizontal, 24) .padding(.vertical, 12) - .shadow(color: CoreAssets.shadowColor.swiftUIColor, + .shadow(color: Theme.Colors.shadowColor, radius: 12, y: 4) .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background ) Divider().offset(y: -8) } @@ -112,7 +112,7 @@ public struct PostsView: View { HStack(alignment: .center) { Text(title) .font(Theme.Fonts.titleLarge) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) Spacer() Button(action: { router.createNewThread(courseID: courseID, @@ -133,7 +133,7 @@ public struct PostsView: View { .foregroundColor(.white) .background( Circle() - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) ) }) } @@ -161,7 +161,7 @@ public struct PostsView: View { VStack(spacing: 0) { CoreAssets.discussionIcon.swiftUIImage .renderingMode(.template) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) Text(DiscussionLocalization.Posts.NoDiscussion.title) .font(Theme.Fonts.titleLarge) .multilineTextAlignment(.center) @@ -207,7 +207,7 @@ public struct PostsView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) // MARK: - Action Sheet @@ -289,21 +289,21 @@ public struct PostCell: View { Text(DiscussionLocalization.missedPostsCount(post.unreadCommentCount - 1)) } }.font(Theme.Fonts.labelSmall) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) Text(post.title) .multilineTextAlignment(.leading) .font(Theme.Fonts.labelLarge) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) Text("\(DiscussionLocalization.Post.lastPost) \(post.lastPostDateFormatted)") .font(Theme.Fonts.labelSmall) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) HStack { CoreAssets.responses.swiftUIImage Text("\(post.replies - 1)") Text(DiscussionLocalization.responsesCount(post.replies - 1)) .font(Theme.Fonts.labelLarge) } - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) } }) } diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index fa57435d4..402896348 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -28,9 +28,9 @@ struct MainScreenView: View { init() { UITabBar.appearance().isTranslucent = false - UITabBar.appearance().barTintColor = CoreAssets.textInputUnfocusedBackground.color - UITabBar.appearance().backgroundColor = CoreAssets.textInputUnfocusedBackground.color - UITabBar.appearance().unselectedItemTintColor = CoreAssets.textSecondary.color + UITabBar.appearance().barTintColor = UIColor(Theme.Colors.textInputUnfocusedBackground) + UITabBar.appearance().backgroundColor = UIColor(Theme.Colors.textInputUnfocusedBackground) + UITabBar.appearance().unselectedItemTintColor = UIColor(Theme.Colors.textSecondary) } var body: some View { diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index bef8c06c9..8c6b778af 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -36,14 +36,14 @@ public struct DeleteAccountView: View { CoreAssets.deleteAccount.swiftUIImage .padding(.top, 50) Text(ProfileLocalization.DeleteAccount.areYouSure) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) + Text(ProfileLocalization.DeleteAccount.wantToDelete) - .foregroundColor(CoreAssets.alert.swiftUIColor) + .foregroundColor(Theme.Colors.alert) }.multilineTextAlignment(.center) .font(Theme.Fonts.headlineSmall) Text(ProfileLocalization.DeleteAccount.description) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) .font(Theme.Fonts.labelLarge) .multilineTextAlignment(.center) .padding(.top, 16) @@ -51,7 +51,7 @@ public struct DeleteAccountView: View { // MARK: Password Group { Text(ProfileLocalization.DeleteAccount.password) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) .font(Theme.Fonts.labelLarge) .multilineTextAlignment(.leading) .padding(.top, 16) @@ -60,24 +60,24 @@ public struct DeleteAccountView: View { SecureField(ProfileLocalization.DeleteAccount.passwordDescription, text: $viewModel.password) .font(Theme.Fonts.labelLarge) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .foregroundColor(Theme.Colors.textPrimary) } .padding(.horizontal, 14) .frame(minHeight: 48) .frame(maxWidth: .infinity) .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) - .fill(CoreAssets.textInputUnfocusedStroke.swiftUIColor) + .fill(Theme.Colors.textInputUnfocusedStroke) ) Text(viewModel.incorrectPassword ? ProfileLocalization.DeleteAccount.incorrectPassword : " ") - .foregroundColor(CoreAssets.alert.swiftUIColor) + .foregroundColor(Theme.Colors.alert) .font(Theme.Fonts.labelLarge) .multilineTextAlignment(.leading) .padding(.top, 0) @@ -98,7 +98,7 @@ public struct DeleteAccountView: View { Task { try await viewModel.deleteAccount(password: viewModel.password) } - }, color: CoreAssets.alert.swiftUIColor, + }, color: Theme.Colors.alert, isActive: viewModel.password.count >= 2) .padding(.top, 18) } @@ -142,7 +142,7 @@ public struct DeleteAccountView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index 93ba44d04..c9b1aaec3 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -60,7 +60,7 @@ public struct EditProfileView: View { VStack { Text(viewModel.profileChanges.profileType.localizedValue.capitalized) .font(Theme.Fonts.titleSmall) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) Button(action: { withAnimation { showingBottomSheet.toggle() @@ -71,7 +71,7 @@ public struct EditProfileView: View { .overlay( ZStack { Circle().frame(width: 36, height: 36) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .foregroundColor(Theme.Colors.accentColor) CoreAssets.addPhoto.swiftUIImage .foregroundColor(.white) }.offset(x: 36, y: 50) @@ -110,13 +110,13 @@ public struct EditProfileView: View { .hideScrollContentBackground() .background( Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) + .fill(Theme.Colors.textInputBackground) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) .fill( - CoreAssets.textInputStroke.swiftUIColor + Theme.Colors.textInputStroke ) ) } @@ -144,7 +144,7 @@ public struct EditProfileView: View { viewModel.router.showDeleteProfileView() }) .font(Theme.Fonts.labelLarge) - .foregroundColor(CoreAssets.alert.swiftUIColor) + .foregroundColor(Theme.Colors.alert) .padding(.top, 44) Spacer(minLength: 84) @@ -194,7 +194,7 @@ public struct EditProfileView: View { HStack(alignment: .top, spacing: 6) { CoreAssets.alarm.swiftUIImage.renderingMode(.template) Text(viewModel.alertMessage ?? "") - }.shadowCardStyle(bgColor: CoreAssets.warning.swiftUIColor, + }.shadowCardStyle(bgColor: Theme.Colors.warning, textColor: .black) .transition(.move(edge: .bottom)) .onAppear { @@ -225,7 +225,7 @@ public struct EditProfileView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } diff --git a/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift b/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift index 79f100c2c..9a3f09330 100644 --- a/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift +++ b/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift @@ -64,7 +64,7 @@ struct ProfileBottomSheet: View { VStack(alignment: .center, spacing: 4) { HStack(alignment: .center) { RoundedRectangle(cornerRadius: 2, style: .circular) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) .frame(width: 31, height: 4) .padding(.top, 4) }.frame(maxWidth: .infinity) @@ -97,7 +97,7 @@ struct ProfileBottomSheet: View { }.padding(.horizontal, 24) }.frame(maxWidth: idiom == .pad ? 330 : .infinity, maxHeight: 290, alignment: .topLeading) - .background(CoreAssets.cardViewBackground.swiftUIColor) + .background(Theme.Colors.cardViewBackground) .cornerRadius(8) .padding(.horizontal, 22) } @@ -159,7 +159,7 @@ extension ProfileBottomSheet { func bgColor() -> Color { switch self { case .gallery: - return CoreAssets.accentColor.swiftUIColor + return Theme.Colors.accentColor case .remove: return .clear case .cancel: @@ -170,11 +170,11 @@ extension ProfileBottomSheet { func frameColor() -> Color { switch self { case .gallery: - return CoreAssets.accentColor.swiftUIColor + return Theme.Colors.accentColor case .remove: - return CoreAssets.alert.swiftUIColor + return Theme.Colors.alert case .cancel: - return CoreAssets.textInputStroke.swiftUIColor + return Theme.Colors.textInputStroke } } @@ -183,9 +183,9 @@ extension ProfileBottomSheet { case .gallery: return .white case .remove: - return CoreAssets.alert.swiftUIColor + return Theme.Colors.alert case .cancel: - return CoreAssets.textPrimary.swiftUIColor + return Theme.Colors.textPrimary } } } diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index ec3879bc5..da4a4c05e 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -65,7 +65,7 @@ public struct ProfileView: View { Text("@\(viewModel.userModel?.username ?? "")") .font(Theme.Fonts.labelLarge) .padding(.top, 4) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) .padding(.bottom, 10) // MARK: - Profile Info @@ -79,20 +79,20 @@ public struct ProfileView: View { if viewModel.userModel?.yearOfBirth != 0 { HStack { Text(ProfileLocalization.Edit.Fields.yearOfBirth) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) Text(String(viewModel.userModel?.yearOfBirth ?? 0)) } } if let bio = viewModel.userModel?.shortBiography, bio != "" { HStack(alignment: .top) { Text(ProfileLocalization.bio + " ") - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) + Text(bio) } } } .cardStyle( - bgColor: CoreAssets.textInputUnfocusedBackground.swiftUIColor, + bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear ) }.padding(.bottom, 16) @@ -115,7 +115,7 @@ public struct ProfileView: View { }) } }.cardStyle( - bgColor: CoreAssets.textInputUnfocusedBackground.swiftUIColor, + bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear ) @@ -139,7 +139,7 @@ public struct ProfileView: View { .foregroundColor(.primary) Rectangle() .frame(height: 1) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) } if let tos = viewModel.config.termsOfUse { @@ -157,7 +157,7 @@ public struct ProfileView: View { .foregroundColor(.primary) Rectangle() .frame(height: 1) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) } if let privacy = viewModel.config.privacyPolicy { @@ -175,7 +175,7 @@ public struct ProfileView: View { .foregroundColor(.primary) } }.cardStyle( - bgColor: CoreAssets.textInputUnfocusedBackground.swiftUIColor, + bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear ) @@ -206,8 +206,8 @@ public struct ProfileView: View { Image(systemName: "rectangle.portrait.and.arrow.right") }) } - }.foregroundColor(CoreAssets.alert.swiftUIColor) - .cardStyle(bgColor: CoreAssets.textInputUnfocusedBackground.swiftUIColor, + }.foregroundColor(Theme.Colors.alert) + .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear) .padding(.top, 24) .padding(.bottom, 60) @@ -242,7 +242,7 @@ public struct ProfileView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } @@ -288,7 +288,7 @@ struct UserAvatar: View { var body: some View { ZStack { Circle() - .foregroundColor(CoreAssets.avatarStroke.swiftUIColor) + .foregroundColor(Theme.Colors.avatarStroke) .frame(width: 104, height: 104) if let image { Image(uiImage: image) diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 84b6869af..b414939ea 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -44,7 +44,7 @@ public struct SettingsView: View { Toggle(isOn: $viewModel.wifiOnly, label: {}) .toggleStyle(SwitchToggleStyle(tint: .accentColor)) .frame(width: 50) - }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) + }.foregroundColor(Theme.Colors.textPrimary) Divider() // MARK: Download Quality @@ -84,7 +84,7 @@ public struct SettingsView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } @@ -129,9 +129,9 @@ public struct SettingsCell: View { if let description { Text(description) .font(Theme.Fonts.labelMedium) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + .foregroundColor(Theme.Colors.textSecondary) } - }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) + }.foregroundColor(Theme.Colors.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index bcf071d6a..0b4e0e2a9 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -51,7 +51,7 @@ public struct VideoQualityView: View { .foregroundColor(.accentColor) .opacity(quality == viewModel.selectedQuality ? 1 : 0) - }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) + }.foregroundColor(Theme.Colors.textPrimary) }) Divider() } @@ -80,7 +80,7 @@ public struct VideoQualityView: View { } } .background( - CoreAssets.background.swiftUIColor + Theme.Colors.background .ignoresSafeArea() ) } From 8d45fc53ecdcb62a1a686d6bfd8786171a437927 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Wed, 9 Aug 2023 17:07:09 +0300 Subject: [PATCH 06/19] Fix not updating completion error (#64) completion doesn't update status when user has finished learning the contents of a unit --- .../Course/Presentation/Outline/CourseOutlineView.swift | 8 +++++++- .../Course/Presentation/Video/VideoPlayerViewModel.swift | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index a08479293..5b9f9bea7 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -229,7 +229,13 @@ struct CourseStructureView: View { }, label: { Group { - child.type.image + if child.completion == 1 { + CoreAssets.finished.swiftUIImage + .renderingMode(.template) + .foregroundColor(.accentColor) + } else { + child.type.image + } Text(child.displayName) .font(Theme.Fonts.titleMedium) .multilineTextAlignment(.leading) diff --git a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift index 7aab1c567..cddcdba6c 100644 --- a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift @@ -50,7 +50,7 @@ public class VideoPlayerViewModel: ObservableObject { @MainActor func blockCompletionRequest() async { - let fullBlockID = "block-v1:\(courseID.dropFirst(10))+type@discussion+block@\(blockID)" + let fullBlockID = "block-v1:\(courseID.dropFirst(10))+type@video+block@\(blockID)" do { try await interactor.blockCompletionRequest(courseID: courseID, blockID: fullBlockID) } catch let error { From 5cc6e14ac69b4fc21479d2a14bbcf8842a6be560 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 21 Aug 2023 13:04:07 +0300 Subject: [PATCH 07/19] Remove custom navigation controller (#63) * Remove custom navigation controller Change navigation controller from custom to system for feature changes and adding possibility to swipe gestures --- .../Presentation/Login/SignInView.swift | 7 +- .../Presentation/Login/SignInViewModel.swift | 25 +- .../Registration/SignUpView.swift | 6 +- .../Registration/SignUpViewModel.swift | 6 +- .../Reset Password/ResetPasswordView.swift | 3 +- .../Login/SignInViewModelTests.swift | 124 ++++-- .../Register/SignUpViewModelTests.swift | 131 +++--- .../arrowLeft.imageset/Contents.json | 3 + Core/Core/Configuration/BaseRouter.swift | 4 +- Core/Core/Extensions/CGColorExtension.swift | 17 + .../Extensions/UIApplicationExtension.swift | 25 ++ Core/Core/Extensions/ViewExtension.swift | 2 +- Core/Core/View/Base/NavigationBar.swift | 2 +- Core/Core/View/Base/WebBrowser.swift | 8 +- .../Container/CourseContainerView.swift | 37 +- .../Details/CourseDetailsView.swift | 19 +- .../Handouts/HandoutsUpdatesDetailView.swift | 114 +++--- .../Presentation/Handouts/HandoutsView.swift | 4 - .../Outline/CourseOutlineView.swift | 14 +- .../Outline/CourseVerticalView.swift | 197 +++++---- .../Presentation/Unit/CourseUnitView.swift | 35 +- .../Presentation/DashboardView.swift | 125 +++--- .../Presentation/DiscoveryView.swift | 72 ++-- .../Presentation/DiscoveryViewModel.swift | 8 +- .../Discovery/Presentation/SearchView.swift | 7 +- .../Comments/Responses/ResponsesView.swift | 14 +- .../Comments/Thread/ThreadView.swift | 285 +++++++------ .../CreateNewThread/CreateNewThreadView.swift | 9 +- .../DiscussionSearchTopicsView.swift | 8 +- .../DiscussionTopicsView.swift | 31 +- .../Presentation/Posts/PostsView.swift | 268 ++++++------- OpenEdX.xcodeproj/project.pbxproj | 4 - OpenEdX/RouteController.swift | 9 +- OpenEdX/Router.swift | 47 ++- OpenEdX/SwiftUIHostController.swift | 50 --- OpenEdX/View/MainScreenView.swift | 63 ++- .../DeleteAccount/DeleteAccountView.swift | 185 +++++---- .../EditProfile/EditProfileView.swift | 263 ++++++------ .../EditProfile/EditProfileViewModel.swift | 35 +- .../Presentation/Profile/ProfileView.swift | 377 +++++++++--------- .../Profile/ProfileViewModel.swift | 43 +- .../Presentation/Settings/SettingsView.swift | 89 ++--- .../Settings/VideoQualityView.swift | 80 ++-- .../EditProfileViewModelTests.swift | 62 +++ .../Profile/ProfileViewModelTests.swift | 234 ++++++++--- 45 files changed, 1720 insertions(+), 1431 deletions(-) delete mode 100644 OpenEdX/SwiftUIHostController.swift diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index d7434f57c..fd98fde7c 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -83,14 +83,14 @@ public struct SignInView: View { HStack { Button(AuthLocalization.SignIn.registerBtn) { - viewModel.analytics.signUpClicked() + viewModel.trackSignUpClicked() viewModel.router.showRegisterScreen() }.foregroundColor(Theme.Colors.accentColor) Spacer() Button(AuthLocalization.SignIn.forgotPassBtn) { - viewModel.analytics.forgotPasswordClicked() + viewModel.trackForgotPasswordClicked() viewModel.router.showForgotPasswordScreen() }.foregroundColor(Theme.Colors.accentColor) } @@ -149,6 +149,9 @@ public struct SignInView: View { } } } + .hideNavigationBar() + .navigationBarBackButtonHidden(true) + .navigationBarHidden(true) .background(Theme.Colors.background.ignoresSafeArea(.all)) } } diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 91a136958..6d8ebfdee 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -30,21 +30,24 @@ public class SignInViewModel: ObservableObject { } } - private let interactor: AuthInteractorProtocol let router: AuthorizationRouter - let analytics: AuthorizationAnalytics + + private let interactor: AuthInteractorProtocol + private let analytics: AuthorizationAnalytics private let validator: Validator - public init(interactor: AuthInteractorProtocol, - router: AuthorizationRouter, - analytics: AuthorizationAnalytics, - validator: Validator) { + public init( + interactor: AuthInteractorProtocol, + router: AuthorizationRouter, + analytics: AuthorizationAnalytics, + validator: Validator + ) { self.interactor = interactor self.router = router self.analytics = analytics self.validator = validator } - + @MainActor func login(username: String, password: String) async { guard validator.isValidEmail(username) else { @@ -76,4 +79,12 @@ public class SignInViewModel: ObservableObject { } } } + + func trackSignUpClicked() { + analytics.signUpClicked() + } + + func trackForgotPasswordClicked() { + analytics.forgotPasswordClicked() + } } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 65df060e8..2ce5f263c 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -2,7 +2,7 @@ // SignUpView.swift // Authorization // -// Created by  Stepanok Ivan on 24.10.2022. +// Created by Stepanok Ivan on 24.10.2022. // import SwiftUI @@ -48,7 +48,6 @@ public struct SignUpView: View { }.frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading) - .frameLimit() } GeometryReader { proxy in @@ -96,9 +95,9 @@ public struct SignUpView: View { } else { StyledButton(AuthLocalization.SignUp.createAccountBtn) { Task { - viewModel.analytics.createAccountClicked() await viewModel.registerUser() } + viewModel.trackCreateAccountClicked() } .padding(.top, 40) .padding(.bottom, 80) @@ -137,6 +136,7 @@ public struct SignUpView: View { } } .background(Theme.Colors.background.ignoresSafeArea(.all)) + .hideNavigationBar() } } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index c929c80c3..a2142684f 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -25,11 +25,11 @@ public class SignUpViewModel: ObservableObject { @Published var fields: [FieldConfiguration] = [] let router: AuthorizationRouter - let analytics: AuthorizationAnalytics let config: Config let cssInjector: CSSInjector private let interactor: AuthInteractorProtocol + private let analytics: AuthorizationAnalytics private let validator: Validator public init( @@ -106,4 +106,8 @@ public class SignUpViewModel: ObservableObject { } } } + + func trackCreateAccountClicked() { + analytics.createAccountClicked() + } } diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index b65238f69..17f7466c0 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -2,7 +2,7 @@ // ResetPasswordView.swift // Authorization // -// Created by  Stepanok Ivan on 27.03.2023. +// Created by Stepanok Ivan on 27.03.2023. // import SwiftUI @@ -150,6 +150,7 @@ public struct ResetPasswordView: View { } } .background(Theme.Colors.background.ignoresSafeArea(.all)) + .hideNavigationBar() } } diff --git a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift index 2fa56145d..d478f0f68 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift @@ -13,24 +13,26 @@ import Alamofire import SwiftUI final class SignInViewModelTests: XCTestCase { - + override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. } - + override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } - + func testLoginValidationEmailError() async throws { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignInViewModel(interactor: interactor, - router: router, - analytics: analytics, - validator: validator) + let viewModel = SignInViewModel( + interactor: interactor, + router: router, + analytics: analytics, + validator: validator + ) await viewModel.login(username: "email", password: "") @@ -46,10 +48,12 @@ final class SignInViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignInViewModel(interactor: interactor, - router: router, - analytics: analytics, - validator: validator) + let viewModel = SignInViewModel( + interactor: interactor, + router: router, + analytics: analytics, + validator: validator + ) await viewModel.login(username: "edxUser@edx.com", password: "") Verify(interactor, 0, .login(username: .any, password: .any)) @@ -64,10 +68,12 @@ final class SignInViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignInViewModel(interactor: interactor, - router: router, - analytics: analytics, - validator: validator) + let viewModel = SignInViewModel( + interactor: interactor, + router: router, + analytics: analytics, + validator: validator + ) let user = User(id: 1, username: "username", email: "edxUser@edx.com", name: "Name", userAvatar: "") Given(interactor, .login(username: .any, password: .any, willReturn: user)) @@ -87,10 +93,12 @@ final class SignInViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignInViewModel(interactor: interactor, - router: router, - analytics: analytics, - validator: validator) + let viewModel = SignInViewModel( + interactor: interactor, + router: router, + analytics: analytics, + validator: validator + ) let validationErrorMessage = "Some error" let validationError = CustomValidationError(statusCode: 400, data: ["error_description": validationErrorMessage]) @@ -112,10 +120,12 @@ final class SignInViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignInViewModel(interactor: interactor, - router: router, - analytics: analytics, - validator: validator) + let viewModel = SignInViewModel( + interactor: interactor, + router: router, + analytics: analytics, + validator: validator + ) Given(interactor, .login(username: .any, password: .any, willThrow: APIError.invalidGrant)) @@ -133,18 +143,20 @@ final class SignInViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignInViewModel(interactor: interactor, - router: router, - analytics: analytics, - validator: validator) + let viewModel = SignInViewModel( + interactor: interactor, + router: router, + analytics: analytics, + validator: validator + ) Given(interactor, .login(username: .any, password: .any, willThrow: NSError())) - + await viewModel.login(username: "edxUser@edx.com", password: "password123") - + Verify(interactor, 1, .login(username: .any, password: .any)) Verify(router, 0, .showMainScreen()) - + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) XCTAssertEqual(viewModel.isShowProgress, false) } @@ -154,22 +166,58 @@ final class SignInViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignInViewModel(interactor: interactor, - router: router, - analytics: analytics, - validator: validator) + let viewModel = SignInViewModel( + interactor: interactor, + router: router, + analytics: analytics, + validator: validator + ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) Given(interactor, .login(username: .any, password: .any, willThrow: noInternetError)) - + await viewModel.login(username: "edxUser@edx.com", password: "password123") - + Verify(interactor, 1, .login(username: .any, password: .any)) Verify(router, 0, .showMainScreen()) - + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) XCTAssertEqual(viewModel.isShowProgress, false) } - + + func testTrackSignUpClicked() { + let interactor = AuthInteractorProtocolMock() + let router = AuthorizationRouterMock() + let validator = Validator() + let analytics = AuthorizationAnalyticsMock() + let viewModel = SignInViewModel( + interactor: interactor, + router: router, + analytics: analytics, + validator: validator + ) + + viewModel.trackSignUpClicked() + + Verify(analytics, 1, .signUpClicked()) + } + + func testTrackForgotPasswordClicked() { + let interactor = AuthInteractorProtocolMock() + let router = AuthorizationRouterMock() + let validator = Validator() + let analytics = AuthorizationAnalyticsMock() + let viewModel = SignInViewModel( + interactor: interactor, + router: router, + analytics: analytics, + validator: validator + ) + + viewModel.trackForgotPasswordClicked() + + Verify(analytics, 1, .forgotPasswordClicked()) + } + } diff --git a/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift index ee463f6ef..8699b79fc 100644 --- a/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift @@ -27,12 +27,14 @@ final class SignUpViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignUpViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - cssInjector: CSSInjectorMock(), - validator: validator) + let viewModel = SignUpViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + cssInjector: CSSInjectorMock(), + validator: validator + ) let fields = [ PickerFields(type: .email, label: "", required: true, name: "email", instructions: "", options: []), @@ -56,12 +58,14 @@ final class SignUpViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignUpViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - cssInjector: CSSInjectorMock(), - validator: validator) + let viewModel = SignUpViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + cssInjector: CSSInjectorMock(), + validator: validator + ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -80,12 +84,14 @@ final class SignUpViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignUpViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - cssInjector: CSSInjectorMock(), - validator: validator) + let viewModel = SignUpViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + cssInjector: CSSInjectorMock(), + validator: validator + ) Given(interactor, .getRegistrationFields(willThrow: NSError())) @@ -102,12 +108,14 @@ final class SignUpViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignUpViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - cssInjector: CSSInjectorMock(), - validator: validator) + let viewModel = SignUpViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + cssInjector: CSSInjectorMock(), + validator: validator + ) Given(interactor, .registerUser(fields: .any, willReturn: .init(id: 1, username: "Name", @@ -131,12 +139,14 @@ final class SignUpViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignUpViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - cssInjector: CSSInjectorMock(), - validator: validator) + let viewModel = SignUpViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + cssInjector: CSSInjectorMock(), + validator: validator + ) viewModel.fields = [ FieldConfiguration(field: .init(type: .email, @@ -166,12 +176,14 @@ final class SignUpViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignUpViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - cssInjector: CSSInjectorMock(), - validator: validator) + let viewModel = SignUpViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + cssInjector: CSSInjectorMock(), + validator: validator + ) Given(interactor, .validateRegistrationFields(fields: .any, willReturn: [:])) Given(interactor, .registerUser(fields: .any, willThrow: APIError.invalidGrant)) @@ -192,12 +204,14 @@ final class SignUpViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignUpViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - cssInjector: CSSInjectorMock(), - validator: validator) + let viewModel = SignUpViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + cssInjector: CSSInjectorMock(), + validator: validator + ) Given(interactor, .validateRegistrationFields(fields: .any, willReturn: [:])) Given(interactor, .registerUser(fields: .any, willThrow: NSError())) @@ -218,12 +232,14 @@ final class SignUpViewModelTests: XCTestCase { let router = AuthorizationRouterMock() let validator = Validator() let analytics = AuthorizationAnalyticsMock() - let viewModel = SignUpViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - cssInjector: CSSInjectorMock(), - validator: validator) + let viewModel = SignUpViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + cssInjector: CSSInjectorMock(), + validator: validator + ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -240,4 +256,23 @@ final class SignUpViewModelTests: XCTestCase { XCTAssertEqual(viewModel.showError, true) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) } + + func testTrackCreateAccountClicked() { + let interactor = AuthInteractorProtocolMock() + let router = AuthorizationRouterMock() + let validator = Validator() + let analytics = AuthorizationAnalyticsMock() + let viewModel = SignUpViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + cssInjector: CSSInjectorMock(), + validator: validator + ) + + viewModel.trackCreateAccountClicked() + + Verify(analytics, 1, .createAccountClicked()) + } } diff --git a/Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json b/Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json index 90cefb924..b2ecd4b1c 100644 --- a/Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json @@ -18,5 +18,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/Core/Core/Configuration/BaseRouter.swift b/Core/Core/Configuration/BaseRouter.swift index c6c54ca67..c86b90f62 100644 --- a/Core/Core/Configuration/BaseRouter.swift +++ b/Core/Core/Configuration/BaseRouter.swift @@ -28,7 +28,7 @@ public protocol BaseRouter { func showRegisterScreen() func showForgotPasswordScreen() - + func presentAlert( alertTitle: String, alertMessage: String, @@ -88,7 +88,7 @@ open class BaseRouterMock: BaseRouter { public func backWithFade() {} public func removeLastView(controllers: Int) {} - + public func presentAlert( alertTitle: String, alertMessage: String, diff --git a/Core/Core/Extensions/CGColorExtension.swift b/Core/Core/Extensions/CGColorExtension.swift index 3086aaf61..2454e026d 100644 --- a/Core/Core/Extensions/CGColorExtension.swift +++ b/Core/Core/Extensions/CGColorExtension.swift @@ -25,3 +25,20 @@ public extension CGColor { return hexString } } + +public extension Color { + func uiColor() -> UIColor { + let scanner = Scanner(string: description.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)) + var hexNumber: UInt64 = 0 + var r: CGFloat = 0.0, g: CGFloat = 0.0, b: CGFloat = 0.0, a: CGFloat = 0.0 + + let result = scanner.scanHexInt64(&hexNumber) + if result { + r = CGFloat((hexNumber & 0xFF000000) >> 24) / 255 + g = CGFloat((hexNumber & 0x00FF0000) >> 16) / 255 + b = CGFloat((hexNumber & 0x0000FF00) >> 8) / 255 + a = CGFloat(hexNumber & 0x000000FF) / 255 + } + return UIColor(red: r, green: g, blue: b, alpha: a) + } +} diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index 1c5dec36c..64acdb8f8 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -34,3 +34,28 @@ extension UIApplication { return controller } } + +extension UINavigationController { + open override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + navigationBar.topItem?.backButtonDisplayMode = .minimal + navigationBar.barTintColor = .clear + navigationBar.setBackgroundImage(UIImage(), for: .default) + navigationBar.shadowImage = UIImage() + + navigationBar.backIndicatorImage = CoreAssets.arrowLeft.image + navigationBar.backIndicatorTransitionMaskImage = CoreAssets.arrowLeft.image + navigationBar.titleTextAttributes = [.foregroundColor: CoreAssets.textPrimary.color] + } +} + +extension UINavigationController: UIGestureRecognizerDelegate { + override open func viewDidLoad() { + super.viewDidLoad() + interactivePopGestureRecognizer?.delegate = self + } + + public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + return viewControllers.count > 1 + } +} diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 0d37801b3..3beb78550 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -202,7 +202,7 @@ public extension Image { .resizable() .scaledToFit() .frame(height: 24) - .padding(.horizontal) + .padding(.horizontal, 8) .padding(.top, topPadding) .foregroundColor(color) } diff --git a/Core/Core/View/Base/NavigationBar.swift b/Core/Core/View/Base/NavigationBar.swift index c616a7dea..2af6581f9 100644 --- a/Core/Core/View/Base/NavigationBar.swift +++ b/Core/Core/View/Base/NavigationBar.swift @@ -96,7 +96,7 @@ public struct NavigationBar: View { maxWidth: .infinity, alignment: .topTrailing) } - } .frameLimit() + } } } diff --git a/Core/Core/View/Base/WebBrowser.swift b/Core/Core/View/Base/WebBrowser.swift index 6d89e4528..3c0ae6ec4 100644 --- a/Core/Core/View/Base/WebBrowser.swift +++ b/Core/Core/View/Base/WebBrowser.swift @@ -25,11 +25,6 @@ public struct WebBrowser: View { // MARK: - Page name VStack(alignment: .center) { - NavigationBar( - title: pageTitle, - leftButtonAction: { presentationMode.wrappedValue.dismiss() } - ) - // MARK: - Page Body VStack { ZStack(alignment: .top) { @@ -46,6 +41,9 @@ public struct WebBrowser: View { } } } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(pageTitle) } } } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 69a9755f7..574f71b81 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -12,12 +12,6 @@ import Swinject public struct CourseContainerView: View { - @ObservedObject - private var viewModel: CourseContainerViewModel - @State private var selection: CourseTab = .course - private var courseID: String - private var title: String - enum CourseTab { case course case videos @@ -25,6 +19,12 @@ public struct CourseContainerView: View { case handounds } + @ObservedObject + private var viewModel: CourseContainerViewModel + @State private var selection: CourseTab = .course + private var courseID: String + private var title: String + public init( viewModel: CourseContainerViewModel, courseID: String, @@ -39,7 +39,7 @@ public struct CourseContainerView: View { } public var body: some View { - ZStack { + ZStack(alignment: .top) { if let courseStart = viewModel.courseStart { if courseStart > Date() { CourseOutlineView( @@ -61,7 +61,6 @@ public struct CourseContainerView: View { Text(CourseLocalization.CourseContainer.course) } .tag(CourseTab.course) - .hideNavigationBar() CourseOutlineView( viewModel: self.viewModel, @@ -74,7 +73,6 @@ public struct CourseContainerView: View { Text(CourseLocalization.CourseContainer.videos) } .tag(CourseTab.videos) - .hideNavigationBar() DiscussionTopicsView(courseID: courseID, viewModel: Container.shared.resolve(DiscussionTopicsViewModel.self, @@ -85,7 +83,6 @@ public struct CourseContainerView: View { Text(CourseLocalization.CourseContainer.discussion) } .tag(CourseTab.discussion) - .hideNavigationBar() HandoutsView(courseID: courseID, viewModel: Container.shared.resolve(HandoutsViewModel.self, argument: courseID)!) @@ -94,7 +91,6 @@ public struct CourseContainerView: View { Text(CourseLocalization.CourseContainer.handouts) } .tag(CourseTab.handounds) - .hideNavigationBar() } .onFirstAppear { Task { @@ -103,7 +99,11 @@ public struct CourseContainerView: View { } } } - }.onChange(of: selection, perform: { selection in + } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(titleBar()) + .onChange(of: selection, perform: { selection in viewModel.trackSelectedTab( selection: selection, courseId: courseID, @@ -111,6 +111,19 @@ public struct CourseContainerView: View { ) }) } + + private func titleBar() -> String { + switch selection { + case .course: + return self.title + case .videos: + return self.title + case .discussion: + return DiscussionLocalization.title + case .handounds: + return CourseLocalization.CourseContainer.handouts + } + } } #if DEBUG diff --git a/Course/Course/Presentation/Details/CourseDetailsView.swift b/Course/Course/Presentation/Details/CourseDetailsView.swift index 876dc3086..f8f9ebf0c 100644 --- a/Course/Course/Presentation/Details/CourseDetailsView.swift +++ b/Course/Course/Presentation/Details/CourseDetailsView.swift @@ -37,17 +37,7 @@ public struct CourseDetailsView: View { public var body: some View { ZStack(alignment: .top) { - - // MARK: - Page name VStack(alignment: .center) { - NavigationBar(title: CourseLocalization.Details.title, - leftButtonAction: { viewModel.router.back() }) - .onReceive(NotificationCenter - .Publisher(center: .default, - name: UIDevice.orientationDidChangeNotification)) { _ in - updateOrientation() - } - // MARK: - Page Body GeometryReader { proxy in if viewModel.isShowProgress { @@ -150,6 +140,15 @@ public struct CourseDetailsView: View { Spacer(minLength: 84) } } + }.padding(.top, 8) + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(CourseLocalization.Details.title) + + .onReceive(NotificationCenter + .Publisher(center: .default, + name: UIDevice.orientationDidChangeNotification)) { _ in + updateOrientation() } // MARK: - Offline mode SnackBar diff --git a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift index b9087b1e6..eaf467484 100644 --- a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift @@ -69,70 +69,68 @@ public struct HandoutsUpdatesDetailView: View { public var body: some View { ZStack(alignment: .top) { GeometryReader { reader in - // MARK: - Page name - VStack(alignment: .center) { - NavigationBar( - title: title, - leftButtonAction: { router.back() } - ) + + // MARK: - Page Body + VStack(alignment: .leading) { - // MARK: - Page Body - VStack(alignment: .leading) { + // MARK: - Handouts + if let handouts { + let formattedHandouts = cssInjector.injectCSS( + colorScheme: colorScheme, + html: handouts, + type: .discovery, + fontSize: idiom == .pad ? 100 : 300, + screenWidth: .infinity + ) + + WebViewHtml(fixBrokenLinks(in: formattedHandouts)) + } else if let announcements { - // MARK: - Handouts - if let handouts { - let formattedHandouts = cssInjector.injectCSS( - colorScheme: colorScheme, - html: handouts, - type: .discovery, - fontSize: idiom == .pad ? 100 : 300, - screenWidth: .infinity - ) - - WebViewHtml(fixBrokenLinks(in: formattedHandouts)) - } else if let announcements { - - // MARK: - Announcements - ScrollView { - ForEach(Array(announcements.enumerated()), id: \.offset) { index, ann in - - Text(ann.date) - .font(Theme.Fonts.labelSmall) - let formattedAnnouncements = cssInjector.injectCSS( - colorScheme: colorScheme, - html: ann.content, - type: .discovery, - screenWidth: reader.size.width - ) - HTMLFormattedText( - fixBrokenLinks(in: formattedAnnouncements), - isScrollEnabled: true, - textViewHeight: $height[index] - ) - .frame(height: height[index]) - - if index != announcements.count - 1 { - Divider() - } + // MARK: - Announcements + ScrollView { + ForEach(Array(announcements.enumerated()), id: \.offset) { index, ann in + + Text(ann.date) + .font(Theme.Fonts.labelSmall) + let formattedAnnouncements = cssInjector.injectCSS( + colorScheme: colorScheme, + html: ann.content, + type: .discovery, + screenWidth: reader.size.width + ) + HTMLFormattedText( + fixBrokenLinks(in: formattedAnnouncements), + isScrollEnabled: true, + textViewHeight: $height[index] + ) + .frame(height: height[index]) + + if index != announcements.count - 1 { + Divider() } - }.frame(height: reader.size.height - 60) - } - }.padding(.horizontal, 32) - .frame( - maxHeight: .infinity, - alignment: .topLeading) - .onRightSwipeGesture { - router.back() - } - Spacer(minLength: 84) - - }.background( - Theme.Colors.background - .ignoresSafeArea() - ) + } + }.frame(height: reader.size.height - 60) + } + }.padding(.top, 8) + .padding(.horizontal, 32) + .frame( + maxHeight: .infinity, + alignment: .topLeading) + .onRightSwipeGesture { + router.back() + } + Spacer(minLength: 84) + + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(title) } } diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index 36c937d29..a1eda50b6 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -25,11 +25,7 @@ struct HandoutsView: View { public var body: some View { ZStack(alignment: .top) { - - // MARK: - Page name VStack(alignment: .center) { - NavigationBar(title: CourseLocalization.CourseContainer.handouts, - leftButtonAction: {viewModel.router.back() }) // MARK: - Page Body if viewModel.isShowProgress { diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 5b9f9bea7..a0c38d78c 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -11,7 +11,7 @@ import Kingfisher public struct CourseOutlineView: View { - @ObservedObject private var viewModel: CourseContainerViewModel + @StateObject private var viewModel: CourseContainerViewModel private let title: String private let courseID: String private let isVideo: Bool @@ -26,7 +26,7 @@ public struct CourseOutlineView: View { isVideo: Bool ) { self.title = title - self.viewModel = viewModel + self._viewModel = StateObject(wrappedValue: { viewModel }()) self.courseID = courseID self.isVideo = isVideo } @@ -36,11 +36,6 @@ public struct CourseOutlineView: View { // MARK: - Page name GeometryReader { proxy in VStack(alignment: .center) { - NavigationBar( - title: title, - leftButtonAction: { viewModel.router.back() } - ) - // MARK: - Page Body RefreshableScrollViewCompat(action: { await viewModel.getCourseBlocks(courseID: courseID, withProgress: isIOS14) @@ -145,8 +140,8 @@ public struct CourseOutlineView: View { .onRightSwipeGesture { viewModel.router.back() } - } - + }.padding(.top, 8) + // MARK: - Offline mode SnackBar OfflineSnackBarView( connectivity: viewModel.connectivity, @@ -173,7 +168,6 @@ public struct CourseOutlineView: View { if viewModel.isShowProgress { VStack(alignment: .center) { ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 200) .padding(.horizontal) }.frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Course/Course/Presentation/Outline/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVerticalView.swift index a89728eb3..c5cb290af 100644 --- a/Course/Course/Presentation/Outline/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVerticalView.swift @@ -33,112 +33,108 @@ public struct CourseVerticalView: View { public var body: some View { ZStack(alignment: .top) { - VStack(alignment: .center) { - NavigationBar(title: title, - leftButtonAction: { viewModel.router.back() }) - - // MARK: - Page Body - GeometryReader { proxy in - ScrollView { - VStack(alignment: .leading) { - // MARK: - Lessons list - ForEach(viewModel.verticals, id: \.id) { vertical in - if let index = viewModel.verticals.firstIndex(where: {$0.id == vertical.id}) { - Button(action: { - let vertical = viewModel.verticals[index] - if let block = vertical.childs.first { - viewModel.trackVerticalClicked( - courseId: courseID, - courseName: courseName, - vertical: vertical - ) - viewModel.router.showCourseUnit( - courseName: courseName, - blockId: block.id, - courseID: courseID, - sectionName: block.displayName, - verticalIndex: index, - chapters: viewModel.chapters, - chapterIndex: viewModel.chapterIndex, - sequentialIndex: viewModel.sequentialIndex - ) - } - }, label: { - HStack { - Group { - if vertical.completion == 1 { - CoreAssets.finished.swiftUIImage - .renderingMode(.template) - .foregroundColor(.accentColor) - } else { - vertical.type.image - } - Text(vertical.displayName) - .font(Theme.Fonts.titleMedium) - .lineLimit(1) - .frame(maxWidth: idiom == .pad - ? proxy.size.width * 0.5 - : proxy.size.width * 0.6, - alignment: .leading) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - }.foregroundColor(Theme.Colors.textPrimary) - Spacer() - if let state = viewModel.downloadState[vertical.id] { - switch state { - case .available: - DownloadAvailableView() - .onTapGesture { - viewModel.onDownloadViewTap( - blockId: vertical.id, - state: state - ) - } - .onForeground { - viewModel.onForeground() - } - case .downloading: - DownloadProgressView() - .onTapGesture { - viewModel.onDownloadViewTap( - blockId: vertical.id, - state: state - ) - } - .onBackground { - viewModel.onBackground() - } - case .finished: - DownloadFinishedView() - .onTapGesture { - viewModel.onDownloadViewTap( - blockId: vertical.id, - state: state - ) - } - } + // MARK: - Page Body + GeometryReader { proxy in + ScrollView { + VStack(alignment: .leading) { + // MARK: - Lessons list + ForEach(viewModel.verticals, id: \.id) { vertical in + if let index = viewModel.verticals.firstIndex(where: {$0.id == vertical.id}) { + Button(action: { + let vertical = viewModel.verticals[index] + if let block = vertical.childs.first { + viewModel.trackVerticalClicked( + courseId: courseID, + courseName: courseName, + vertical: vertical + ) + viewModel.router.showCourseUnit( + courseName: courseName, + blockId: block.id, + courseID: courseID, + sectionName: block.displayName, + verticalIndex: index, + chapters: viewModel.chapters, + chapterIndex: viewModel.chapterIndex, + sequentialIndex: viewModel.sequentialIndex + ) + } + }, label: { + HStack { + Group { + if vertical.completion == 1 { + CoreAssets.finished.swiftUIImage + .renderingMode(.template) + .foregroundColor(.accentColor) + } else { + vertical.type.image + } + Text(vertical.displayName) + .font(Theme.Fonts.titleMedium) + .lineLimit(1) + .frame(maxWidth: idiom == .pad + ? proxy.size.width * 0.5 + : proxy.size.width * 0.6, + alignment: .leading) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + }.foregroundColor(Theme.Colors.textPrimary) + Spacer() + if let state = viewModel.downloadState[vertical.id] { + switch state { + case .available: + DownloadAvailableView() + .onTapGesture { + viewModel.onDownloadViewTap( + blockId: vertical.id, + state: state + ) + } + .onForeground { + viewModel.onForeground() + } + case .downloading: + DownloadProgressView() + .onTapGesture { + viewModel.onDownloadViewTap( + blockId: vertical.id, + state: state + ) + } + .onBackground { + viewModel.onBackground() + } + case .finished: + DownloadFinishedView() + .onTapGesture { + viewModel.onDownloadViewTap( + blockId: vertical.id, + state: state + ) + } } - Image(systemName: "chevron.right") - .padding(.vertical, 8) } - }).padding(.horizontal, 36) - .padding(.vertical, 14) - if index != viewModel.verticals.count - 1 { - Divider() - .frame(height: 1) - .overlay(Theme.Colors.cardViewStroke) - .padding(.horizontal, 24) + Image(systemName: "chevron.right") + .padding(.vertical, 8) } + }).padding(.horizontal, 36) + .padding(.vertical, 14) + if index != viewModel.verticals.count - 1 { + Divider() + .frame(height: 1) + .overlay(Theme.Colors.cardViewStroke) + .padding(.horizontal, 24) } } } - Spacer(minLength: 84) - }.frameLimit() - .onRightSwipeGesture { - viewModel.router.back() - } - } + } + Spacer(minLength: 84) + }.frameLimit() + .onRightSwipeGesture { + viewModel.router.back() + } } + .padding(.top, 8) // MARK: - Offline mode SnackBar OfflineSnackBarView(connectivity: viewModel.connectivity, @@ -160,6 +156,9 @@ public struct CourseVerticalView: View { } } } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(title) .background( Theme.Colors.background .ignoresSafeArea() diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 478457fa2..9b4807632 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -43,13 +43,8 @@ public struct CourseUnitView: View { ZStack(alignment: .bottom) { GeometryReader { reader in VStack(spacing: 0) { + VStack {}.frame(height: 100) if viewModel.connectivity.isInternetAvaliable { - NavigationBar(title: "", - leftButtonAction: { - viewModel.router.back() - playerStateSubject.send(VideoPlayerState.kill) - }).padding(.top, 50) - LazyVStack(spacing: 0) { let data = Array(viewModel.verticals[viewModel.verticalIndex].childs.enumerated()) ForEach(data, id: \.offset) { index, block in @@ -68,7 +63,6 @@ public struct CourseUnitView: View { isOnScreen: index == viewModel.index ).frameLimit() Spacer(minLength: 100) - // MARK: Encoded Video case let .video(encodedUrl, blockID): EncodedVideoView( @@ -104,14 +98,9 @@ public struct CourseUnitView: View { ) Spacer(minLength: 100) } else { - DiscussionView( - id: viewModel.courseID, - blockID: blockID, - blockKey: blockKey, - title: title, - viewModel: viewModel - ).drawingGroup() - Spacer(minLength: 100) + VStack { + Color.clear + } } }.frameLimit() } @@ -183,13 +172,6 @@ public struct CourseUnitView: View { // MARK: - Course Navigation VStack { - NavigationBar( - title: "", - leftButtonAction: { - viewModel.router.back() - playerStateSubject.send(VideoPlayerState.kill) - }).padding(.top, 50) - Spacer() CourseNavigationView( sectionName: sectionName, viewModel: viewModel, @@ -202,7 +184,14 @@ public struct CourseUnitView: View { viewModel.router.back() } } - }.ignoresSafeArea() + .onDisappear { + playerStateSubject.send(VideoPlayerState.kill) + } + } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle("") + .ignoresSafeArea() .background( Theme.Colors.background .ignoresSafeArea() diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/DashboardView.swift index 2cfeb25a6..d4e63949c 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/DashboardView.swift @@ -19,88 +19,78 @@ public struct DashboardView: View { }.listRowBackground(Color.clear) .padding(.top, 24) - @ObservedObject + @StateObject private var viewModel: DashboardViewModel private let router: DashboardRouter public init(viewModel: DashboardViewModel, router: DashboardRouter) { - self.viewModel = viewModel + self._viewModel = StateObject(wrappedValue: { viewModel }()) self.router = router - Task { - await viewModel.getMyCourses(page: 1) - } } public var body: some View { ZStack(alignment: .top) { - // MARK: - Page name + // MARK: - Page body VStack(alignment: .center) { - ZStack { - Text(DashboardLocalization.title) - .titleSettings() - } - - ZStack { - RefreshableScrollViewCompat(action: { - await viewModel.getMyCourses(page: 1, refresh: true) - }) { - if viewModel.courses.isEmpty && !viewModel.fetchInProgress { - EmptyPageIcon() - } else { - LazyVStack(spacing: 0) { - HStack { - dashboardCourses - .padding(.horizontal, 20) - .padding(.bottom, 20) - Spacer() - }.padding(.leading, 10) - ForEach(Array(viewModel.courses.enumerated()), - id: \.offset) { index, course in - - CourseCellView( - model: course, - type: .dashboard, - index: index, - cellsCount: viewModel.courses.count - ) + RefreshableScrollViewCompat(action: { + await viewModel.getMyCourses(page: 1, refresh: true) + }) { + if viewModel.courses.isEmpty && !viewModel.fetchInProgress { + EmptyPageIcon() + } else { + LazyVStack(spacing: 0) { + HStack { + dashboardCourses .padding(.horizontal, 20) - .listRowBackground(Color.clear) - .onAppear { - Task { - await viewModel.getMyCoursesPagination(index: index) - } - } - .onTapGesture { - viewModel.trackDashboardCourseClicked( - courseID: course.courseID, - courseName: course.name - ) - router.showCourseScreens( - courseID: course.courseID, - isActive: course.isActive, - courseStart: course.courseStart, - courseEnd: course.courseEnd, - enrollmentStart: course.enrollmentStart, - enrollmentEnd: course.enrollmentEnd, - title: course.name - ) + .padding(.bottom, 20) + Spacer() + }.padding(.leading, 10) + ForEach(Array(viewModel.courses.enumerated()), + id: \.offset) { index, course in + + CourseCellView( + model: course, + type: .dashboard, + index: index, + cellsCount: viewModel.courses.count + ) + .padding(.horizontal, 20) + .listRowBackground(Color.clear) + .onAppear { + Task { + await viewModel.getMyCoursesPagination(index: index) } } - // MARK: - ProgressBar - if viewModel.nextPage <= viewModel.totalPages { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) + .onTapGesture { + viewModel.trackDashboardCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + router.showCourseScreens( + courseID: course.courseID, + isActive: course.isActive, + courseStart: course.courseStart, + courseEnd: course.courseEnd, + enrollmentStart: course.enrollmentStart, + enrollmentEnd: course.enrollmentEnd, + title: course.name + ) } - VStack {}.frame(height: 40) } + // MARK: - ProgressBar + if viewModel.nextPage <= viewModel.totalPages { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) + } + VStack {}.frame(height: 40) } - }.frameLimit() - } - } + } + }.frameLimit() + }.padding(.top, 8) // MARK: - Offline mode SnackBar OfflineSnackBarView(connectivity: viewModel.connectivity, @@ -124,6 +114,11 @@ public struct DashboardView: View { } } } + .onFirstAppear { + Task { + await viewModel.getMyCourses(page: 1) + } + } .background( Theme.Colors.background .ignoresSafeArea() diff --git a/Discovery/Discovery/Presentation/DiscoveryView.swift b/Discovery/Discovery/Presentation/DiscoveryView.swift index b82e915a9..7341e8684 100644 --- a/Discovery/Discovery/Presentation/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/DiscoveryView.swift @@ -10,7 +10,7 @@ import Core public struct DiscoveryView: View { - @ObservedObject + @StateObject private var viewModel: DiscoveryViewModel private let router: DiscoveryRouter @State private var isRefreshing: Bool = false @@ -25,11 +25,8 @@ public struct DiscoveryView: View { }.listRowBackground(Color.clear) public init(viewModel: DiscoveryViewModel, router: DiscoveryRouter) { - self.viewModel = viewModel + self._viewModel = StateObject(wrappedValue: { viewModel }()) self.router = router - Task { - await viewModel.discovery(page: 1) - } } public var body: some View { @@ -37,10 +34,6 @@ public struct DiscoveryView: View { // MARK: - Page name VStack(alignment: .center) { - ZStack { - Text(DiscoveryLocalization.title) - .titleSettings(top: 10) - } // MARK: - Search fake field HStack(spacing: 11) { @@ -86,25 +79,28 @@ public struct DiscoveryView: View { .padding(.bottom, 20) Spacer() }.padding(.leading, 10) - ForEach(Array(viewModel.courses.enumerated()), - id: \.offset) { index, course in - CourseCellView(model: course, - type: .discovery, - index: index, - cellsCount: viewModel.courses.count) - .padding(.horizontal, 24) - .onAppear { - Task { - await viewModel.getDiscoveryCourses(index: index) + ForEach(Array(viewModel.courses.enumerated()), id: \.offset) { index, course in + CourseCellView( + model: course, + type: .discovery, + index: index, + cellsCount: viewModel.courses.count + ).padding(.horizontal, 24) + .onAppear { + Task { + await viewModel.getDiscoveryCourses(index: index) + } + } + .onTapGesture { + viewModel.discoveryCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + router.showCourseDetais( + courseID: course.courseID, + title: course.name + ) } - } - .onTapGesture { - viewModel.discoveryCourseClicked(courseID: course.courseID, courseName: course.name) - router.showCourseDetais( - courseID: course.courseID, - title: course.name - ) - } } // MARK: - ProgressBar @@ -119,16 +115,17 @@ public struct DiscoveryView: View { } }.frameLimit() } - } + }.padding(.top, 8) // MARK: - Offline mode SnackBar - OfflineSnackBarView(connectivity: viewModel.connectivity, - reloadAction: { - viewModel.courses = [] - viewModel.totalPages = 1 - viewModel.nextPage = 1 - await viewModel.discovery(page: 1, withProgress: isIOS14) - }) + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: { + viewModel.courses = [] + viewModel.totalPages = 1 + viewModel.nextPage = 1 + await viewModel.discovery(page: 1, withProgress: isIOS14) + }) // MARK: - Error Alert if viewModel.showError { @@ -146,6 +143,11 @@ public struct DiscoveryView: View { } } } + .onFirstAppear { + Task { + await viewModel.discovery(page: 1) + } + } .background(Theme.Colors.background.ignoresSafeArea()) } } diff --git a/Discovery/Discovery/Presentation/DiscoveryViewModel.swift b/Discovery/Discovery/Presentation/DiscoveryViewModel.swift index c99e2e3f8..568a37a9b 100644 --- a/Discovery/Discovery/Presentation/DiscoveryViewModel.swift +++ b/Discovery/Discovery/Presentation/DiscoveryViewModel.swift @@ -30,9 +30,11 @@ public class DiscoveryViewModel: ObservableObject { private let interactor: DiscoveryInteractorProtocol private let analytics: DiscoveryAnalytics - public init(interactor: DiscoveryInteractorProtocol, - connectivity: ConnectivityProtocol, - analytics: DiscoveryAnalytics) { + public init( + interactor: DiscoveryInteractorProtocol, + connectivity: ConnectivityProtocol, + analytics: DiscoveryAnalytics + ) { self.interactor = interactor self.connectivity = connectivity self.analytics = analytics diff --git a/Discovery/Discovery/Presentation/SearchView.swift b/Discovery/Discovery/Presentation/SearchView.swift index e8dbc5e79..09e4619cf 100644 --- a/Discovery/Discovery/Presentation/SearchView.swift +++ b/Discovery/Discovery/Presentation/SearchView.swift @@ -27,7 +27,7 @@ public struct SearchView: View { NavigationBar(title: DiscoveryLocalization.search, leftButtonAction: { viewModel.router.backWithFade() - }) + }).padding(.bottom, -7) HStack(spacing: 11) { Image(systemName: "magnifyingglass") @@ -67,7 +67,6 @@ public struct SearchView: View { .foregroundColor(Theme.Colors.styledButtonText) } } - .padding(.top, 3) .frame(minHeight: 48) .frame(maxWidth: 532) .background( @@ -147,7 +146,9 @@ public struct SearchView: View { } } } - }.hideNavigationBar() + } + .navigationBarBackButtonHidden(true) + .navigationBarHidden(true) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now()) { withAnimation(.easeIn(duration: 0.3)) { diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 92e5b8ac4..5d14e14ef 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -37,12 +37,6 @@ public struct ResponsesView: View { public var body: some View { ZStack(alignment: .top) { - - // MARK: - Page name - VStack(alignment: .center) { - NavigationBar(title: title, - leftButtonAction: { router.back() }) - // MARK: - Page Body ScrollViewReader { scroll in VStack { @@ -181,7 +175,7 @@ public struct ResponsesView: View { }) .frame(maxWidth: .infinity, maxHeight: .infinity) }.scrollAvoidKeyboard(dismissKeyboardByTap: true) - } + .padding(.top, 8) // MARK: - Error Alert if viewModel.showError { VStack { @@ -195,7 +189,11 @@ public struct ResponsesView: View { } } } - }.edgesIgnoringSafeArea(.bottom) + } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(title) + .edgesIgnoringSafeArea(.bottom) .background( Theme.Colors.background .ignoresSafeArea() diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 7519ec6c3..fcbdc1925 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -35,170 +35,161 @@ public struct ThreadView: View { public var body: some View { ZStack(alignment: .top) { - // MARK: - Page name - VStack(alignment: .center) { - NavigationBar(title: title, - leftButtonAction: { - viewModel.router.back() - onBackTapped() - viewModel.sendUpdateUnreadState() - }) - - // MARK: - Page Body - ScrollViewReader { scroll in - VStack { - ZStack(alignment: .top) { - RefreshableScrollViewCompat(action: { - if let thread { - viewModel.comments = [] - _ = await viewModel.getPosts(thread: thread, page: 1) - } - }) { - VStack { - if let comments = viewModel.postComments { - ParentCommentView( - comments: comments, - isThread: true, + // MARK: - Page Body + ScrollViewReader { scroll in + VStack { + ZStack(alignment: .top) { + RefreshableScrollViewCompat(action: { + if let thread { + viewModel.comments = [] + _ = await viewModel.getPosts(thread: thread, page: 1) + } + }) { + VStack { + if let comments = viewModel.postComments { + ParentCommentView( + comments: comments, + isThread: true, + onLikeTap: { + Task { + if await viewModel.vote( + id: comments.threadID, + isThread: true, + voted: comments.voted, + index: nil + ) { + viewModel.sendPostLikedState() + } + } + }, + onReportTap: { + Task { + if await viewModel.flag( + id: comments.threadID, + isThread: true, + abuseFlagged: comments.abuseFlagged, + index: nil + ) { + viewModel.sendReportedState() + } + } + }, + onFollowTap: { + Task { + if await viewModel.followThread( + following: comments.followed, + threadID: comments.threadID + ) { + viewModel.sendPostFollowedState() + } + } + } + ) + + HStack { + Text("\(viewModel.itemsCount)") + Text(DiscussionLocalization.responsesCount(viewModel.itemsCount)) + Spacer() + }.padding(.top, 40) + .padding(.bottom, 14) + .padding(.leading, 24) + .font(Theme.Fonts.titleMedium) + + ForEach(Array(comments.comments.enumerated()), id: \.offset) { index, comment in + CommentCell( + comment: comment, + addCommentAvailable: true, onLikeTap: { Task { - if await viewModel.vote( - id: comments.threadID, - isThread: true, - voted: comments.voted, - index: nil - ) { - viewModel.sendPostLikedState() - } + await viewModel.vote( + id: comment.commentID, + isThread: false, + voted: comment.voted, + index: index + ) } }, onReportTap: { Task { - if await viewModel.flag( - id: comments.threadID, - isThread: true, - abuseFlagged: comments.abuseFlagged, - index: nil - ) { - viewModel.sendReportedState() - } + await viewModel.flag( + id: comment.commentID, + isThread: false, + abuseFlagged: comment.abuseFlagged, + index: index + ) } }, - onFollowTap: { - Task { - if await viewModel.followThread( - following: comments.followed, - threadID: comments.threadID - ) { - viewModel.sendPostFollowedState() - } - } - } - ) - - HStack { - Text("\(viewModel.itemsCount)") - Text(DiscussionLocalization.responsesCount(viewModel.itemsCount)) - Spacer() - }.padding(.top, 40) - .padding(.bottom, 14) - .padding(.leading, 24) - .font(Theme.Fonts.titleMedium) - - ForEach(Array(comments.comments.enumerated()), id: \.offset) { index, comment in - CommentCell( - comment: comment, - addCommentAvailable: true, - onLikeTap: { - Task { - await viewModel.vote( - id: comment.commentID, - isThread: false, - voted: comment.voted, - index: index - ) - } - }, - onReportTap: { + onCommentsTap: { + viewModel.router.showComments( + commentID: comment.commentID, + parentComment: comment, + threadStateSubject: viewModel.threadStateSubject + ) + }, + onFetchMore: { + if let thread { Task { - await viewModel.flag( - id: comment.commentID, - isThread: false, - abuseFlagged: comment.abuseFlagged, - index: index - ) - } - }, - onCommentsTap: { - viewModel.router.showComments( - commentID: comment.commentID, - parentComment: comment, - threadStateSubject: viewModel.threadStateSubject - ) - }, - onFetchMore: { - if let thread { - Task { - await viewModel.fetchMorePosts(thread: thread, - index: index) - } + await viewModel.fetchMorePosts(thread: thread, + index: index) } } - ) - .id(index) - } - if viewModel.nextPage <= viewModel.totalPages { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) } + ) + .id(index) + } + if viewModel.nextPage <= viewModel.totalPages { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) } - Spacer(minLength: 84) } - } - .frameLimit() - .onRightSwipeGesture { - viewModel.router.back() - onBackTapped() - viewModel.sendUpdateUnreadState() + Spacer(minLength: 84) } } - if let thread { - if !thread.closed { - FlexibleKeyboardInputView( - hint: DiscussionLocalization.Thread.addResponse, - sendText: { commentText in - if let threadID = viewModel.postComments?.threadID { - Task { - await viewModel.postComment( - threadID: threadID, - rawBody: commentText, - parentID: viewModel.postComments?.parentID - ) - } + .frameLimit() + .onRightSwipeGesture { + viewModel.router.back() + onBackTapped() + viewModel.sendUpdateUnreadState() + } + } + if let thread { + if !thread.closed { + FlexibleKeyboardInputView( + hint: DiscussionLocalization.Thread.addResponse, + sendText: { commentText in + if let threadID = viewModel.postComments?.threadID { + Task { + await viewModel.postComment( + threadID: threadID, + rawBody: commentText, + parentID: viewModel.postComments?.parentID + ) } } - ) - } + } + ) } } - .onReceive(viewModel.addPostSubject, perform: { newComment in - guard let newComment else { return } - viewModel.sendPostRepliesCountState() - if viewModel.nextPage - 1 == viewModel.totalPages { - viewModel.addNewPost(newComment) - withAnimation { - guard let count = viewModel.postComments?.comments.count else { return } - scroll.scrollTo(count - 2, anchor: .top) - } - } else { - viewModel.alertMessage = DiscussionLocalization.Thread.Alert.commentAdded - viewModel.showAlert = true + } + .onReceive(viewModel.addPostSubject, perform: { newComment in + guard let newComment else { return } + viewModel.sendPostRepliesCountState() + if viewModel.nextPage - 1 == viewModel.totalPages { + viewModel.addNewPost(newComment) + withAnimation { + guard let count = viewModel.postComments?.comments.count else { return } + scroll.scrollTo(count - 2, anchor: .top) } - }) - .frame(maxWidth: .infinity, maxHeight: .infinity) - }.scrollAvoidKeyboard(dismissKeyboardByTap: true) - } + } else { + viewModel.alertMessage = DiscussionLocalization.Thread.Alert.commentAdded + viewModel.showAlert = true + } + }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + }.scrollAvoidKeyboard(dismissKeyboardByTap: true) } + .padding(.top, 8) // MARK: - Error Alert if viewModel.showError { VStack { @@ -232,7 +223,15 @@ public struct ThreadView: View { } } } - }.edgesIgnoringSafeArea(.bottom) + } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(title) + .onDisappear { + onBackTapped() + viewModel.sendUpdateUnreadState() + } + .edgesIgnoringSafeArea(.bottom) .background( Theme.Colors.background .ignoresSafeArea() diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift index a19201649..64e0c487a 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift @@ -42,11 +42,7 @@ public struct CreateNewThreadView: View { public var body: some View { ZStack(alignment: .top) { - - // MARK: - Page name VStack(alignment: .center) { - NavigationBar(title: DiscussionLocalization.CreateThread.newPost, - leftButtonAction: { viewModel.router.back() }) // MARK: - Page Body if viewModel.isShowProgress { @@ -186,8 +182,11 @@ public struct CreateNewThreadView: View { } }.scrollAvoidKeyboard(dismissKeyboardByTap: true) } - } + }.padding(.top, 8) } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(DiscussionLocalization.CreateThread.newPost) .background( Theme.Colors.background .ignoresSafeArea() diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift index f3513503a..24d4335da 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift @@ -25,7 +25,7 @@ public struct DiscussionSearchTopicsView: View { VStack(alignment: .center) { NavigationBar(title: DiscussionLocalization.search, leftButtonAction: { viewModel.router.backWithFade() }) - + .padding(.bottom, -7) HStack(spacing: 11) { Image(systemName: "magnifyingglass") .padding(.leading, 16) @@ -64,7 +64,7 @@ public struct DiscussionSearchTopicsView: View { .foregroundColor(Theme.Colors.styledButtonText) } } - .padding(.top, 3) +// .padding(.top, -7) .frame(minHeight: 48) .frame(maxWidth: 532) .background( @@ -140,7 +140,9 @@ public struct DiscussionSearchTopicsView: View { } } } - }.hideNavigationBar() + } + .navigationBarBackButtonHidden(true) + .navigationBarHidden(true) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now()) { withAnimation(.easeIn(duration: 0.3)) { diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index 2a7948857..73ac31b33 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -11,27 +11,19 @@ import Core public struct DiscussionTopicsView: View { - @ObservedObject private var viewModel: DiscussionTopicsViewModel + @StateObject private var viewModel: DiscussionTopicsViewModel private let router: DiscussionRouter private let courseID: String public init(courseID: String, viewModel: DiscussionTopicsViewModel, router: DiscussionRouter) { - self.viewModel = viewModel + self._viewModel = StateObject(wrappedValue: { viewModel }()) self.courseID = courseID - Task { - await viewModel.getTopics(courseID: courseID) - } self.router = router } public var body: some View { - ZStack(alignment: .top) { - - // MARK: - Page name + ZStack(alignment: .center) { VStack(alignment: .center) { - NavigationBar(title: DiscussionLocalization.title, - leftButtonAction: { router.back() }) - // MARK: - Search fake field HStack(spacing: 11) { Image(systemName: "magnifyingglass") @@ -65,11 +57,6 @@ public struct DiscussionTopicsView: View { await viewModel.getTopics(courseID: self.courseID, withProgress: isIOS14) }) { VStack { - if viewModel.isShowProgress { - ProgressBar(size: 40, lineWidth: 8) - .padding(.horizontal) - .padding(.top, 200) - } if let topics = viewModel.discussionTopics { HStack { @@ -147,8 +134,20 @@ public struct DiscussionTopicsView: View { } } }.frame(maxWidth: .infinity) + }.padding(.top, 8) + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.horizontal) + } + } + .onFirstAppear { + Task { + await viewModel.getTopics(courseID: courseID) } } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(DiscussionLocalization.title) .background( Theme.Colors.background .ignoresSafeArea() diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 8f3008aa1..49d90aab2 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -32,9 +32,6 @@ public struct PostsView: View { self.viewModel.courseID = courseID self.viewModel.topics = topics viewModel.type = type - Task { - await viewModel.getPosts(courseID: courseID, pageNumber: 1, withProgress: true) - } } public init(courseID: String, router: DiscussionRouter, viewModel: PostsViewModel) { @@ -43,161 +40,150 @@ public struct PostsView: View { self.currentBlockID = "" self.router = router self.viewModel = viewModel - Task { - await viewModel.getPosts(courseID: courseID, pageNumber: 1, withProgress: true) - } self.showTopMenu = true self.viewModel.courseID = courseID } public var body: some View { ZStack(alignment: .top) { - - // MARK: - Page name - VStack(alignment: .center) { - if showTopMenu { - NavigationBar(title: title, - leftButtonAction: { router.back() }) - } - // MARK: - Page Body - ScrollViewReader { scroll in + // MARK: - Page Body + ScrollViewReader { scroll in + VStack { + ZStack(alignment: .top) { VStack { - ZStack(alignment: .top) { VStack { - HStack(alignment: .top) { - VStack { - HStack { - Group { - Button(action: { - listAnimation = .easeIn - viewModel.generateButtons(type: .filter) - showingAlert = true - }, label: { - CoreAssets.filter.swiftUIImage - Text(viewModel.filterTitle.localizedValue) - }) - Spacer() - Button(action: { - listAnimation = .easeIn - viewModel.generateButtons(type: .sort) - showingAlert = true - }, label: { - CoreAssets.sort.swiftUIImage - Text(viewModel.sortTitle.localizedValue) + HStack { + Group { + Button(action: { + listAnimation = .easeIn + viewModel.generateButtons(type: .filter) + showingAlert = true + }, label: { + CoreAssets.filter.swiftUIImage + Text(viewModel.filterTitle.localizedValue) + }) + Spacer() + Button(action: { + listAnimation = .easeIn + viewModel.generateButtons(type: .sort) + showingAlert = true + }, label: { + CoreAssets.sort.swiftUIImage + Text(viewModel.sortTitle.localizedValue) + }) + }.foregroundColor(Theme.Colors.accentColor) + } .font(Theme.Fonts.labelMedium) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .shadow(color: Theme.Colors.shadowColor, + radius: 12, y: 4) + .background( + Theme.Colors.background + ) + Divider().offset(y: -8) + } + .frameLimit() + RefreshableScrollViewCompat(action: { + listAnimation = nil + viewModel.resetPosts() + _ = await viewModel.getPosts(courseID: courseID, + pageNumber: 1, + withProgress: isIOS14) + }) { + let posts = Array(viewModel.filteredPosts.enumerated()) + if posts.count >= 1 { + LazyVStack { + VStack {}.frame(height: 1) + .id(1) + HStack(alignment: .center) { + Text(title) + .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) + Spacer() + Button(action: { + router.createNewThread(courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage(onSuccess: { + withAnimation { + scroll.scrollTo(1) + } }) - }.foregroundColor(Theme.Colors.accentColor) - } .font(Theme.Fonts.labelMedium) - .padding(.horizontal, 24) - .padding(.vertical, 12) - .shadow(color: Theme.Colors.shadowColor, - radius: 12, y: 4) + }) + }, label: { + VStack { + CoreAssets.addComment.swiftUIImage + .font(Theme.Fonts.labelLarge) + .padding(6) + } + .foregroundColor(.white) .background( - Theme.Colors.background + Circle() + .foregroundColor(Theme.Colors.accentColor) ) - Divider().offset(y: -8) + }) } - }.frameLimit() - RefreshableScrollViewCompat(action: { - listAnimation = nil - viewModel.resetPosts() - _ = await viewModel.getPosts(courseID: courseID, - pageNumber: 1, - withProgress: isIOS14) - }) { - let posts = Array(viewModel.filteredPosts.enumerated()) - if posts.count >= 1 { - LazyVStack { - VStack {}.frame(height: 1) - .id(1) - HStack(alignment: .center) { - Text(title) - .font(Theme.Fonts.titleLarge) - .foregroundColor(Theme.Colors.textPrimary) - Spacer() - Button(action: { - router.createNewThread(courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } - }) - }) - }, label: { - VStack { - CoreAssets.addComment.swiftUIImage - .font(Theme.Fonts.labelLarge) - .padding(6) - } - .foregroundColor(.white) - .background( - Circle() - .foregroundColor(Theme.Colors.accentColor) + .padding(.horizontal, 24) + + ForEach(posts, id: \.offset) { index, post in + PostCell(post: post).padding(24) + .id(UUID()) + .onAppear { + Task { + await viewModel.getPostsPagination( + courseID: self.courseID, + index: index ) - }) - } - .padding(.horizontal, 24) - - ForEach(posts, id: \.offset) { index, post in - PostCell(post: post).padding(24) - .id(UUID()) - .onAppear { - Task { - await viewModel.getPostsPagination( - courseID: self.courseID, - index: index - ) - } - } - if posts.last?.element != post { - Divider().padding(.horizontal, 24) } } - Spacer(minLength: 84) - } - } else { - if !viewModel.fetchInProgress { - VStack(spacing: 0) { - CoreAssets.discussionIcon.swiftUIImage - .renderingMode(.template) - .foregroundColor(Theme.Colors.textPrimary) - Text(DiscussionLocalization.Posts.NoDiscussion.title) - .font(Theme.Fonts.titleLarge) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.top, 40) - Text(DiscussionLocalization.Posts.NoDiscussion.description) - .font(Theme.Fonts.bodyLarge) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.top, 12) - StyledButton(DiscussionLocalization.Posts.NoDiscussion.createbutton, - action: { - router.createNewThread(courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } - }) - }) - }).frame(width: 215).padding(.top, 40) - }.padding(24) - .padding(.top, 100) + if posts.last?.element != post { + Divider().padding(.horizontal, 24) } } + Spacer(minLength: 84) } - }.frameLimit() - .animation(listAnimation) - .onRightSwipeGesture { - router.back() + } else { + if !viewModel.fetchInProgress { + VStack(spacing: 0) { + CoreAssets.discussionIcon.swiftUIImage + .renderingMode(.template) + .foregroundColor(Theme.Colors.textPrimary) + Text(DiscussionLocalization.Posts.NoDiscussion.title) + .font(Theme.Fonts.titleLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 40) + Text(DiscussionLocalization.Posts.NoDiscussion.description) + .font(Theme.Fonts.bodyLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 12) + StyledButton(DiscussionLocalization.Posts.NoDiscussion.createbutton, + action: { + router.createNewThread(courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage(onSuccess: { + withAnimation { + scroll.scrollTo(1) + } + }) + }) + }).frame(width: 215).padding(.top, 40) + }.padding(24) + .padding(.top, 100) } + } + } + }.frameLimit() + .animation(listAnimation) + .onRightSwipeGesture { + router.back() } - }.frame(maxWidth: .infinity) } - } + }.frame(maxWidth: .infinity) + } + .padding(.top, 8) if viewModel.isShowProgress { VStack(alignment: .center) { ProgressBar(size: 40, lineWidth: 8) @@ -206,6 +192,14 @@ public struct PostsView: View { maxHeight: .infinity) } } + .onFirstAppear { + Task { + await viewModel.getPosts(courseID: courseID, pageNumber: 1, withProgress: true) + } + } + .navigationBarHidden(!showTopMenu) + .navigationBarBackButtonHidden(!showTopMenu) + .navigationTitle(title) .background( Theme.Colors.background .ignoresSafeArea() diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 101ae2cf5..430caae7c 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -32,7 +32,6 @@ 0770DE1728D080A1006D8A5D /* RouteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE1628D080A1006D8A5D /* RouteController.swift */; }; 0770DE1E28D084E8006D8A5D /* AppAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE1D28D084E8006D8A5D /* AppAssembly.swift */; }; 0770DE2028D0858A006D8A5D /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE1F28D0858A006D8A5D /* Router.swift */; }; - 0770DE2728D09209006D8A5D /* SwiftUIHostController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE2628D09209006D8A5D /* SwiftUIHostController.swift */; }; 0770DE4B28D0A462006D8A5D /* Authorization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0770DE4A28D0A462006D8A5D /* Authorization.framework */; }; 0770DE4C28D0A462006D8A5D /* Authorization.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0770DE4A28D0A462006D8A5D /* Authorization.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0770DE5028D0A707006D8A5D /* NetworkAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE4F28D0A707006D8A5D /* NetworkAssembly.swift */; }; @@ -94,7 +93,6 @@ 0770DE1628D080A1006D8A5D /* RouteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteController.swift; sourceTree = ""; }; 0770DE1D28D084E8006D8A5D /* AppAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAssembly.swift; sourceTree = ""; }; 0770DE1F28D0858A006D8A5D /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; - 0770DE2628D09209006D8A5D /* SwiftUIHostController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIHostController.swift; sourceTree = ""; }; 0770DE4A28D0A462006D8A5D /* Authorization.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Authorization.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0770DE4F28D0A707006D8A5D /* NetworkAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkAssembly.swift; sourceTree = ""; }; 0770DE6528D0BCC7006D8A5D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; @@ -189,7 +187,6 @@ 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */, 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */, 0293A2012A6FC9E30090A336 /* Data */, - 0770DE2628D09209006D8A5D /* SwiftUIHostController.swift */, 0727876C28D23312002E9142 /* Environment.swift */, 0727878C28D347B2002E9142 /* View */, 0770DE1A28D084BC006D8A5D /* DI */, @@ -387,7 +384,6 @@ 0727876D28D23312002E9142 /* Environment.swift in Sources */, 0293A2092A6FCDE50090A336 /* DashboardPersistence.swift in Sources */, 0770DE1728D080A1006D8A5D /* RouteController.swift in Sources */, - 0770DE2728D09209006D8A5D /* SwiftUIHostController.swift in Sources */, 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index a4264ca0a..7086e0ec5 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -6,6 +6,7 @@ // import UIKit +import SwiftUI import Core import Authorization @@ -15,7 +16,7 @@ class RouteController: UIViewController { diContainer.resolve(UINavigationController.self)! }() - private lazy var appStorage: AppStorage = { + private lazy var appStorage: Core.AppStorage = { diContainer.resolve(AppStorage.self)! }() @@ -39,15 +40,15 @@ class RouteController: UIViewController { } private func showAuthorization() { - let controller = SwiftUIHostController( - view: SignInView(viewModel: diContainer.resolve(SignInViewModel.self)!) + let controller = UIHostingController( + rootView: SignInView(viewModel: diContainer.resolve(SignInViewModel.self)!) ) navigation.viewControllers = [controller] present(navigation, animated: false) } private func showMainScreen() { - let controller = SwiftUIHostController(view: MainScreenView()) + let controller = UIHostingController(rootView: MainScreenView()) navigation.viewControllers = [controller] present(navigation, animated: false) } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 0e7e332cb..ba468cebd 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -57,13 +57,14 @@ public class Router: AuthorizationRouter, } public func showMainScreen() { - let controller = SwiftUIHostController(view: MainScreenView()) + showToolBar() + let controller = UIHostingController(rootView: MainScreenView()) navigationController.setViewControllers([controller], animated: true) } public func showLoginScreen() { let view = SignInView(viewModel: Container.shared.resolve(SignInViewModel.self)!) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.setViewControllers([controller], animated: false) } @@ -122,13 +123,13 @@ public class Router: AuthorizationRouter, public func showRegisterScreen() { let view = SignUpView(viewModel: Container.shared.resolve(SignUpViewModel.self)!) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } public func showForgotPasswordScreen() { let view = ResetPasswordView(viewModel: Container.shared.resolve(ResetPasswordViewModel.self)!) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } @@ -138,7 +139,7 @@ public class Router: AuthorizationRouter, courseID: courseID, title: title ) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } @@ -146,7 +147,7 @@ public class Router: AuthorizationRouter, let viewModel = Container.shared.resolve(SearchViewModel.self)! let view = SearchView(viewModel: viewModel) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushFade(viewController: controller) } @@ -154,7 +155,7 @@ public class Router: AuthorizationRouter, let viewModel = Container.shared.resolve(DiscussionSearchTopicsViewModel.self, argument: courseID)! let view = DiscussionSearchTopicsView(viewModel: viewModel) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushFade(viewController: controller) } @@ -179,7 +180,7 @@ public class Router: AuthorizationRouter, courseID: courseID, viewModel: viewModel ) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } @@ -206,7 +207,7 @@ public class Router: AuthorizationRouter, title: title ) - let controller = SwiftUIHostController(view: screensView) + let controller = UIHostingController(rootView: screensView) navigationController.pushViewController(controller, animated: true) } @@ -222,7 +223,7 @@ public class Router: AuthorizationRouter, router: router, cssInjector: cssInjector ) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } @@ -247,7 +248,7 @@ public class Router: AuthorizationRouter, verticalIndex )! let view = CourseUnitView(viewModel: viewModel, sectionName: sectionName) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } @@ -275,7 +276,7 @@ public class Router: AuthorizationRouter, courseID: courseID, viewModel: vmVertical ) - let controllerVertical = SwiftUIHostController(view: viewVertical) + let controllerVertical = UIHostingController(rootView: viewVertical) let viewModel = Container.shared.resolve( CourseUnitViewModel.self, @@ -288,7 +289,7 @@ public class Router: AuthorizationRouter, verticalIndex )! let view = CourseUnitView(viewModel: viewModel, sectionName: sectionName) - let controllerUnit = SwiftUIHostController(view: view) + let controllerUnit = UIHostingController(rootView: view) var controllers = navigationController.viewControllers controllers.removeLast(2) controllers.append(contentsOf: [controllerVertical, controllerUnit]) @@ -307,14 +308,14 @@ public class Router: AuthorizationRouter, viewModel: viewModel, router: router ) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } public func showThread(thread: UserThread, postStateSubject: CurrentValueSubject) { let viewModel = Container.shared.resolve(ThreadViewModel.self, argument: postStateSubject)! let view = ThreadView(thread: thread, viewModel: viewModel) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } @@ -331,7 +332,7 @@ public class Router: AuthorizationRouter, router: router, parentComment: parentComment ) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } @@ -347,7 +348,7 @@ public class Router: AuthorizationRouter, courseID: courseID, onPostCreated: onPostCreated ) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } @@ -362,20 +363,20 @@ public class Router: AuthorizationRouter, avatar: avatar, profileDidEdit: profileDidEdit ) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } public func showSettings() { let viewModel = Container.shared.resolve(SettingsViewModel.self)! let view = SettingsView(viewModel: viewModel) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } public func showVideoQualityView(viewModel: SettingsViewModel) { let view = VideoQualityView(viewModel: viewModel) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } @@ -391,7 +392,7 @@ public class Router: AuthorizationRouter, let viewModel = Container.shared.resolve(DeleteAccountViewModel.self)! let view = DeleteAccountView(viewModel: viewModel) - let controller = SwiftUIHostController(view: view) + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } @@ -403,4 +404,8 @@ public class Router: AuthorizationRouter, hosting.modalPresentationStyle = .overFullScreen return hosting } + + private func showToolBar() { + self.navigationController.setNavigationBarHidden(false, animated: false) + } } diff --git a/OpenEdX/SwiftUIHostController.swift b/OpenEdX/SwiftUIHostController.swift deleted file mode 100644 index 922edce15..000000000 --- a/OpenEdX/SwiftUIHostController.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// SwiftUIHostController.swift -// OpenEdX -// -// Created by Vladimir Chekyrta on 13.09.2022. -// - -import UIKit -import SwiftUI -import Core - -public class SwiftUIHostController: UIViewController { - - private var innerView: InnerView - - public init(view: InnerView) { - self.innerView = view - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public override func viewDidLoad() { - super.viewDidLoad() - - let childView = UIHostingController(rootView: innerView) - addChild(childView) - view.addSubview(childView.view) - childView.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - childView.view.centerXAnchor.constraint(equalTo: view.centerXAnchor), - childView.view.centerYAnchor.constraint(equalTo: view.centerYAnchor), - childView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0), - childView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0), - childView.view.topAnchor.constraint(equalTo: view.topAnchor, constant: 0), - childView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0) - ]) - childView.didMove(toParent: self) - } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - DispatchQueue.main.async { - self.navigationController?.setNavigationBarHidden(true, animated: false) - } - } - -} diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 402896348..c27d7ea3f 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -16,6 +16,7 @@ import SwiftUIIntrospect struct MainScreenView: View { @State private var selection: MainTab = .discovery + @State private var settingsTapped: Bool = false enum MainTab { case discovery @@ -24,7 +25,7 @@ struct MainScreenView: View { case profile } - let analytics = Container.shared.resolve(MainScreenAnalytics.self)! + private let analytics = Container.shared.resolve(MainScreenAnalytics.self)! init() { UITabBar.appearance().isTranslucent = false @@ -44,8 +45,7 @@ struct MainScreenView: View { Text(CoreLocalization.Mainscreen.discovery) } .tag(MainTab.discovery) - .hideNavigationBar() - + VStack { DashboardView( viewModel: Container.shared.resolve(DashboardViewModel.self)!, @@ -57,7 +57,6 @@ struct MainScreenView: View { Text(CoreLocalization.Mainscreen.dashboard) } .tag(MainTab.dashboard) - .hideNavigationBar() VStack { Text(CoreLocalization.Mainscreen.inDeveloping) @@ -67,11 +66,10 @@ struct MainScreenView: View { Text(CoreLocalization.Mainscreen.programs) } .tag(MainTab.programs) - .hideNavigationBar() - + VStack { ProfileView( - viewModel: Container.shared.resolve(ProfileViewModel.self)! + viewModel: Container.shared.resolve(ProfileViewModel.self)!, settingsTapped: $settingsTapped ) } .tabItem { @@ -79,20 +77,49 @@ struct MainScreenView: View { Text(CoreLocalization.Mainscreen.profile) } .tag(MainTab.profile) - .hideNavigationBar() } - .onChange(of: selection, perform: { selection in - switch selection { - case .discovery: - analytics.mainDiscoveryTabClicked() - case .dashboard: - analytics.mainDashboardTabClicked() - case .programs: - analytics.mainProgramsTabClicked() - case .profile: - analytics.mainProfileTabClicked() + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(titleBar()) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing, content: { + if selection == .profile { + Button(action: { + settingsTapped.toggle() + }, label: { + CoreAssets.edit.swiftUIImage + .foregroundColor(Theme.Colors.textPrimary) + }) + } else { + VStack {} } }) + } + .onChange(of: selection, perform: { selection in + switch selection { + case .discovery: + analytics.mainDiscoveryTabClicked() + case .dashboard: + analytics.mainDashboardTabClicked() + case .programs: + analytics.mainProgramsTabClicked() + case .profile: + analytics.mainProfileTabClicked() + } + }) + } + + private func titleBar() -> String { + switch selection { + case .discovery: + return DiscoveryLocalization.title + case .dashboard: + return DashboardLocalization.title + case .programs: + return CoreLocalization.Mainscreen.programs + case .profile: + return ProfileLocalization.title + } } struct MainScreenView_Previews: PreviewProvider { diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 8c6b778af..88c5546a2 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -19,111 +19,104 @@ public struct DeleteAccountView: View { public var body: some View { ZStack(alignment: .top) { - - // MARK: - Page name - VStack(alignment: .center) { - NavigationBar( - title: ProfileLocalization.DeleteAccount.title, - leftButtonAction: { viewModel.router.back() } - ) - - .frameLimit() - - // MARK: - Page Body - ScrollView { - VStack { - Group { - CoreAssets.deleteAccount.swiftUIImage - .padding(.top, 50) - Text(ProfileLocalization.DeleteAccount.areYouSure) - .foregroundColor(Theme.Colors.textPrimary) - + Text(ProfileLocalization.DeleteAccount.wantToDelete) - .foregroundColor(Theme.Colors.alert) - }.multilineTextAlignment(.center) - .font(Theme.Fonts.headlineSmall) - - Text(ProfileLocalization.DeleteAccount.description) + // MARK: - Page Body + ScrollView { + VStack { + Group { + CoreAssets.deleteAccount.swiftUIImage + .padding(.top, 50) + Text(ProfileLocalization.DeleteAccount.areYouSure) + .foregroundColor(Theme.Colors.textPrimary) + + Text(ProfileLocalization.DeleteAccount.wantToDelete) + .foregroundColor(Theme.Colors.alert) + }.multilineTextAlignment(.center) + .font(Theme.Fonts.headlineSmall) + + Text(ProfileLocalization.DeleteAccount.description) + .foregroundColor(Theme.Colors.textSecondary) + .font(Theme.Fonts.labelLarge) + .multilineTextAlignment(.center) + .padding(.top, 16) + + // MARK: Password + Group { + Text(ProfileLocalization.DeleteAccount.password) .foregroundColor(Theme.Colors.textSecondary) .font(Theme.Fonts.labelLarge) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) .padding(.top, 16) - // MARK: Password - Group { - Text(ProfileLocalization.DeleteAccount.password) - .foregroundColor(Theme.Colors.textSecondary) - .font(Theme.Fonts.labelLarge) - .multilineTextAlignment(.leading) - .padding(.top, 16) - - HStack(spacing: 11) { - SecureField(ProfileLocalization.DeleteAccount.passwordDescription, - text: $viewModel.password) - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textPrimary) - } - .padding(.horizontal, 14) - .frame(minHeight: 48) - .frame(maxWidth: .infinity) - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(Theme.Colors.textInputUnfocusedStroke) - ) - Text(viewModel.incorrectPassword - ? ProfileLocalization.DeleteAccount.incorrectPassword - : " ") - .foregroundColor(Theme.Colors.alert) + HStack(spacing: 11) { + SecureField(ProfileLocalization.DeleteAccount.passwordDescription, + text: $viewModel.password) .font(Theme.Fonts.labelLarge) - .multilineTextAlignment(.leading) - .padding(.top, 0) - .shake($viewModel.incorrectPassword, - onCompletion: { viewModel.incorrectPassword.toggle() }) - - }.frame(minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading) - - // MARK: Comfirmation button - if viewModel.isShowProgress { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - .padding(.horizontal) - } else { - StyledButton(ProfileLocalization.DeleteAccount.comfirm, action: { - Task { - try await viewModel.deleteAccount(password: viewModel.password) - } - }, color: Theme.Colors.alert, - isActive: viewModel.password.count >= 2) - .padding(.top, 18) + .foregroundColor(Theme.Colors.textPrimary) } + .padding(.horizontal, 14) + .frame(minHeight: 48) + .frame(maxWidth: .infinity) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputUnfocusedStroke) + ) + Text(viewModel.incorrectPassword + ? ProfileLocalization.DeleteAccount.incorrectPassword + : " ") + .foregroundColor(Theme.Colors.alert) + .font(Theme.Fonts.labelLarge) + .multilineTextAlignment(.leading) + .padding(.top, 0) + .shake($viewModel.incorrectPassword, + onCompletion: { viewModel.incorrectPassword.toggle() }) - // MARK: Back to profile - Button(action: { - viewModel.router.back() - }, label: { - HStack(spacing: 9) { - CoreAssets.arrowRight16.swiftUIImage.renderingMode(.template) - .rotationEffect(Angle(degrees: 180)) - Text(ProfileLocalization.DeleteAccount.backToProfile) - .font(Theme.Fonts.labelLarge) + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + + // MARK: Comfirmation button + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + .padding(.horizontal) + } else { + StyledButton(ProfileLocalization.DeleteAccount.comfirm, action: { + Task { + try await viewModel.deleteAccount(password: viewModel.password) } - }) - .padding(.top, 35) - + }, color: Theme.Colors.alert, + isActive: viewModel.password.count >= 2) + .padding(.top, 18) } - }.padding(.horizontal, 24) - .frame(minHeight: 0, - maxHeight: .infinity, - alignment: .top) - .frameLimit(sizePortrait: 420) - - } + + // MARK: Back to profile + Button(action: { + viewModel.router.back() + }, label: { + HStack(spacing: 9) { + CoreAssets.arrowRight16.swiftUIImage.renderingMode(.template) + .rotationEffect(Angle(degrees: 180)) + Text(ProfileLocalization.DeleteAccount.backToProfile) + .font(Theme.Fonts.labelLarge) + } + }) + .padding(.top, 35) + + } + }.padding(.horizontal, 24) + .frame(minHeight: 0, + maxHeight: .infinity, + alignment: .top) + .frameLimit(sizePortrait: 420) + + .padding(.top, 8) + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(ProfileLocalization.DeleteAccount.title) // MARK: - Error Alert if viewModel.showError { diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index c9b1aaec3..3a1b79761 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -30,130 +30,105 @@ public struct EditProfileView: View { public var body: some View { ZStack(alignment: .top) { - - // MARK: - Page name - VStack(alignment: .center) { - NavigationBar( - title: ProfileLocalization.editProfile, - leftButtonAction: { - viewModel.backButtonTapped() - if viewModel.profileChanges.isAvatarSaved { - self.profileDidEdit((viewModel.editedProfile, viewModel.inputImage)) - } else { - self.profileDidEdit((viewModel.editedProfile, oldAvatar)) - } - }, - rightButtonType: .done, - rightButtonAction: { - if viewModel.isChanged { - Task { - viewModel.analytics.profileEditDoneClicked() - await viewModel.saveProfileUpdates() - } + // MARK: - Page Body + ScrollView { + VStack { + Text(viewModel.profileChanges.profileType.localizedValue.capitalized) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textSecondary) + Button(action: { + withAnimation { + showingBottomSheet.toggle() } - }, - rightButtonIsActive: $viewModel.isChanged - ) - - // MARK: - Page Body - ScrollView { - VStack { - Text(viewModel.profileChanges.profileType.localizedValue.capitalized) - .font(Theme.Fonts.titleSmall) - .foregroundColor(Theme.Colors.textSecondary) - Button(action: { - withAnimation { - showingBottomSheet.toggle() - } - }, label: { - UserAvatar(url: viewModel.userModel.avatarUrl, image: $viewModel.inputImage) - .padding(.top, 30) - .overlay( - ZStack { - Circle().frame(width: 36, height: 36) - .foregroundColor(Theme.Colors.accentColor) - CoreAssets.addPhoto.swiftUIImage - .foregroundColor(.white) - }.offset(x: 36, y: 50) - ) - }).disabled(!viewModel.isEditable) - - Text(viewModel.userModel.name) - .font(Theme.Fonts.headlineSmall) - - Button(ProfileLocalization.switchTo + " " + - viewModel.profileChanges.profileType.switchToButtonTitle, - action: { - viewModel.switchProfile() - }).padding(.vertical, 24) - .font(Theme.Fonts.labelLarge) - - Group { - PickerView( - config: viewModel.yearsConfiguration, - router: viewModel.router + }, label: { + UserAvatar(url: viewModel.userModel.avatarUrl, image: $viewModel.inputImage) + .padding(.top, 30) + .overlay( + ZStack { + Circle().frame(width: 36, height: 36) + .foregroundColor(Theme.Colors.accentColor) + CoreAssets.addPhoto.swiftUIImage + .foregroundColor(.white) + }.offset(x: 36, y: 50) ) - if viewModel.isEditable { - VStack(alignment: .leading) { - PickerView(config: viewModel.countriesConfiguration, - router: viewModel.router) - - PickerView(config: viewModel.spokenLanguageConfiguration, - router: viewModel.router) - - Text(ProfileLocalization.Edit.Fields.aboutMe) - .font(Theme.Fonts.titleMedium) - TextEditor(text: $viewModel.profileChanges.shortBiography) - .padding(.horizontal, 12) - .padding(.vertical, 4) - .frame(height: 200) - .hideScrollContentBackground() - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill( - Theme.Colors.textInputStroke - ) - ) - } + }).disabled(!viewModel.isEditable) + + Text(viewModel.userModel.name) + .font(Theme.Fonts.headlineSmall) + + Button(ProfileLocalization.switchTo + " " + + viewModel.profileChanges.profileType.switchToButtonTitle, + action: { + viewModel.switchProfile() + }).padding(.vertical, 24) + .font(Theme.Fonts.labelLarge) + + Group { + PickerView( + config: viewModel.yearsConfiguration, + router: viewModel.router + ) + if viewModel.isEditable { + VStack(alignment: .leading) { + PickerView(config: viewModel.countriesConfiguration, + router: viewModel.router) + + PickerView(config: viewModel.spokenLanguageConfiguration, + router: viewModel.router) + + Text(ProfileLocalization.Edit.Fields.aboutMe) + .font(Theme.Fonts.titleMedium) + TextEditor(text: $viewModel.profileChanges.shortBiography) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .frame(height: 200) + .hideScrollContentBackground() + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill( + Theme.Colors.textInputStroke + ) + ) } } - .onReceive(viewModel.yearsConfiguration.$text - .combineLatest(viewModel.countriesConfiguration.$text, - viewModel.spokenLanguageConfiguration.$text), - perform: { _ in - viewModel.checkChanges() - viewModel.checkProfileType() - }) - .onChange(of: viewModel.profileChanges) { _ in - viewModel.checkChanges() - viewModel.checkProfileType() - } - .onChange(of: viewModel.profileChanges.shortBiography, perform: { bio in - if bio.count > 300 { - viewModel.profileChanges.shortBiography.removeLast() - } - }) - - Button(ProfileLocalization.Edit.deleteAccount, action: { - viewModel.analytics.profileDeleteAccountClicked() - viewModel.router.showDeleteProfileView() - }) - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.alert) - .padding(.top, 44) - - Spacer(minLength: 84) - }.padding(.horizontal, 24) - .sheet(isPresented: $showingImagePicker) { - ImagePickerView(image: $viewModel.inputImage) - .ignoresSafeArea() + } + .onReceive(viewModel.yearsConfiguration.$text + .combineLatest(viewModel.countriesConfiguration.$text, + viewModel.spokenLanguageConfiguration.$text), + perform: { _ in + viewModel.checkChanges() + viewModel.checkProfileType() + }) + .onChange(of: viewModel.profileChanges) { _ in + viewModel.checkChanges() + viewModel.checkProfileType() + } + .onChange(of: viewModel.profileChanges.shortBiography, perform: { bio in + if bio.count > 300 { + viewModel.profileChanges.shortBiography.removeLast() } - } + }) + + Button(ProfileLocalization.Edit.deleteAccount, action: { + viewModel.trackProfileDeleteAccountClicked() + viewModel.router.showDeleteProfileView() + }) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.alert) + .padding(.top, 44) + + Spacer(minLength: 84) + }.padding(.horizontal, 24) + .sheet(isPresented: $showingImagePicker) { + ImagePickerView(image: $viewModel.inputImage) + .ignoresSafeArea() + } + }.padding(.top, 8) .onChange(of: showingImagePicker, perform: { value in if !value { if let image = viewModel.inputImage { @@ -170,10 +145,9 @@ public struct EditProfileView: View { self.profileDidEdit((viewModel.editedProfile, oldAvatar)) } } - .scrollAvoidKeyboard(dismissKeyboardByTap: true) .frameLimit(sizePortrait: 420) - }.ignoresSafeArea(edges: .bottom) + .ignoresSafeArea(edges: .bottom) // MARK: - Error Alert if viewModel.showError { VStack { @@ -224,24 +198,55 @@ public struct EditProfileView: View { .padding(.horizontal) } } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(ProfileLocalization.editProfile) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing, content: { + Button(action: { + if viewModel.isChanged { + Task { + viewModel.trackProfileEditDoneClicked() + await viewModel.saveProfileUpdates() + } + } + }, label: { + HStack(spacing: 2) { + CoreAssets.done.swiftUIImage + Text(CoreLocalization.done) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentColor) + } + }).opacity(viewModel.isChanged ? 1 : 0.3) + }) + } .background( Theme.Colors.background .ignoresSafeArea() ) + .onDisappear { + if viewModel.profileChanges.isAvatarSaved { + self.profileDidEdit((viewModel.editedProfile, viewModel.inputImage)) + } else { + self.profileDidEdit((viewModel.editedProfile, oldAvatar)) + } + } } } #if DEBUG struct EditProfileView_Previews: PreviewProvider { static var previews: some View { - let userModel = UserProfile(avatarUrl: "", - name: "Peter Parket", - username: "Peter", - dateJoined: Date(), - yearOfBirth: 0, - country: "Ukraine", - shortBiography: "", - isFullProfile: true) + let userModel = UserProfile( + avatarUrl: "", + name: "Peter Parket", + username: "Peter", + dateJoined: Date(), + yearOfBirth: 0, + country: "Ukraine", + shortBiography: "", + isFullProfile: true + ) EditProfileView( viewModel: EditProfileViewModel( diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift index ab76a9d76..daa72ab39 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift @@ -75,14 +75,17 @@ public class EditProfileViewModel: ObservableObject { } } - private let interactor: ProfileInteractorProtocol let router: ProfileRouter - let analytics: ProfileAnalytics - public init(userModel: UserProfile, - interactor: ProfileInteractorProtocol, - router: ProfileRouter, - analytics: ProfileAnalytics) { + private let interactor: ProfileInteractorProtocol + private let analytics: ProfileAnalytics + + public init( + userModel: UserProfile, + interactor: ProfileInteractorProtocol, + router: ProfileRouter, + analytics: ProfileAnalytics + ) { self.userModel = userModel self.interactor = interactor self.router = router @@ -136,12 +139,12 @@ public class EditProfileViewModel: ObservableObject { withAnimation(.easeIn(duration: 0.1)) { self.isChanged = [spokenLanguageConfiguration.text.isEmpty ? false : spokenLanguageConfiguration.text != userModel.spokenLanguage, - yearsConfiguration.text.isEmpty ? false : yearsConfiguration.text != String(userModel.yearOfBirth), - countriesConfiguration.text.isEmpty ? false : countriesConfiguration.text != userModel.country, - userModel.shortBiography != profileChanges.shortBiography, - profileChanges.isAvatarChanged, - profileChanges.isAvatarDeleted, - userModel.isFullProfile != profileChanges.profileType.boolValue].contains(where: { $0 == true }) + yearsConfiguration.text.isEmpty ? false : yearsConfiguration.text != String(userModel.yearOfBirth), + countriesConfiguration.text.isEmpty ? false : countriesConfiguration.text != userModel.country, + userModel.shortBiography != profileChanges.shortBiography, + profileChanges.isAvatarChanged, + profileChanges.isAvatarDeleted, + userModel.isFullProfile != profileChanges.profileType.boolValue].contains(where: { $0 == true }) } } @@ -341,4 +344,12 @@ public class EditProfileViewModel: ObservableObject { profileChanges.shortBiography = userModel.shortBiography } + + func trackProfileDeleteAccountClicked() { + analytics.profileDeleteAccountClicked() + } + + func trackProfileEditDoneClicked() { + analytics.profileEditDoneClicked() + } } diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index da4a4c05e..e1b961bf5 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -11,25 +11,189 @@ import Kingfisher public struct ProfileView: View { - @ObservedObject private var viewModel: ProfileViewModel + @StateObject private var viewModel: ProfileViewModel + @Binding var settingsTapped: Bool - public init(viewModel: ProfileViewModel) { - self.viewModel = viewModel - Task { - await viewModel.getMyProfile() - } + public init(viewModel: ProfileViewModel, settingsTapped: Binding) { + self._viewModel = StateObject(wrappedValue: { viewModel }()) + self._settingsTapped = settingsTapped } public var body: some View { ZStack(alignment: .top) { - - // MARK: - Page name - VStack(alignment: .center) { - NavigationBar(title: ProfileLocalization.title, - rightButtonType: .edit, - rightButtonAction: { + // MARK: - Page Body + RefreshableScrollViewCompat(action: { + await viewModel.getMyProfile(withProgress: isIOS14) + }) { + VStack { + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + } else { + UserAvatar(url: viewModel.userModel?.avatarUrl ?? "", image: $viewModel.updatedAvatar) + .padding(.top, 30) + Text(viewModel.userModel?.name ?? "") + .font(Theme.Fonts.headlineSmall) + .padding(.top, 20) + + Text("@\(viewModel.userModel?.username ?? "")") + .font(Theme.Fonts.labelLarge) + .padding(.top, 4) + .foregroundColor(Theme.Colors.textSecondary) + .padding(.bottom, 10) + + // MARK: - Profile Info + if viewModel.userModel?.yearOfBirth != 0 || viewModel.userModel?.shortBiography != "" { + VStack(alignment: .leading, spacing: 14) { + Text(ProfileLocalization.info) + .padding(.horizontal, 24) + .font(Theme.Fonts.labelLarge) + + VStack(alignment: .leading, spacing: 16) { + if viewModel.userModel?.yearOfBirth != 0 { + HStack { + Text(ProfileLocalization.Edit.Fields.yearOfBirth) + .foregroundColor(Theme.Colors.textSecondary) + Text(String(viewModel.userModel?.yearOfBirth ?? 0)) + } + } + if let bio = viewModel.userModel?.shortBiography, bio != "" { + HStack(alignment: .top) { + Text(ProfileLocalization.bio + " ") + .foregroundColor(Theme.Colors.textSecondary) + + Text(bio) + } + } + } + .cardStyle( + bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear + ) + }.padding(.bottom, 16) + } + + VStack(alignment: .leading, spacing: 14) { + // MARK: - Settings + Text(ProfileLocalization.settings) + .padding(.horizontal, 24) + .font(Theme.Fonts.labelLarge) + VStack(alignment: .leading, spacing: 27) { + HStack { + Button(action: { + viewModel.trackProfileVideoSettingsClicked() + viewModel.router.showSettings() + }, label: { + Text(ProfileLocalization.settingsVideo) + Spacer() + Image(systemName: "chevron.right") + }) + } + }.cardStyle( + bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear + ) + + // MARK: - Support info + Text(ProfileLocalization.supportInfo) + .padding(.horizontal, 24) + .font(Theme.Fonts.labelLarge) + VStack(alignment: .leading, spacing: 24) { + if let support = viewModel.contactSupport() { + Button(action: { + viewModel.trackEmailSupportClicked() + UIApplication.shared.open(support) + }, label: { + HStack { + Text(ProfileLocalization.contact) + Spacer() + Image(systemName: "chevron.right") + } + }) + .buttonStyle(PlainButtonStyle()) + .foregroundColor(.primary) + Rectangle() + .frame(height: 1) + .foregroundColor(Theme.Colors.textSecondary) + } + + if let tos = viewModel.config.termsOfUse { + Button(action: { + viewModel.trackCookiePolicyClicked() + UIApplication.shared.open(tos) + }, label: { + HStack { + Text(ProfileLocalization.terms) + Spacer() + Image(systemName: "chevron.right") + } + }) + .buttonStyle(PlainButtonStyle()) + .foregroundColor(.primary) + Rectangle() + .frame(height: 1) + .foregroundColor(Theme.Colors.textSecondary) + } + + if let privacy = viewModel.config.privacyPolicy { + Button(action: { + viewModel.trackPrivacyPolicyClicked() + UIApplication.shared.open(privacy) + }, label: { + HStack { + Text(ProfileLocalization.privacy) + Spacer() + Image(systemName: "chevron.right") + } + }) + .buttonStyle(PlainButtonStyle()) + .foregroundColor(.primary) + } + }.cardStyle( + bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear + ) + + // MARK: - Log out + VStack { + HStack { + Button(action: { + viewModel.router.presentView(transitionStyle: .crossDissolve) { + AlertView( + alertTitle: ProfileLocalization.LogoutAlert.title, + alertMessage: ProfileLocalization.LogoutAlert.text, + positiveAction: CoreLocalization.Alert.accept, + onCloseTapped: { + viewModel.router.dismiss(animated: true) + }, + okTapped: { + viewModel.router.dismiss(animated: true) + Task { + await viewModel.logOut() + } + }, type: .logOut + ) + } + }, label: { + Text(ProfileLocalization.logout) + Spacer() + Image(systemName: "rectangle.portrait.and.arrow.right") + }) + } + }.foregroundColor(Theme.Colors.alert) + .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear) + .padding(.top, 24) + .padding(.bottom, 60) + } + Spacer() + } + } + }.frameLimit(sizePortrait: 420) + .padding(.top, 8) + .onChange(of: settingsTapped, perform: { _ in if let userModel = viewModel.userModel { - viewModel.analytics.profileEditClicked() + viewModel.trackProfileEditClicked() viewModel.router.showEditProfile( userModel: userModel, avatar: viewModel.updatedAvatar, @@ -43,181 +207,9 @@ public struct ProfileView: View { } ) } - }, rightButtonIsActive: .constant(viewModel.connectivity.isInternetAvaliable)) - - // MARK: - Page Body - - RefreshableScrollViewCompat(action: { - await viewModel.getMyProfile(withProgress: isIOS14) - }) { - VStack { - if viewModel.isShowProgress { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 200) - .padding(.horizontal) - } else { - UserAvatar(url: viewModel.userModel?.avatarUrl ?? "", image: $viewModel.updatedAvatar) - .padding(.top, 30) - Text(viewModel.userModel?.name ?? "") - .font(Theme.Fonts.headlineSmall) - .padding(.top, 20) - - Text("@\(viewModel.userModel?.username ?? "")") - .font(Theme.Fonts.labelLarge) - .padding(.top, 4) - .foregroundColor(Theme.Colors.textSecondary) - .padding(.bottom, 10) - - // MARK: - Profile Info - if viewModel.userModel?.yearOfBirth != 0 || viewModel.userModel?.shortBiography != "" { - VStack(alignment: .leading, spacing: 14) { - Text(ProfileLocalization.info) - .padding(.horizontal, 24) - .font(Theme.Fonts.labelLarge) - - VStack(alignment: .leading, spacing: 16) { - if viewModel.userModel?.yearOfBirth != 0 { - HStack { - Text(ProfileLocalization.Edit.Fields.yearOfBirth) - .foregroundColor(Theme.Colors.textSecondary) - Text(String(viewModel.userModel?.yearOfBirth ?? 0)) - } - } - if let bio = viewModel.userModel?.shortBiography, bio != "" { - HStack(alignment: .top) { - Text(ProfileLocalization.bio + " ") - .foregroundColor(Theme.Colors.textSecondary) - + Text(bio) - } - } - } - .cardStyle( - bgColor: Theme.Colors.textInputUnfocusedBackground, - strokeColor: .clear - ) - }.padding(.bottom, 16) - } - - VStack(alignment: .leading, spacing: 14) { - // MARK: - Settings - Text(ProfileLocalization.settings) - .padding(.horizontal, 24) - .font(Theme.Fonts.labelLarge) - VStack(alignment: .leading, spacing: 27) { - HStack { - Button(action: { - viewModel.analytics.profileVideoSettingsClicked() - viewModel.router.showSettings() - }, label: { - Text(ProfileLocalization.settingsVideo) - Spacer() - Image(systemName: "chevron.right") - }) - } - }.cardStyle( - bgColor: Theme.Colors.textInputUnfocusedBackground, - strokeColor: .clear - ) - - // MARK: - Support info - Text(ProfileLocalization.supportInfo) - .padding(.horizontal, 24) - .font(Theme.Fonts.labelLarge) - VStack(alignment: .leading, spacing: 24) { - if let support = viewModel.contactSupport() { - Button(action: { - viewModel.analytics.emailSupportClicked() - UIApplication.shared.open(support) - }, label: { - HStack { - Text(ProfileLocalization.contact) - Spacer() - Image(systemName: "chevron.right") - } - }) - .buttonStyle(PlainButtonStyle()) - .foregroundColor(.primary) - Rectangle() - .frame(height: 1) - .foregroundColor(Theme.Colors.textSecondary) - } - - if let tos = viewModel.config.termsOfUse { - Button(action: { - viewModel.analytics.cookiePolicyClicked() - UIApplication.shared.open(tos) - }, label: { - HStack { - Text(ProfileLocalization.terms) - Spacer() - Image(systemName: "chevron.right") - } - }) - .buttonStyle(PlainButtonStyle()) - .foregroundColor(.primary) - Rectangle() - .frame(height: 1) - .foregroundColor(Theme.Colors.textSecondary) - } - - if let privacy = viewModel.config.privacyPolicy { - Button(action: { - viewModel.analytics.privacyPolicyClicked() - UIApplication.shared.open(privacy) - }, label: { - HStack { - Text(ProfileLocalization.privacy) - Spacer() - Image(systemName: "chevron.right") - } - }) - .buttonStyle(PlainButtonStyle()) - .foregroundColor(.primary) - } - }.cardStyle( - bgColor: Theme.Colors.textInputUnfocusedBackground, - strokeColor: .clear - ) - - // MARK: - Log out - VStack { - HStack { - Button(action: { - viewModel.router.presentView(transitionStyle: .crossDissolve) { - AlertView( - alertTitle: ProfileLocalization.LogoutAlert.title, - alertMessage: ProfileLocalization.LogoutAlert.text, - positiveAction: CoreLocalization.Alert.accept, - onCloseTapped: { - viewModel.router.dismiss(animated: true) - }, - okTapped: { - Task { - viewModel.analytics.userLogout(force: false) - await viewModel.logOut() - } - viewModel.router.dismiss(animated: true) - }, type: .logOut - ) - } - }, label: { - Text(ProfileLocalization.logout) - Spacer() - Image(systemName: "rectangle.portrait.and.arrow.right") - }) - } - }.foregroundColor(Theme.Colors.alert) - .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, - strokeColor: .clear) - .padding(.top, 24) - .padding(.bottom, 60) - } - Spacer() - } - } - }.frameLimit(sizePortrait: 420) - - } + }) + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) // MARK: - Offline mode SnackBar OfflineSnackBarView(connectivity: viewModel.connectivity, @@ -241,6 +233,11 @@ public struct ProfileView: View { } } } + .onFirstAppear { + Task { + await viewModel.getMyProfile() + } + } .background( Theme.Colors.background .ignoresSafeArea() @@ -258,12 +255,12 @@ struct ProfileView_Previews: PreviewProvider { config: ConfigMock(), connectivity: Connectivity()) - ProfileView(viewModel: vm) + ProfileView(viewModel: vm, settingsTapped: .constant(false)) .preferredColorScheme(.light) .previewDisplayName("DiscoveryView Light") .loadFonts() - ProfileView(viewModel: vm) + ProfileView(viewModel: vm, settingsTapped: .constant(false)) .preferredColorScheme(.dark) .previewDisplayName("DiscoveryView Dark") .loadFonts() diff --git a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift index 8439adbc2..49e5dd254 100644 --- a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift @@ -23,17 +23,21 @@ public class ProfileViewModel: ObservableObject { } } - private let interactor: ProfileInteractorProtocol + let router: ProfileRouter - let analytics: ProfileAnalytics let config: Config let connectivity: ConnectivityProtocol - public init(interactor: ProfileInteractorProtocol, - router: ProfileRouter, - analytics: ProfileAnalytics, - config: Config, - connectivity: ConnectivityProtocol) { + private let interactor: ProfileInteractorProtocol + private let analytics: ProfileAnalytics + + public init( + interactor: ProfileInteractorProtocol, + router: ProfileRouter, + analytics: ProfileAnalytics, + config: Config, + connectivity: ConnectivityProtocol + ) { self.interactor = interactor self.router = router self.analytics = analytics @@ -79,8 +83,9 @@ public class ProfileViewModel: ObservableObject { @MainActor func logOut() async { do { - try await self.interactor.logOut() - self.router.showLoginScreen() + try await interactor.logOut() + router.showLoginScreen() + analytics.userLogout(force: false) } catch let error { if error.isInternetError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection @@ -89,4 +94,24 @@ public class ProfileViewModel: ObservableObject { } } } + + func trackProfileVideoSettingsClicked() { + analytics.profileVideoSettingsClicked() + } + + func trackEmailSupportClicked() { + analytics.emailSupportClicked() + } + + func trackCookiePolicyClicked() { + analytics.cookiePolicyClicked() + } + + func trackPrivacyPolicyClicked() { + analytics.privacyPolicyClicked() + } + + func trackProfileEditClicked() { + analytics.profileEditClicked() + } } diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index b414939ea..1e940716f 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -21,53 +21,47 @@ public struct SettingsView: View { public var body: some View { ZStack(alignment: .top) { - // MARK: - Page name - VStack(alignment: .center) { - NavigationBar(title: ProfileLocalization.Settings.videoSettingsTitle, - leftButtonAction: { viewModel.router.back() }) - - // MARK: - Page Body - - ScrollView { - VStack(alignment: .leading, spacing: 24) { - if viewModel.isShowProgress { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 200) - .padding(.horizontal) - } else { - // MARK: Wi-fi - HStack { - SettingsCell( - title: ProfileLocalization.Settings.wifiTitle, - description: ProfileLocalization.Settings.wifiDescription - ) - Toggle(isOn: $viewModel.wifiOnly, label: {}) - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - .frame(width: 50) - }.foregroundColor(Theme.Colors.textPrimary) - Divider() - - // MARK: Download Quality - HStack { - Button(action: { - viewModel.router.showVideoQualityView(viewModel: viewModel) - }, label: { - SettingsCell(title: ProfileLocalization.Settings.videoQualityTitle, - description: viewModel.selectedQuality.settingsDescription()) - }) - // Spacer() - Image(systemName: "chevron.right") - .padding(.trailing, 12) - .frame(width: 10) - } - Divider() + // MARK: - Page Body + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + } else { + // MARK: Wi-fi + HStack { + SettingsCell( + title: ProfileLocalization.Settings.wifiTitle, + description: ProfileLocalization.Settings.wifiDescription + ) + Toggle(isOn: $viewModel.wifiOnly, label: {}) + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .frame(width: 50) + }.foregroundColor(Theme.Colors.textPrimary) + Divider() + + // MARK: Download Quality + HStack { + Button(action: { + viewModel.router.showVideoQualityView(viewModel: viewModel) + }, label: { + SettingsCell(title: ProfileLocalization.Settings.videoQualityTitle, + description: viewModel.selectedQuality.settingsDescription()) + }) + // Spacer() + Image(systemName: "chevron.right") + .padding(.trailing, 12) + .frame(width: 10) } - }.frame(minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading) - .padding(.horizontal, 24) - }.frameLimit(sizePortrait: 420) - } + Divider() + } + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + .padding(.horizontal, 24) + }.frameLimit(sizePortrait: 420) + .padding(.top, 8) // MARK: - Error Alert if viewModel.showError { @@ -83,6 +77,9 @@ public struct SettingsView: View { } } } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(ProfileLocalization.Settings.videoSettingsTitle) .background( Theme.Colors.background .ignoresSafeArea() diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index 0b4e0e2a9..a82c67747 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -20,50 +20,41 @@ public struct VideoQualityView: View { public var body: some View { ZStack(alignment: .top) { - - // MARK: - Page name - VStack(alignment: .center) { - NavigationBar(title: ProfileLocalization.Settings.videoQualityTitle, - leftButtonAction: { viewModel.router.back() }) - - // MARK: - Page Body - - ScrollView { - VStack(alignment: .leading, spacing: 24) { - if viewModel.isShowProgress { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 200) - .padding(.horizontal) - } else { - - ForEach(viewModel.quality, id: \.offset) { _, quality in - Button(action: { - viewModel.selectedQuality = quality - }, label: { - HStack { - SettingsCell( - title: quality.title(), - description: quality.description() - ) - Spacer() - CoreAssets.checkmark.swiftUIImage - .renderingMode(.template) - .foregroundColor(.accentColor) - .opacity(quality == viewModel.selectedQuality ? 1 : 0) - - }.foregroundColor(Theme.Colors.textPrimary) - }) - Divider() - } - + // MARK: - Page Body + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + } else { + + ForEach(viewModel.quality, id: \.offset) { _, quality in + Button(action: { + viewModel.selectedQuality = quality + }, label: { + HStack { + SettingsCell( + title: quality.title(), + description: quality.description() + ) + Spacer() + CoreAssets.checkmark.swiftUIImage + .renderingMode(.template) + .foregroundColor(.accentColor) + .opacity(quality == viewModel.selectedQuality ? 1 : 0) + + }.foregroundColor(Theme.Colors.textPrimary) + }) + Divider() } - }.frame(minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading) - .padding(.horizontal, 24) - }.frameLimit(sizePortrait: 420) - - } + } + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + .padding(.horizontal, 24) + }.frameLimit(sizePortrait: 420) + .padding(.top, 8) // MARK: - Error Alert if viewModel.showError { @@ -79,6 +70,9 @@ public struct VideoQualityView: View { } } } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(ProfileLocalization.Settings.videoQualityTitle) .background( Theme.Colors.background .ignoresSafeArea() diff --git a/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift index 47280627c..2980fb4a8 100644 --- a/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift @@ -761,4 +761,66 @@ final class EditProfileViewModelTests: XCTestCase { Verify(interactor, 1, .getSpokenLanguages()) Verify(interactor, 1, .getCountries()) } + + func testTrackProfileDeleteAccountClicked() { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let userModel = UserProfile( + avatarUrl: "url", + name: "Test", + username: "Name", + dateJoined: Date(), + yearOfBirth: 1986, + country: "UA", + spokenLanguage: "UA", + shortBiography: "Bio", + isFullProfile: false + ) + + Given(interactor, .getSpokenLanguages(willReturn: [])) + Given(interactor, .getCountries(willReturn: [])) + + let viewModel = EditProfileViewModel( + userModel: userModel, + interactor: interactor, + router: router, + analytics: analytics + ) + + viewModel.trackProfileDeleteAccountClicked() + + Verify(analytics, 1, .profileDeleteAccountClicked()) + } + + func testTrackProfileEditDoneClicked() { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let userModel = UserProfile( + avatarUrl: "url", + name: "Test", + username: "Name", + dateJoined: Date(), + yearOfBirth: 1986, + country: "UA", + spokenLanguage: "UA", + shortBiography: "Bio", + isFullProfile: false + ) + + Given(interactor, .getSpokenLanguages(willReturn: [])) + Given(interactor, .getCountries(willReturn: [])) + + let viewModel = EditProfileViewModel( + userModel: userModel, + interactor: interactor, + router: router, + analytics: analytics + ) + + viewModel.trackProfileEditDoneClicked() + + Verify(analytics, 1, .profileEditDoneClicked()) + } } diff --git a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift index 6c6502921..ddc0f356f 100644 --- a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift @@ -19,21 +19,24 @@ final class ProfileViewModelTests: XCTestCase { let router = ProfileRouterMock() let analytics = ProfileAnalyticsMock() let connectivity = ConnectivityProtocolMock() - - let viewModel = ProfileViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity) - - let user = UserProfile(avatarUrl: "", - name: "Steve", - username: "Steve", - dateJoined: Date(), - yearOfBirth: 2000, - country: "Ua", - shortBiography: "Bio", - isFullProfile: false) + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) + + let user = UserProfile( + avatarUrl: "", + name: "Steve", + username: "Steve", + dateJoined: Date(), + yearOfBirth: 2000, + country: "Ua", + shortBiography: "Bio", + isFullProfile: false + ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .getMyProfile(willReturn: user)) @@ -53,21 +56,24 @@ final class ProfileViewModelTests: XCTestCase { let router = ProfileRouterMock() let analytics = ProfileAnalyticsMock() let connectivity = ConnectivityProtocolMock() - - let viewModel = ProfileViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity) - - let user = UserProfile(avatarUrl: "", - name: "Steve", - username: "Steve", - dateJoined: Date(), - yearOfBirth: 2000, - country: "Ua", - shortBiography: "Bio", - isFullProfile: false) + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) + + let user = UserProfile( + avatarUrl: "", + name: "Steve", + username: "Steve", + dateJoined: Date(), + yearOfBirth: 2000, + country: "Ua", + shortBiography: "Bio", + isFullProfile: false + ) Given(connectivity, .isInternetAvaliable(getter: false)) Given(interactor, .getMyProfileOffline(willReturn: user)) @@ -87,12 +93,13 @@ final class ProfileViewModelTests: XCTestCase { let router = ProfileRouterMock() let analytics = ProfileAnalyticsMock() let connectivity = ConnectivityProtocolMock() - - let viewModel = ProfileViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity) + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -113,12 +120,13 @@ final class ProfileViewModelTests: XCTestCase { let router = ProfileRouterMock() let analytics = ProfileAnalyticsMock() let connectivity = ConnectivityProtocolMock() - - let viewModel = ProfileViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity) + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .getMyProfile(willThrow: NoCachedDataError())) @@ -137,12 +145,13 @@ final class ProfileViewModelTests: XCTestCase { let router = ProfileRouterMock() let analytics = ProfileAnalyticsMock() let connectivity = ConnectivityProtocolMock() - - let viewModel = ProfileViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity) + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .getMyProfile(willThrow: NSError())) @@ -161,12 +170,13 @@ final class ProfileViewModelTests: XCTestCase { let router = ProfileRouterMock() let analytics = ProfileAnalyticsMock() let connectivity = ConnectivityProtocolMock() - - let viewModel = ProfileViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity) + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .logOut(willProduce: {_ in})) @@ -182,12 +192,13 @@ final class ProfileViewModelTests: XCTestCase { let router = ProfileRouterMock() let analytics = ProfileAnalyticsMock() let connectivity = ConnectivityProtocolMock() - - let viewModel = ProfileViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity) + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -205,12 +216,13 @@ final class ProfileViewModelTests: XCTestCase { let router = ProfileRouterMock() let analytics = ProfileAnalyticsMock() let connectivity = ConnectivityProtocolMock() - - let viewModel = ProfileViewModel(interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity) + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .logOut(willThrow: NSError())) @@ -220,5 +232,95 @@ final class ProfileViewModelTests: XCTestCase { XCTAssertTrue(viewModel.showError) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) } + + func testTrackProfileVideoSettingsClicked() { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let connectivity = ConnectivityProtocolMock() + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) + + viewModel.trackProfileVideoSettingsClicked() + + Verify(analytics, 1, .profileVideoSettingsClicked()) + } + + func testTrackEmailSupportClicked() { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let connectivity = ConnectivityProtocolMock() + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) + + viewModel.trackEmailSupportClicked() + + Verify(analytics, 1, .emailSupportClicked()) + } + + func testTrackCookiePolicyClicked() { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let connectivity = ConnectivityProtocolMock() + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) + + viewModel.trackCookiePolicyClicked() + + Verify(analytics, 1, .cookiePolicyClicked()) + } + + func testTrackPrivacyPolicyClicked() { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let connectivity = ConnectivityProtocolMock() + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) + + viewModel.trackPrivacyPolicyClicked() + + Verify(analytics, 1, .privacyPolicyClicked()) + } + + func testTrackProfileEditClicked() { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let connectivity = ConnectivityProtocolMock() + let viewModel = ProfileViewModel( + interactor: interactor, + router: router, + analytics: analytics, + config: ConfigMock(), + connectivity: connectivity + ) + + viewModel.trackProfileEditClicked() + + Verify(analytics, 1, .profileEditClicked()) + } } From 630f9b70b4a7a46949c1156cf32f5c3e049f203d Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 21 Aug 2023 13:59:07 +0300 Subject: [PATCH 08/19] disable routing whenever handouts is empty (#66) --- Course/Course/Presentation/Handouts/HandoutsView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index a1eda50b6..81cd06f6a 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -37,8 +37,9 @@ struct HandoutsView: View { } else { VStack(alignment: .leading) { HandoutsItemCell(type: .handouts, onTapAction: { + guard let handouts = viewModel.handouts else { return } viewModel.router.showHandoutsUpdatesView( - handouts: viewModel.handouts, + handouts: handouts, announcements: nil, router: viewModel.router, cssInjector: viewModel.cssInjector) From aedd05af42dc314fdf271a9cc0bb86da4caf7728 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 25 Aug 2023 11:17:39 +0300 Subject: [PATCH 09/19] Fix the isMobileData variable in the Connectivity module. (#67) Add the possibility to play videos with sound in silent mode. --- Core/Core/Configuration/Connectivity.swift | 2 +- .../Course/Presentation/Video/PlayerViewController.swift | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index b825a9ddb..c1ea53d82 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -30,7 +30,7 @@ public class Connectivity: ConnectivityProtocol { public var isMobileData: Bool { if let networkManager { - return !networkManager.isReachableOnCellular && networkManager.isReachableOnCellular + return networkManager.isReachableOnCellular } else { return false } diff --git a/Course/Course/Presentation/Video/PlayerViewController.swift b/Course/Course/Presentation/Video/PlayerViewController.swift index ef856ff04..01a27e640 100644 --- a/Course/Course/Presentation/Video/PlayerViewController.swift +++ b/Course/Course/Presentation/Video/PlayerViewController.swift @@ -38,6 +38,13 @@ struct PlayerViewController: UIViewControllerRepresentable { self.seconds(seconds) } ) + + do { + try AVAudioSession.sharedInstance().setCategory(.playback) + } catch { + print(error.localizedDescription) + } + return controller } From 033ba4481b21b9600b153f20801e434794f830a5 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 25 Aug 2023 12:10:43 +0300 Subject: [PATCH 10/19] encoded video dissmiss by device rotation (#68) * fixed --- .../Presentation/Unit/CourseUnitView.swift | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 9b4807632..1d9637440 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -25,6 +25,7 @@ public struct CourseUnitView: View { } @State var offsetView: CGFloat = 0 @State var showDiscussion: Bool = false + @Environment(\.presentationMode) private var presentationMode private let sectionName: String public let playerStateSubject = CurrentValueSubject(nil) @@ -112,19 +113,19 @@ public struct CourseUnitView: View { .id(index) } } - .offset(y: offsetView) - .clipped() - .onChange(of: viewModel.index, perform: { index in - DispatchQueue.main.async { - withAnimation(Animation.easeInOut(duration: 0.2)) { - offsetView = -(reader.size.height * CGFloat(index)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - showDiscussion = viewModel.selectedLesson().type == .discussion - } + .offset(y: offsetView) + .clipped() + .onChange(of: viewModel.index, perform: { index in + DispatchQueue.main.async { + withAnimation(Animation.easeInOut(duration: 0.2)) { + offsetView = -(reader.size.height * CGFloat(index)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + showDiscussion = viewModel.selectedLesson().type == .discussion } } - - }) + } + + }) } else { // MARK: No internet view @@ -185,7 +186,9 @@ public struct CourseUnitView: View { } } .onDisappear { - playerStateSubject.send(VideoPlayerState.kill) + if !presentationMode.wrappedValue.isPresented { + playerStateSubject.send(VideoPlayerState.kill) + } } } .navigationBarHidden(false) From eea114b728254348b7fc75c543aae860a6ed8a68 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Thu, 31 Aug 2023 14:46:32 +0300 Subject: [PATCH 11/19] Separate AppStorage to protocol oriented architecture. (#69) * Separate AppStorage to protocol oriented architecture. --- .../Presentation/Base/FieldsView.swift | 1 + Core/Core.xcodeproj/project.pbxproj | 8 +-- Core/Core/Data/CoreStorage.swift | 17 +++++ .../Core/Data/Repository/AuthRepository.swift | 4 +- Core/Core/Network/DownloadManager.swift | 4 +- Core/Core/Network/RequestInterceptor.swift | 14 ++--- Course/Course/Data/CourseRepository.swift | 4 +- .../Dashboard/Data/DashboardRepository.swift | 8 +-- .../Discovery/Data/DiscoveryRepository.swift | 4 +- .../Data/Model/Data_CreatedComment.swift | 4 +- .../Data/Network/DiscussionRepository.swift | 4 +- .../Base/BaseResponsesViewModel.swift | 14 ++--- .../Comments/Responses/ResponsesView.swift | 2 - .../Responses/ResponsesViewModel.swift | 3 +- .../Comments/Thread/ThreadView.swift | 63 ++++++++----------- .../Comments/Thread/ThreadViewModel.swift | 3 +- .../Base/BaseResponsesViewModelTests.swift | 40 +++++++----- .../Comment/ThreadViewModelTests.swift | 8 --- .../Responses/ResponsesViewModelTests.swift | 7 --- OpenEdX.xcodeproj/project.pbxproj | 4 ++ OpenEdX/AppDelegate.swift | 2 +- OpenEdX/DI/AppAssembly.swift | 10 ++- OpenEdX/DI/NetworkAssembly.swift | 2 +- OpenEdX/DI/ScreenAssembly.swift | 14 ++--- {Core/Core => OpenEdX}/Data/AppStorage.swift | 31 ++++----- OpenEdX/Data/DatabaseManager.swift | 23 ------- OpenEdX/RouteController.swift | 4 +- OpenEdX/Router.swift | 2 +- Profile/Data/ProfileStorage.swift | 22 +++++++ Profile/Profile.xcodeproj/project.pbxproj | 12 ++++ Profile/Profile/Data/ProfileRepository.swift | 32 +++++----- 31 files changed, 191 insertions(+), 179 deletions(-) create mode 100644 Core/Core/Data/CoreStorage.swift rename {Core/Core => OpenEdX}/Data/AppStorage.swift (92%) create mode 100644 Profile/Data/ProfileStorage.swift diff --git a/Authorization/Authorization/Presentation/Base/FieldsView.swift b/Authorization/Authorization/Presentation/Base/FieldsView.swift index c021b565c..1a71a2483 100644 --- a/Authorization/Authorization/Presentation/Base/FieldsView.swift +++ b/Authorization/Authorization/Presentation/Base/FieldsView.swift @@ -63,6 +63,7 @@ struct FieldsView: View { type: .discovery, fontSize: 90, screenWidth: proxy.size.width) ) + .id(UUID()) .padding(.horizontal, -6) case .unknown: diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 11e7938c4..680eb2cf4 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -99,7 +99,7 @@ 07460FE3294B72D700F70538 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07460FE2294B72D700F70538 /* Notification.swift */; }; 076F297F2A1F80C800967E7D /* Pagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076F297E2A1F80C800967E7D /* Pagination.swift */; }; 0770DE1928D0847D006D8A5D /* BaseRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE1828D0847D006D8A5D /* BaseRouter.swift */; }; - 0770DE2528D08FBA006D8A5D /* AppStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE2428D08FBA006D8A5D /* AppStorage.swift */; }; + 0770DE2528D08FBA006D8A5D /* CoreStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE2428D08FBA006D8A5D /* CoreStorage.swift */; }; 0770DE2A28D0929E006D8A5D /* HTTPTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE2928D0929E006D8A5D /* HTTPTask.swift */; }; 0770DE2C28D092B3006D8A5D /* NetworkLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE2B28D092B3006D8A5D /* NetworkLogger.swift */; }; 0770DE2E28D09743006D8A5D /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE2D28D09743006D8A5D /* API.swift */; }; @@ -221,7 +221,7 @@ 076F297E2A1F80C800967E7D /* Pagination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pagination.swift; sourceTree = ""; }; 0770DE0828D07831006D8A5D /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0770DE1828D0847D006D8A5D /* BaseRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseRouter.swift; sourceTree = ""; }; - 0770DE2428D08FBA006D8A5D /* AppStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorage.swift; sourceTree = ""; }; + 0770DE2428D08FBA006D8A5D /* CoreStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreStorage.swift; sourceTree = ""; }; 0770DE2928D0929E006D8A5D /* HTTPTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPTask.swift; sourceTree = ""; }; 0770DE2B28D092B3006D8A5D /* NetworkLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkLogger.swift; sourceTree = ""; }; 0770DE2D28D09743006D8A5D /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; @@ -381,7 +381,7 @@ 02CF46C92954A42100A698EE /* Persistence */, 0236961728F9A21600EEF206 /* Repository */, 0727877528D2383C002E9142 /* Model */, - 0770DE2428D08FBA006D8A5D /* AppStorage.swift */, + 0770DE2428D08FBA006D8A5D /* CoreStorage.swift */, 02512FEF299533DE0024D438 /* CoreDataHandlerProtocol.swift */, ); path = Data; @@ -793,7 +793,7 @@ 021D925728DCF12900ACC565 /* AlertView.swift in Sources */, 027BD3A82909474200392132 /* KeyboardAvoidingViewController.swift in Sources */, 0770DE7B28D0C78C006D8A5D /* Theme.swift in Sources */, - 0770DE2528D08FBA006D8A5D /* AppStorage.swift in Sources */, + 0770DE2528D08FBA006D8A5D /* CoreStorage.swift in Sources */, 020306CC2932C0C4000949EA /* PickerView.swift in Sources */, 027BD3C52909707700392132 /* Shake.swift in Sources */, 027BD39C2908810C00392132 /* RegisterUser.swift in Sources */, diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift new file mode 100644 index 000000000..4ff71e963 --- /dev/null +++ b/Core/Core/Data/CoreStorage.swift @@ -0,0 +1,17 @@ +// +// CoreStorage.swift +// Core +// +// Created by Vladimir Chekyrta on 13.09.2022. +// + +import Foundation + +public protocol CoreStorage { + var accessToken: String? {get set} + var refreshToken: String? {get set} + var cookiesDate: String? {get set} + var user: DataLayer.User? {get set} + var userSettings: UserSettings? {get set} + func clear() +} diff --git a/Core/Core/Data/Repository/AuthRepository.swift b/Core/Core/Data/Repository/AuthRepository.swift index e945c8ea4..1ed62fd68 100644 --- a/Core/Core/Data/Repository/AuthRepository.swift +++ b/Core/Core/Data/Repository/AuthRepository.swift @@ -19,10 +19,10 @@ public protocol AuthRepositoryProtocol { public class AuthRepository: AuthRepositoryProtocol { private let api: API - private let appStorage: AppStorage + private var appStorage: CoreStorage private let config: Config - public init(api: API, appStorage: AppStorage, config: Config) { + public init(api: API, appStorage: CoreStorage, config: Config) { self.api = api self.appStorage = appStorage self.config = config diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 33a455cdb..3b5378b9e 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -71,7 +71,7 @@ public protocol DownloadManagerProtocol { public class DownloadManager: DownloadManagerProtocol { private let persistence: CorePersistenceProtocol - private let appStorage: Core.AppStorage + private let appStorage: CoreStorage private let connectivity: ConnectivityProtocol private var downloadRequest: DownloadRequest? private var currentDownload: DownloadData? @@ -79,7 +79,7 @@ public class DownloadManager: DownloadManagerProtocol { public init( persistence: CorePersistenceProtocol, - appStorage: Core.AppStorage, + appStorage: CoreStorage, connectivity: ConnectivityProtocol ) { self.persistence = persistence diff --git a/Core/Core/Network/RequestInterceptor.swift b/Core/Core/Network/RequestInterceptor.swift index 3f8e80b9a..cecdf0570 100644 --- a/Core/Core/Network/RequestInterceptor.swift +++ b/Core/Core/Network/RequestInterceptor.swift @@ -11,11 +11,11 @@ import Alamofire final public class RequestInterceptor: Alamofire.RequestInterceptor { private let config: Config - private let appStorage: AppStorage + private var storage: CoreStorage - public init(config: Config, appStorage: AppStorage) { + public init(config: Config, storage: CoreStorage) { self.config = config - self.appStorage = appStorage + self.storage = storage } private let lock = NSLock() @@ -34,7 +34,7 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { var urlRequest = urlRequest // Set the Authorization header value using the access token. - if let token = appStorage.accessToken { + if let token = storage.accessToken { urlRequest.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") } @@ -57,7 +57,7 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { return completion(.doNotRetry) } - guard let token = appStorage.refreshToken else { + guard let token = storage.refreshToken else { return completion(.doNotRetryWithError(error)) } @@ -117,8 +117,8 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { refreshToken.count > 0 else { return completion(false) } - self.appStorage.accessToken = accessToken - self.appStorage.refreshToken = refreshToken + self.storage.accessToken = accessToken + self.storage.refreshToken = refreshToken completion(true) } catch { completion(false) diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 6be4d39e9..28d3418bb 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -24,12 +24,12 @@ public protocol CourseRepositoryProtocol { public class CourseRepository: CourseRepositoryProtocol { private let api: API - private let appStorage: AppStorage + private let appStorage: CoreStorage private let config: Config private let persistence: CoursePersistenceProtocol public init(api: API, - appStorage: AppStorage, + appStorage: CoreStorage, config: Config, persistence: CoursePersistenceProtocol) { self.api = api diff --git a/Dashboard/Dashboard/Data/DashboardRepository.swift b/Dashboard/Dashboard/Data/DashboardRepository.swift index 0537afea9..ce5721784 100644 --- a/Dashboard/Dashboard/Data/DashboardRepository.swift +++ b/Dashboard/Dashboard/Data/DashboardRepository.swift @@ -16,20 +16,20 @@ public protocol DashboardRepositoryProtocol { public class DashboardRepository: DashboardRepositoryProtocol { private let api: API - private let appStorage: AppStorage + private let storage: CoreStorage private let config: Config private let persistence: DashboardPersistenceProtocol - public init(api: API, appStorage: AppStorage, config: Config, persistence: DashboardPersistenceProtocol) { + public init(api: API, storage: CoreStorage, config: Config, persistence: DashboardPersistenceProtocol) { self.api = api - self.appStorage = appStorage + self.storage = storage self.config = config self.persistence = persistence } public func getMyCourses(page: Int) async throws -> [CourseItem] { let result = try await api.requestData( - DashboardEndpoint.getMyCourses(username: appStorage.user?.username ?? "", page: page) + DashboardEndpoint.getMyCourses(username: storage.user?.username ?? "", page: page) ) .mapResponse(DataLayer.CourseEnrollments.self) .domain(baseURL: config.baseURL.absoluteString) diff --git a/Discovery/Discovery/Data/DiscoveryRepository.swift b/Discovery/Discovery/Data/DiscoveryRepository.swift index 021293fbf..4ba3fa74b 100644 --- a/Discovery/Discovery/Data/DiscoveryRepository.swift +++ b/Discovery/Discovery/Data/DiscoveryRepository.swift @@ -19,12 +19,12 @@ public protocol DiscoveryRepositoryProtocol { public class DiscoveryRepository: DiscoveryRepositoryProtocol { private let api: API - private let appStorage: AppStorage + private let appStorage: CoreStorage private let config: Config private let persistence: DiscoveryPersistenceProtocol public init(api: API, - appStorage: AppStorage, + appStorage: CoreStorage, config: Config, persistence: DiscoveryPersistenceProtocol) { self.api = api diff --git a/Discussion/Discussion/Data/Model/Data_CreatedComment.swift b/Discussion/Discussion/Data/Model/Data_CreatedComment.swift index e94cadb31..b5ac48d50 100644 --- a/Discussion/Discussion/Data/Model/Data_CreatedComment.swift +++ b/Discussion/Discussion/Data/Model/Data_CreatedComment.swift @@ -61,9 +61,7 @@ public extension DataLayer { public extension DataLayer.CreatedComment { var domain: Post { Post(authorName: author ?? DiscussionLocalization.anonymous, - authorAvatar: profileImage.imageURLSmall?.addingPercentEncoding( - withAllowedCharacters: .urlHostAllowed - ) ?? "", + authorAvatar: profileImage.imageURLSmall ?? "", postDate: Date(iso8601: createdAt), postTitle: "", postBodyHtml: renderedBody, diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index a2bc621ec..18530e784 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -36,11 +36,11 @@ public protocol DiscussionRepositoryProtocol { public class DiscussionRepository: DiscussionRepositoryProtocol { private let api: API - private let appStorage: AppStorage + private let appStorage: CoreStorage private let config: Config private let router: DiscussionRouter - public init(api: API, appStorage: AppStorage, config: Config, router: DiscussionRouter) { + public init(api: API, appStorage: CoreStorage, config: Config, router: DiscussionRouter) { self.api = api self.appStorage = appStorage self.config = config diff --git a/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift index d7b7ce10d..bc2572e8d 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift @@ -24,7 +24,7 @@ public class BaseResponsesViewModel { public var totalPages = 1 @Published public var itemsCount = 0 public var fetchInProgress = false - + var errorMessage: String? { didSet { withAnimation { @@ -44,19 +44,16 @@ public class BaseResponsesViewModel { internal let interactor: DiscussionInteractorProtocol internal let router: DiscussionRouter internal let config: Config - internal let storage: Core.AppStorage - internal let addPostSubject = CurrentValueSubject(nil) - init(interactor: DiscussionInteractorProtocol, - router: DiscussionRouter, - config: Config, - storage: Core.AppStorage + init( + interactor: DiscussionInteractorProtocol, + router: DiscussionRouter, + config: Config ) { self.interactor = interactor self.router = router self.config = config - self.storage = storage } @MainActor @@ -137,7 +134,6 @@ public class BaseResponsesViewModel { func addNewPost(_ post: Post) { var newPostWithAvatar = post - newPostWithAvatar.authorAvatar = storage.userProfile?.profileImage?.imageURLLarge ?? "" postComments?.comments.append(newPostWithAvatar) itemsCount += 1 } diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 5d14e14ef..2be19cf06 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -7,7 +7,6 @@ import SwiftUI import Core -import Kingfisher import Combine public struct ResponsesView: View { @@ -208,7 +207,6 @@ struct ResponsesView_Previews: PreviewProvider { interactor: DiscussionInteractor(repository: DiscussionRepositoryMock()), router: DiscussionRouterMock(), config: ConfigMock(), - storage: .mock, threadStateSubject: .init(nil) ) let post = Post( diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift index 1b8e0acc3..fc2012e6e 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift @@ -20,11 +20,10 @@ public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { interactor: DiscussionInteractorProtocol, router: DiscussionRouter, config: Config, - storage: Core.AppStorage, threadStateSubject: CurrentValueSubject ) { self.threadStateSubject = threadStateSubject - super.init(interactor: interactor, router: router, config: config, storage: storage) + super.init(interactor: interactor, router: router, config: config) } func generateCommentsResponses(comments: [UserComment], parentComment: Post) -> Post? { diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index fcbdc1925..f942a4b6c 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -7,13 +7,11 @@ import SwiftUI import Core -import WebKit -import Kingfisher public struct ThreadView: View { private var title: String - private let thread: UserThread? + private let thread: UserThread private var onBackTapped: (() -> Void) = {} @ObservedObject private var viewModel: ThreadViewModel @@ -27,9 +25,6 @@ public struct ThreadView: View { self.thread = thread self.title = thread.title self.viewModel = viewModel - Task { - await viewModel.getPosts(thread: thread, page: 1) - } } public var body: some View { @@ -40,10 +35,8 @@ public struct ThreadView: View { VStack { ZStack(alignment: .top) { RefreshableScrollViewCompat(action: { - if let thread { - viewModel.comments = [] - _ = await viewModel.getPosts(thread: thread, page: 1) - } + viewModel.comments = [] + _ = await viewModel.getPosts(thread: thread, page: 1) }) { VStack { if let comments = viewModel.postComments { @@ -127,11 +120,9 @@ public struct ThreadView: View { ) }, onFetchMore: { - if let thread { - Task { - await viewModel.fetchMorePosts(thread: thread, - index: index) - } + Task { + await viewModel.fetchMorePosts(thread: thread, + index: index) } } ) @@ -153,23 +144,21 @@ public struct ThreadView: View { viewModel.sendUpdateUnreadState() } } - if let thread { - if !thread.closed { - FlexibleKeyboardInputView( - hint: DiscussionLocalization.Thread.addResponse, - sendText: { commentText in - if let threadID = viewModel.postComments?.threadID { - Task { - await viewModel.postComment( - threadID: threadID, - rawBody: commentText, - parentID: viewModel.postComments?.parentID - ) - } + if !thread.closed { + FlexibleKeyboardInputView( + hint: DiscussionLocalization.Thread.addResponse, + sendText: { commentText in + if let threadID = viewModel.postComments?.threadID { + Task { + await viewModel.postComment( + threadID: threadID, + rawBody: commentText, + parentID: viewModel.postComments?.parentID + ) } } - ) - } + } + ) } } .onReceive(viewModel.addPostSubject, perform: { newComment in @@ -227,6 +216,11 @@ public struct ThreadView: View { .navigationBarHidden(false) .navigationBarBackButtonHidden(false) .navigationTitle(title) + .onFirstAppear { + Task { + await viewModel.getPosts(thread: thread, page: 1) + } + } .onDisappear { onBackTapped() viewModel.sendUpdateUnreadState() @@ -239,11 +233,9 @@ public struct ThreadView: View { } private func reloadPage(onSuccess: @escaping () -> Void) { - if let thread { - Task { - if await viewModel.getPosts(thread: thread, - page: viewModel.nextPage-1) { onSuccess() } - } + Task { + if await viewModel.getPosts(thread: thread, + page: viewModel.nextPage-1) { onSuccess() } } } } @@ -275,7 +267,6 @@ struct CommentsView_Previews: PreviewProvider { let vm = ThreadViewModel(interactor: DiscussionInteractor.mock, router: DiscussionRouterMock(), config: ConfigMock(), - storage: .mock, postStateSubject: .init(nil)) ThreadView(thread: userThread, viewModel: vm) diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift index 26d82ce7e..bd8edf468 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift @@ -22,11 +22,10 @@ public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { interactor: DiscussionInteractorProtocol, router: DiscussionRouter, config: Config, - storage: Core.AppStorage, postStateSubject: CurrentValueSubject ) { self.postStateSubject = postStateSubject - super.init(interactor: interactor, router: router, config: config, storage: storage) + super.init(interactor: interactor, router: router, config: config) cancellable = threadStateSubject .receive(on: RunLoop.main) diff --git a/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift index ee4da8ee4..b6940c034 100644 --- a/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift @@ -54,7 +54,7 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) var result = false viewModel.postComments = post @@ -76,7 +76,8 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false viewModel.postComments = post @@ -99,7 +100,8 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false viewModel.postComments = post @@ -121,7 +123,8 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false viewModel.postComments = post @@ -145,7 +148,8 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -166,7 +170,8 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false Given(interactor, .voteThread(voted: .any, threadID: .any, willThrow: NSError())) @@ -185,7 +190,8 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false viewModel.postComments = post @@ -207,7 +213,8 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false viewModel.postComments = post @@ -229,7 +236,8 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -250,7 +258,8 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false Given(interactor, .flagThread(abuseFlagged: .any, threadID: .any, willThrow: NSError())) @@ -269,7 +278,8 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false viewModel.postComments = post @@ -291,7 +301,8 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -312,7 +323,8 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false Given(interactor, .followThread(following: .any, threadID: .any, willThrow: NSError())) @@ -331,7 +343,7 @@ final class BaseResponsesViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config, storage: .mock) + let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) viewModel.postComments = post diff --git a/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift index d0909522b..724e99e32 100644 --- a/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift @@ -212,7 +212,6 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, - storage: .mock, postStateSubject: .init(.readed(id: "1"))) Given(interactor, .readBody(threadID: .any, willProduce: {_ in})) @@ -242,7 +241,6 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, - storage: .mock, postStateSubject: .init(.readed(id: "1"))) Given(interactor, .readBody(threadID: .any, willProduce: {_ in})) @@ -272,7 +270,6 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, - storage: .mock, postStateSubject: .init(.readed(id: "1"))) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -304,7 +301,6 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, - storage: .mock, postStateSubject: .init(.readed(id: "1"))) Given(interactor, .readBody(threadID: .any, willThrow: NSError())) @@ -332,7 +328,6 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, - storage: .mock, postStateSubject: .init(.readed(id: "1"))) let post = Post(authorName: "", @@ -373,7 +368,6 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, - storage: .mock, postStateSubject: .init(.readed(id: "1"))) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -398,7 +392,6 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, - storage: .mock, postStateSubject: .init(.readed(id: "1"))) Given(interactor, .addCommentTo(threadID: .any, rawBody: .any, parentID: .any, willThrow: NSError()) ) @@ -422,7 +415,6 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, - storage: .mock, postStateSubject: .init(.readed(id: "1"))) viewModel.totalPages = 2 diff --git a/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift index 183174526..997364f10 100644 --- a/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift @@ -108,7 +108,6 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, - storage: .mock, threadStateSubject: .init(.postAdded(id: "1"))) Given(interactor, .getCommentResponses(commentID: .any, page: .any, @@ -136,7 +135,6 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, - storage: .mock, threadStateSubject: .init(.postAdded(id: "1"))) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -163,7 +161,6 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, - storage: .mock, threadStateSubject: .init(.postAdded(id: "1"))) Given(interactor, .getCommentResponses(commentID: .any, page: .any, willThrow: NSError())) @@ -187,7 +184,6 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, - storage: .mock, threadStateSubject: .init(.postAdded(id: "1"))) Given(interactor, .addCommentTo(threadID: .any, rawBody: .any, parentID: .any, willReturn: post)) @@ -209,7 +205,6 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, - storage: .mock, threadStateSubject: .init(.postAdded(id: "1"))) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -233,7 +228,6 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, - storage: .mock, threadStateSubject: .init(.postAdded(id: "1"))) Given(interactor, .addCommentTo(threadID: .any, rawBody: .any, parentID: .any, willThrow: NSError())) @@ -255,7 +249,6 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, - storage: .mock, threadStateSubject: .init(.postAdded(id: "1"))) viewModel.totalPages = 2 diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 430caae7c..715431a35 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 020CA5D92AA0A25300970AAF /* AppStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020CA5D82AA0A25300970AAF /* AppStorage.swift */; }; 0218196428F734FA00202564 /* Discussion.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0218196328F734FA00202564 /* Discussion.framework */; }; 0218196528F734FA00202564 /* Discussion.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0218196328F734FA00202564 /* Discussion.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0219C67728F4347600D64452 /* Course.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0219C67628F4347600D64452 /* Course.framework */; }; @@ -65,6 +66,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 020CA5D82AA0A25300970AAF /* AppStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorage.swift; sourceTree = ""; }; 0218196328F734FA00202564 /* Discussion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discussion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0219C67628F4347600D64452 /* Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 02450ABD29C35FF20094E2D0 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -137,6 +139,7 @@ 0293A2042A6FCD430090A336 /* CoursePersistence.swift */, 0293A2062A6FCDA30090A336 /* DiscoveryPersistence.swift */, 0293A2082A6FCDE50090A336 /* DashboardPersistence.swift */, + 020CA5D82AA0A25300970AAF /* AppStorage.swift */, ); path = Data; sourceTree = ""; @@ -371,6 +374,7 @@ buildActionMask = 2147483647; files = ( 0293A2052A6FCD430090A336 /* CoursePersistence.swift in Sources */, + 020CA5D92AA0A25300970AAF /* AppStorage.swift in Sources */, 0298DF302A4EF7230023A257 /* AnalyticsManager.swift in Sources */, 0293A2072A6FCDA30090A336 /* DiscoveryPersistence.swift in Sources */, 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */, diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 6e847a5ed..3c3cad303 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -90,7 +90,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { lastForceLogoutTime = Date().timeIntervalSince1970 - Container.shared.resolve(AppStorage.self)?.clear() + Container.shared.resolve(CoreStorage.self)?.clear() 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 978a5b6b4..edc74bd1f 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -84,7 +84,7 @@ class AppAssembly: Assembly { container.register(DownloadManagerProtocol.self, factory: { r in DownloadManager(persistence: r.resolve(CorePersistenceProtocol.self)!, - appStorage: r.resolve(AppStorage.self)!, + appStorage: r.resolve(CoreStorage.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!) }).inObjectScope(.container) @@ -135,6 +135,14 @@ class AppAssembly: Assembly { ) }.inObjectScope(.container) + container.register(CoreStorage.self) { r in + r.resolve(AppStorage.self)! + }.inObjectScope(.container) + + container.register(ProfileStorage.self) { r in + r.resolve(AppStorage.self)! + }.inObjectScope(.container) + container.register(Validator.self) { _ in Validator() }.inObjectScope(.container) diff --git a/OpenEdX/DI/NetworkAssembly.swift b/OpenEdX/DI/NetworkAssembly.swift index f609b5bc5..1f036a860 100644 --- a/OpenEdX/DI/NetworkAssembly.swift +++ b/OpenEdX/DI/NetworkAssembly.swift @@ -13,7 +13,7 @@ import Swinject class NetworkAssembly: Assembly { func assemble(container: Container) { container.register(RequestInterceptor.self) { r in - RequestInterceptor(config: r.resolve(Config.self)!, appStorage: r.resolve(AppStorage.self)!) + RequestInterceptor(config: r.resolve(Config.self)!, storage: r.resolve(CoreStorage.self)!) }.inObjectScope(.container) container.register(Alamofire.Session.self) { r in diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 618945106..1dd9ddddb 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -23,7 +23,7 @@ class ScreenAssembly: Assembly { container.register(AuthRepositoryProtocol.self) { r in AuthRepository( api: r.resolve(API.self)!, - appStorage: r.resolve(AppStorage.self)!, + appStorage: r.resolve(CoreStorage.self)!, config: r.resolve(Config.self)! ) } @@ -69,7 +69,7 @@ class ScreenAssembly: Assembly { container.register(DiscoveryRepositoryProtocol.self) { r in DiscoveryRepository( api: r.resolve(API.self)!, - appStorage: r.resolve(AppStorage.self)!, + appStorage: r.resolve(CoreStorage.self)!, config: r.resolve(Config.self)!, persistence: r.resolve(DiscoveryPersistenceProtocol.self)! ) @@ -105,7 +105,7 @@ class ScreenAssembly: Assembly { container.register(DashboardRepositoryProtocol.self) { r in DashboardRepository( api: r.resolve(API.self)!, - appStorage: r.resolve(AppStorage.self)!, + storage: r.resolve(CoreStorage.self)!, config: r.resolve(Config.self)!, persistence: r.resolve(DashboardPersistenceProtocol.self)! ) @@ -128,7 +128,7 @@ class ScreenAssembly: Assembly { container.register(ProfileRepositoryProtocol.self) { r in ProfileRepository( api: r.resolve(API.self)!, - appStorage: r.resolve(AppStorage.self)!, + storage: r.resolve(AppStorage.self)!, coreDataHandler: r.resolve(CoreDataHandlerProtocol.self)!, downloadManager: r.resolve(DownloadManagerProtocol.self)!, config: r.resolve(Config.self)! @@ -181,7 +181,7 @@ class ScreenAssembly: Assembly { container.register(CourseRepositoryProtocol.self) { r in CourseRepository( api: r.resolve(API.self)!, - appStorage: r.resolve(AppStorage.self)!, + appStorage: r.resolve(CoreStorage.self)!, config: r.resolve(Config.self)!, persistence: r.resolve(CoursePersistenceProtocol.self)! ) @@ -302,7 +302,7 @@ class ScreenAssembly: Assembly { container.register(DiscussionRepositoryProtocol.self) { r in DiscussionRepository( api: r.resolve(API.self)!, - appStorage: r.resolve(AppStorage.self)!, + appStorage: r.resolve(CoreStorage.self)!, config: r.resolve(Config.self)!, router: r.resolve(DiscussionRouter.self)! ) @@ -346,7 +346,6 @@ class ScreenAssembly: Assembly { interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, config: r.resolve(Config.self)!, - storage: r.resolve(AppStorage.self)!, postStateSubject: subject ) } @@ -356,7 +355,6 @@ class ScreenAssembly: Assembly { interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, config: r.resolve(Config.self)!, - storage: r.resolve(AppStorage.self)!, threadStateSubject: subject ) } diff --git a/Core/Core/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift similarity index 92% rename from Core/Core/Data/AppStorage.swift rename to OpenEdX/Data/AppStorage.swift index ee8bccccb..99144be00 100644 --- a/Core/Core/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -1,14 +1,16 @@ // // AppStorage.swift -// Core +// OpenEdX // -// Created by Vladimir Chekyrta on 13.09.2022. +// Created by  Stepanok Ivan on 31.08.2023. // import Foundation import KeychainSwift +import Core +import Profile -public class AppStorage { +public class AppStorage: CoreStorage, ProfileStorage { private let keychain: KeychainSwift private let userDefaults: UserDefaults @@ -17,7 +19,7 @@ public class AppStorage { self.keychain = keychain self.userDefaults = userDefaults } - + public var accessToken: String? { get { return keychain.get(KEY_ACCESS_TOKEN) @@ -30,7 +32,7 @@ public class AppStorage { } } } - + public var refreshToken: String? { get { return keychain.get(KEY_REFRESH_TOKEN) @@ -43,7 +45,7 @@ public class AppStorage { } } } - + public var cookiesDate: String? { get { return userDefaults.string(forKey: KEY_COOKIES_DATE) @@ -56,7 +58,7 @@ public class AppStorage { } } } - + public var userProfile: DataLayer.UserProfile? { get { guard let userJson = userDefaults.data(forKey: KEY_USER_PROFILE) else { @@ -75,7 +77,7 @@ public class AppStorage { } } } - + public var userSettings: UserSettings? { get { guard let userSettings = userDefaults.data(forKey: KEY_SETTINGS) else { @@ -99,7 +101,7 @@ public class AppStorage { } } } - + public var user: DataLayer.User? { get { guard let userJson = userDefaults.data(forKey: KEY_USER) else { @@ -118,14 +120,14 @@ public class AppStorage { } } } - + public func clear() { accessToken = nil refreshToken = nil cookiesDate = nil user = nil } - + private let KEY_ACCESS_TOKEN = "accessToken" private let KEY_REFRESH_TOKEN = "refreshToken" private let KEY_COOKIES_DATE = "cookiesDate" @@ -133,10 +135,3 @@ public class AppStorage { private let KEY_USER = "refreshToken" private let KEY_SETTINGS = "userSettings" } - -// Mark - For testing and SwiftUI preview -#if DEBUG -public extension AppStorage { - static let mock: AppStorage = .init(keychain: KeychainSwift(), userDefaults: UserDefaults.standard) -} -#endif diff --git a/OpenEdX/Data/DatabaseManager.swift b/OpenEdX/Data/DatabaseManager.swift index d6280c43c..326f42a10 100644 --- a/OpenEdX/Data/DatabaseManager.swift +++ b/OpenEdX/Data/DatabaseManager.swift @@ -35,29 +35,6 @@ class DatabaseManager: CoreDataHandlerProtocol { self.databaseName = databaseName } - public func saveCourseDetails() { - context.performAndWait { - let newCourseDetails = CDCourseDetails(context: context) - newCourseDetails.courseID = UUID().uuidString - newCourseDetails.org = "course.org" - newCourseDetails.courseTitle = "course.courseTitle" - newCourseDetails.courseDescription = "course.courseDescription" - newCourseDetails.courseStart = Date() - newCourseDetails.courseEnd = Date() - newCourseDetails.enrollmentStart = Date() - newCourseDetails.enrollmentEnd = Date() - newCourseDetails.isEnrolled = false - newCourseDetails.overviewHTML = "course.overviewHTML" - newCourseDetails.courseBannerURL = "course.courseBannerURL" - - do { - try context.save() - } catch { - print("⛔️⛔️⛔️⛔️⛔️", error) - } - } - } - private func createContainer() -> NSPersistentContainer { let model = NSManagedObjectModel.mergedModel(from: bundles)! let container = NSPersistentContainer(name: databaseName, managedObjectModel: model) diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index 7086e0ec5..1aa036dc1 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -16,8 +16,8 @@ class RouteController: UIViewController { diContainer.resolve(UINavigationController.self)! }() - private lazy var appStorage: Core.AppStorage = { - diContainer.resolve(AppStorage.self)! + private lazy var appStorage: CoreStorage = { + diContainer.resolve(CoreStorage.self)! }() private lazy var analytics: AuthorizationAnalytics = { diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index ba468cebd..9be51948e 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -17,7 +17,7 @@ import Discovery import Dashboard import Profile import Combine - + public class Router: AuthorizationRouter, DiscoveryRouter, ProfileRouter, diff --git a/Profile/Data/ProfileStorage.swift b/Profile/Data/ProfileStorage.swift new file mode 100644 index 000000000..2770f6060 --- /dev/null +++ b/Profile/Data/ProfileStorage.swift @@ -0,0 +1,22 @@ +// +// ProfileStorage.swift +// Profile +// +// Created by  Stepanok Ivan on 30.08.2023. +// + +import Foundation +import Core + +public protocol ProfileStorage { + var userProfile: DataLayer.UserProfile? {get set} +} + +#if DEBUG +public class ProfileStorageMock: ProfileStorage { + + public var userProfile: DataLayer.UserProfile? + + public init() {} +} +#endif diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index c92a7d9d7..ef60fd5ab 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 02A9A91D2978194A00B55797 /* ProfileViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9A91C2978194A00B55797 /* ProfileViewModelTests.swift */; }; 02A9A91E2978194A00B55797 /* Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 020F834A28DB4CCD0062FA70 /* Profile.framework */; platformFilter = ios; }; 02A9A92B29781A6300B55797 /* ProfileMock.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9A92A29781A6300B55797 /* ProfileMock.generated.swift */; }; + 02B089432A9F832200754BD4 /* ProfileStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B089422A9F832200754BD4 /* ProfileStorage.swift */; }; 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */; }; 02F3BFE7292539850051930C /* ProfileRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFE6292539850051930C /* ProfileRouter.swift */; }; 0796C8C929B7905300444B05 /* ProfileBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0796C8C829B7905300444B05 /* ProfileBottomSheet.swift */; }; @@ -70,6 +71,7 @@ 02A9A91A2978194A00B55797 /* ProfileTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ProfileTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 02A9A91C2978194A00B55797 /* ProfileViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModelTests.swift; sourceTree = ""; }; 02A9A92A29781A6300B55797 /* ProfileMock.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileMock.generated.swift; sourceTree = ""; }; + 02B089422A9F832200754BD4 /* ProfileStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStorage.swift; sourceTree = ""; }; 02ED50CE29A64BAD008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAnalytics.swift; sourceTree = ""; }; 02F3BFE6292539850051930C /* ProfileRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRouter.swift; sourceTree = ""; }; @@ -138,6 +140,7 @@ 020F834028DB4CCD0062FA70 = { isa = PBXGroup; children = ( + 02B089412A9F830D00754BD4 /* Data */, 021D925B28DDADBD00ACC565 /* swiftgen.yml */, 020F834C28DB4CCD0062FA70 /* Profile */, 02A9A91B2978194A00B55797 /* ProfileTests */, @@ -276,6 +279,14 @@ path = ProfileTests; sourceTree = ""; }; + 02B089412A9F830D00754BD4 /* Data */ = { + isa = PBXGroup; + children = ( + 02B089422A9F832200754BD4 /* ProfileStorage.swift */, + ); + path = Data; + sourceTree = ""; + }; 0766DFD3299AD9D800EBEF6A /* Presentation */ = { isa = PBXGroup; children = ( @@ -544,6 +555,7 @@ 021D925528DC92F800ACC565 /* ProfileInteractor.swift in Sources */, 029301DA2938948500E99AB8 /* ProfileType.swift in Sources */, 0259104429C39C9E004B5A55 /* SettingsView.swift in Sources */, + 02B089432A9F832200754BD4 /* ProfileStorage.swift in Sources */, 021D925228DC918D00ACC565 /* ProfileViewModel.swift in Sources */, 0248F9B128DDB09D0041327E /* Strings.swift in Sources */, 020306CA2932B14D000949EA /* EditProfileViewModel.swift in Sources */, diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 1295ff44e..9c95c9a7a 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -26,20 +26,20 @@ public protocol ProfileRepositoryProtocol { public class ProfileRepository: ProfileRepositoryProtocol { private let api: API - private let appStorage: AppStorage + private var storage: CoreStorage & ProfileStorage private let downloadManager: DownloadManagerProtocol private let coreDataHandler: CoreDataHandlerProtocol private let config: Config public init( api: API, - appStorage: AppStorage, + storage: CoreStorage & ProfileStorage, coreDataHandler: CoreDataHandlerProtocol, downloadManager: DownloadManagerProtocol, config: Config ) { self.api = api - self.appStorage = appStorage + self.storage = storage self.coreDataHandler = coreDataHandler self.downloadManager = downloadManager self.config = config @@ -48,14 +48,14 @@ public class ProfileRepository: ProfileRepositoryProtocol { public func getMyProfile() async throws -> UserProfile { let user = try await api.requestData( - ProfileEndpoint.getUserProfile(username: appStorage.user?.username ?? "") + ProfileEndpoint.getUserProfile(username: storage.user?.username ?? "") ).mapResponse(DataLayer.UserProfile.self) - appStorage.userProfile = user + storage.userProfile = user return user.domain } public func getMyProfileOffline() throws -> UserProfile { - if let user = appStorage.userProfile { + if let user = storage.userProfile { return user.domain } else { throw NoCachedDataError() @@ -63,11 +63,11 @@ public class ProfileRepository: ProfileRepositoryProtocol { } public func logOut() async throws { - guard let refreshToken = appStorage.refreshToken else { return } + guard let refreshToken = storage.refreshToken else { return } _ = try await api.request( ProfileEndpoint.logOut(refreshToken: refreshToken, clientID: config.oAuthClientId) ) - appStorage.clear() + storage.clear() downloadManager.deleteAllFiles() coreDataHandler.clear() } @@ -110,23 +110,23 @@ public class ProfileRepository: ProfileRepositoryProtocol { } public func uploadProfilePicture(pictureData: Data) async throws { - let response = try await api.request( - ProfileEndpoint.uploadProfilePicture(username: appStorage.user?.username ?? "", - pictureData: pictureData)) + let response = try await api.request( + ProfileEndpoint.uploadProfilePicture(username: storage.user?.username ?? "", + pictureData: pictureData)) if response.statusCode != 204 { throw APIError.uploadError } } public func deleteProfilePicture() async throws -> Bool { - let response = try await api.request( - ProfileEndpoint.deleteProfilePicture(username: appStorage.user?.username ?? "")) + let response = try await api.request( + ProfileEndpoint.deleteProfilePicture(username: storage.user?.username ?? "")) return response.statusCode == 204 } public func updateUserProfile(parameters: [String: Any]) async throws -> UserProfile { let response = try await api.requestData( - ProfileEndpoint.updateUserProfile(username: appStorage.user?.username ?? "", + ProfileEndpoint.updateUserProfile(username: storage.user?.username ?? "", parameters: parameters)) .mapResponse(DataLayer.UserProfile.self).domain return response @@ -138,7 +138,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { } public func getSettings() -> UserSettings { - if let userSettings = appStorage.userSettings { + if let userSettings = storage.userSettings { return userSettings } else { return UserSettings(wifiOnly: true, downloadQuality: VideoQuality.auto) @@ -146,7 +146,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { } public func saveSettings(_ settings: UserSettings) { - appStorage.userSettings = settings + storage.userSettings = settings } } From 89530b4cb723358cb6e2c0323eb259951ea7335b Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Fri, 8 Sep 2023 11:27:06 +0300 Subject: [PATCH 12/19] Added path field to the DownloadData model. (#70) * Added path field to the DownloadData model. All control over files was refactored using the new path field. * Fix CourseContainerViewModelTests --- .../CoreDataModel.xcdatamodel/contents | 3 +- .../Persistence/CorePersistenceProtocol.swift | 3 +- Core/Core/Network/DownloadManager.swift | 51 +++++++++++-------- .../CourseContainerViewModelTests.swift | 3 ++ OpenEdX/Data/CorePersistence.swift | 24 ++++++++- OpenEdX/Data/DatabaseManager.swift | 8 ++- 6 files changed, 66 insertions(+), 26 deletions(-) diff --git a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 79f74bbed..ed12a0c8a 100644 --- a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,9 +1,10 @@ - + + diff --git a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift index b93c8a428..e7256850c 100644 --- a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift +++ b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift @@ -10,11 +10,12 @@ 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 updateDownloadState(id: String, state: DownloadState, resumeData: Data?) + func updateDownloadState(id: String, state: DownloadState, path: String?, resumeData: Data?) func deleteDownloadData(id: String) throws func saveDownloadData(data: DownloadData) } diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 3b5378b9e..4b8ed6df8 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -24,6 +24,7 @@ public struct DownloadData { public let id: String public let courseId: String public let url: String + public let path: String? public let fileName: String public let progress: Double public let resumeData: Data? @@ -34,6 +35,7 @@ public struct DownloadData { id: String, courseId: String, url: String, + path: String?, fileName: String, progress: Double, resumeData: Data?, @@ -43,6 +45,7 @@ public struct DownloadData { self.id = id self.courseId = courseId self.url = url + self.path = path self.fileName = fileName self.progress = progress self.resumeData = resumeData @@ -145,6 +148,7 @@ public class DownloadManager: DownloadManagerProtocol { persistence.updateDownloadState( id: download.id, state: .inProgress, + path: nil, resumeData: download.resumeData ) self.isDownloadingInProgress = true @@ -161,10 +165,11 @@ public class DownloadManager: DownloadManagerProtocol { downloadRequest?.responseData(completionHandler: { [weak self] data in guard let self else { return } if let data = data.value, let url = self.videosFolderUrl() { - self.saveFile(file: fileName, data: data, url: url) + let fileUrl = self.saveFile(fileName: fileName, data: data, folderURL: url) self.persistence.updateDownloadState( id: download.id, state: .finished, + path: fileUrl?.absoluteString, resumeData: nil ) try? self.newDownload() @@ -183,6 +188,7 @@ public class DownloadManager: DownloadManagerProtocol { self.persistence.updateDownloadState( id: currentDownload.id, state: .paused, + path: nil, resumeData: resumeData ) }) @@ -190,26 +196,29 @@ public class DownloadManager: DownloadManagerProtocol { public func deleteFile(blocks: [CourseBlock]) { for block in blocks { - if let url = block.videoUrl, - let fileName = URL(string: url)?.lastPathComponent, let folderUrl = videosFolderUrl() { - do { - let fileUrl = folderUrl.appendingPathComponent(fileName) - try persistence.deleteDownloadData(id: block.id) - try FileManager.default.removeItem(at: fileUrl) - print("File deleted successfully") - } catch { - print("Error deleting file: \(error.localizedDescription)") - } + let downloadData = persistence.downloadData(by: block.id) + guard let path = persistence.downloadData(by: block.id)?.path, + let fileUrl = URL(string: path) else { return } + + do { + try persistence.deleteDownloadData(id: block.id) + try FileManager.default.removeItem(at: fileUrl) + print("File deleted successfully") + } catch { + print("Error deleting file: \(error.localizedDescription)") } } } public func deleteAllFiles() { - if let folderUrl = videosFolderUrl() { - do { - try FileManager.default.removeItem(at: folderUrl) - } catch { - NSLog("Error deleting All files: \(error.localizedDescription)") + let downloadData = persistence.getAllDownloadData() + downloadData.forEach { + if let path = $0.path, let fileURL = URL(string: path) { + do { + try FileManager.default.removeItem(at: fileURL) + } catch { + NSLog("Error deleting All files: \(error.localizedDescription)") + } } } } @@ -219,9 +228,7 @@ public class DownloadManager: DownloadManagerProtocol { data.url.count > 0, data.state == .finished else { return nil } - let documentDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let directoryURL = documentDirectoryURL.appendingPathComponent("Files", isDirectory: true) - return directoryURL.appendingPathComponent(data.fileName) + return URL(string: data.path ?? "") } private func videosFolderUrl() -> URL? { @@ -245,13 +252,15 @@ public class DownloadManager: DownloadManagerProtocol { } } - private func saveFile(file: String, data: Data, url: URL) { - let fileURL = url.appendingPathComponent(file) + private func saveFile(fileName: String, data: Data, folderURL: URL) -> URL? { + let fileURL = folderURL.appendingPathComponent(fileName) do { try data.write(to: fileURL) + return fileURL } catch { print("SaveFile Error", error.localizedDescription) } + return nil } } diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 86c12aa7a..63632ac3b 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -631,6 +631,7 @@ final class CourseContainerViewModelTests: XCTestCase { id: "1", courseId: "course123", url: "https://example.com/file.mp4", + path: nil, fileName: "file.mp4", progress: 0, resumeData: nil, @@ -739,6 +740,7 @@ final class CourseContainerViewModelTests: XCTestCase { id: "1", courseId: "course123", url: "https://example.com/file.mp4", + path: "file://../file.mp4", fileName: "file.mp4", progress: 0, resumeData: nil, @@ -860,6 +862,7 @@ final class CourseContainerViewModelTests: XCTestCase { id: "1", courseId: "course123", url: "https://example.com/file.mp4", + path: nil, fileName: "file.mp4", progress: 0, resumeData: nil, diff --git a/OpenEdX/Data/CorePersistence.swift b/OpenEdX/Data/CorePersistence.swift index ce2a4e31e..38605c97b 100644 --- a/OpenEdX/Data/CorePersistence.swift +++ b/OpenEdX/Data/CorePersistence.swift @@ -41,6 +41,24 @@ public class CorePersistence: CorePersistenceProtocol { .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 ?? "", + path: $0.path, + fileName: $0.fileName ?? "", + progress: $0.progress, + resumeData: $0.resumeData, + state: DownloadState(rawValue: $0.state ?? "") ?? .waiting, + type: DownloadType(rawValue: $0.type ?? "") ?? .video + ) + } + } + public func addToDownloadQueue(blocks: [CourseBlock]) { for block in blocks { let request = CDDownloadData.fetchRequest() @@ -72,6 +90,7 @@ public class CorePersistence: CorePersistenceProtocol { id: data.id ?? "", courseId: data.courseId ?? "", url: data.url ?? "", + path: data.path, fileName: data.fileName ?? "", progress: data.progress, resumeData: data.resumeData, @@ -89,6 +108,7 @@ public class CorePersistence: CorePersistenceProtocol { id: $0.id ?? "", courseId: $0.courseId ?? "", url: $0.url ?? "", + path: $0.path, fileName: $0.fileName ?? "", progress: $0.progress, resumeData: $0.resumeData, @@ -106,6 +126,7 @@ public class CorePersistence: CorePersistenceProtocol { id: downloadData.id ?? "", courseId: downloadData.courseId ?? "", url: downloadData.url ?? "", + path: downloadData.path, fileName: downloadData.fileName ?? "", progress: downloadData.progress, resumeData: downloadData.resumeData, @@ -114,12 +135,13 @@ public class CorePersistence: CorePersistenceProtocol { ) } - public func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + public func updateDownloadState(id: String, state: DownloadState, path: String?, 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 + downloadData.path = path downloadData.resumeData = resumeData do { try context.save() diff --git a/OpenEdX/Data/DatabaseManager.swift b/OpenEdX/Data/DatabaseManager.swift index 326f42a10..b8f71b346 100644 --- a/OpenEdX/Data/DatabaseManager.swift +++ b/OpenEdX/Data/DatabaseManager.swift @@ -74,7 +74,11 @@ class DatabaseManager: CoreDataHandlerProtocol { } // Re-create the persistent container - persistentContainer = createContainer() - context = createContext() + persistentContainer.loadPersistentStores { _, error in + if let error = error { + print("Unresolved error \(error)") + fatalError() + } + } } } From 2a385a80f8ac25e92be735f2c37a88d7c138f030 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Wed, 13 Sep 2023 11:26:49 +0300 Subject: [PATCH 13/19] September bugfixes (#71) * fix incorrect back button on the course screen * Fix issue on Delete account view. The inactive button is clickable. * change CourseDetailsEndpoint to CourseEndpoint fix WebView bug with iframe fix doubling courses on Dashboard after subscribe to a new course --- .../Profile/done.imageset/Contents.json | 3 +++ .../arrowLeft.imageset/Contents.json | 2 +- .../arrowRight16.imageset/Contents.json | 3 +++ .../Core/Extensions/UIApplicationExtension.swift | 8 +++++--- Core/Core/View/Base/StyledButton.swift | 4 +++- Core/Core/View/Base/WebView.swift | 15 ++++++++++----- Course/Course/Data/CourseRepository.swift | 16 ++++++++-------- .../Data/Network/CourseDetailsEndpoint.swift | 4 ++-- .../Presentation/DashboardViewModel.swift | 2 +- 9 files changed, 36 insertions(+), 21 deletions(-) diff --git a/Core/Core/Assets.xcassets/Profile/done.imageset/Contents.json b/Core/Core/Assets.xcassets/Profile/done.imageset/Contents.json index 7706af200..f664a70cf 100644 --- a/Core/Core/Assets.xcassets/Profile/done.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/Profile/done.imageset/Contents.json @@ -8,5 +8,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json b/Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json index b2ecd4b1c..cfa90a49f 100644 --- a/Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json @@ -20,6 +20,6 @@ "version" : 1 }, "properties" : { - "template-rendering-intent" : "template" + "template-rendering-intent" : "original" } } diff --git a/Core/Core/Assets.xcassets/arrowRight16.imageset/Contents.json b/Core/Core/Assets.xcassets/arrowRight16.imageset/Contents.json index cf5254936..2d22dfa63 100644 --- a/Core/Core/Assets.xcassets/arrowRight16.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/arrowRight16.imageset/Contents.json @@ -8,5 +8,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index 64acdb8f8..616b9f466 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -42,9 +42,11 @@ extension UINavigationController { navigationBar.barTintColor = .clear navigationBar.setBackgroundImage(UIImage(), for: .default) navigationBar.shadowImage = UIImage() - - navigationBar.backIndicatorImage = CoreAssets.arrowLeft.image - navigationBar.backIndicatorTransitionMaskImage = CoreAssets.arrowLeft.image + + let image = CoreAssets.arrowLeft.image + navigationBar.backIndicatorImage = image.withTintColor(CoreAssets.accentColor.color) + navigationBar.tintColor = .clear + navigationBar.backIndicatorTransitionMaskImage = image.withTintColor(CoreAssets.accentColor.color) navigationBar.titleTextAttributes = [.foregroundColor: CoreAssets.textPrimary.color] } } diff --git a/Core/Core/View/Base/StyledButton.swift b/Core/Core/View/Base/StyledButton.swift index deb704595..b16f61a1a 100644 --- a/Core/Core/View/Base/StyledButton.swift +++ b/Core/Core/View/Base/StyledButton.swift @@ -15,6 +15,7 @@ public struct StyledButton: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } private let buttonColor: Color private let textColor: Color + private let isActive: Bool public init(_ title: String, action: @escaping () -> Void, @@ -30,8 +31,8 @@ public struct StyledButton: View { } else { self.buttonColor = Theme.Colors.cardViewStroke self.textColor = Theme.Colors.textPrimary - } + self.isActive = isActive } public var body: some View { @@ -43,6 +44,7 @@ public struct StyledButton: View { .frame(maxWidth: .infinity) .padding(.horizontal, 16) } + .disabled(!isActive) .frame(maxWidth: idiom == .pad ? 260: .infinity, minHeight: isTransparent ? 36 : 42) .background( Theme.Shapes.buttonShape diff --git a/Core/Core/View/Base/WebView.swift b/Core/Core/View/Base/WebView.swift index 463a9a315..9534f7529 100644 --- a/Core/Core/View/Base/WebView.swift +++ b/Core/Core/View/Base/WebView.swift @@ -75,14 +75,19 @@ public struct WebView: UIViewRepresentable { _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction ) async -> WKNavigationActionPolicy { - guard let url = navigationAction.request.url else { - return .cancel - } + + guard let url = navigationAction.request.url else { return .cancel } let baseURL = await parent.viewModel.baseURL if !baseURL.isEmpty, !url.absoluteString.starts(with: baseURL) { - await MainActor.run { - UIApplication.shared.open(url, options: [:]) + if navigationAction.navigationType == .other { + return .allow + } else if navigationAction.navigationType == .linkActivated { + await MainActor.run { + UIApplication.shared.open(url, options: [:]) + } + } else if navigationAction.navigationType == .formSubmitted { + return .allow } return .cancel } diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 28d3418bb..68e833255 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -39,7 +39,7 @@ public class CourseRepository: CourseRepositoryProtocol { } public func getCourseDetails(courseID: String) async throws -> CourseDetails { - let response = try await api.requestData(CourseDetailsEndpoint.getCourseDetail(courseID: courseID)) + let response = try await api.requestData(CourseEndpoint.getCourseDetail(courseID: courseID)) .mapResponse(DataLayer.CourseDetailsResponse.self) .domain(baseURL: config.baseURL.absoluteString) persistence.saveCourseDetails(course: response) @@ -52,7 +52,7 @@ public class CourseRepository: CourseRepositoryProtocol { public func getCourseBlocks(courseID: String) async throws -> CourseStructure { let course = try await api.requestData( - CourseDetailsEndpoint.getCourseBlocks(courseID: courseID, userName: appStorage.user?.username ?? "") + CourseEndpoint.getCourseBlocks(courseID: courseID, userName: appStorage.user?.username ?? "") ).mapResponse(DataLayer.CourseStructure.self) persistence.saveCourseStructure(structure: course) let parsedStructure = parseCourseStructure(course: course) @@ -65,7 +65,7 @@ public class CourseRepository: CourseRepositoryProtocol { } public func enrollToCourse(courseID: String) async throws -> Bool { - let enroll = try await api.request(CourseDetailsEndpoint.enrollToCourse(courseID: courseID)) + let enroll = try await api.request(CourseEndpoint.enrollToCourse(courseID: courseID)) if enroll.statusCode == 200 { return true } else { @@ -74,7 +74,7 @@ public class CourseRepository: CourseRepositoryProtocol { } public func blockCompletionRequest(courseID: String, blockID: String) async throws { - try await api.requestData(CourseDetailsEndpoint.blockCompletionRequest( + try await api.requestData(CourseEndpoint.blockCompletionRequest( username: appStorage.user?.username ?? "", courseID: courseID, blockID: blockID) @@ -82,18 +82,18 @@ public class CourseRepository: CourseRepositoryProtocol { } public func getHandouts(courseID: String) async throws -> String? { - return try await api.requestData(CourseDetailsEndpoint.getHandouts(courseID: courseID)) + return try await api.requestData(CourseEndpoint.getHandouts(courseID: courseID)) .mapResponse(DataLayer.HandoutsResponse.self) .handoutsHtml } public func getUpdates(courseID: String) async throws -> [CourseUpdate] { - return try await api.requestData(CourseDetailsEndpoint.getUpdates(courseID: courseID)) + return try await api.requestData(CourseEndpoint.getUpdates(courseID: courseID)) .mapResponse(DataLayer.CourseUpdates.self).map { $0.domain } } public func resumeBlock(courseID: String) async throws -> ResumeBlock { - return try await api.requestData(CourseDetailsEndpoint + return try await api.requestData(CourseEndpoint .resumeBlock(userName: appStorage.user?.username ?? "", courseID: courseID)) .mapResponse(DataLayer.ResumeBlock.self).domain } @@ -102,7 +102,7 @@ public class CourseRepository: CourseRepositoryProtocol { if let subtitlesOffline = persistence.loadSubtitles(url: url + selectedLanguage) { return subtitlesOffline } else { - let result = try await api.requestData(CourseDetailsEndpoint.getSubtitles( + let result = try await api.requestData(CourseEndpoint.getSubtitles( url: url, selectedLanguage: selectedLanguage )) diff --git a/Course/Course/Data/Network/CourseDetailsEndpoint.swift b/Course/Course/Data/Network/CourseDetailsEndpoint.swift index 57bb2459f..7b3109a9c 100644 --- a/Course/Course/Data/Network/CourseDetailsEndpoint.swift +++ b/Course/Course/Data/Network/CourseDetailsEndpoint.swift @@ -1,5 +1,5 @@ // -// CourseDetailsEndpoint.swift +// CourseEndpoint.swift // CourseDetails // // Created by  Stepanok Ivan on 26.09.2022. @@ -9,7 +9,7 @@ import Foundation import Core import Alamofire -enum CourseDetailsEndpoint: EndPointType { +enum CourseEndpoint: EndPointType { case getCourseDetail(courseID: String) case getCourseBlocks(courseID: String, userName: String) case pageHTML(pageUrlString: String) diff --git a/Dashboard/Dashboard/Presentation/DashboardViewModel.swift b/Dashboard/Dashboard/Presentation/DashboardViewModel.swift index 16ddff151..6e4d9974a 100644 --- a/Dashboard/Dashboard/Presentation/DashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/DashboardViewModel.swift @@ -43,7 +43,7 @@ public class DashboardViewModel: ObservableObject { .sink { [weak self] _ in guard let self = self else { return } Task { - await self.getMyCourses(page: 1) + await self.getMyCourses(page: 1, refresh: true) } } } From 9683c5338a83b4ecea7b1d1c19f1685056d1384a Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Wed, 13 Sep 2023 14:22:23 +0300 Subject: [PATCH 14/19] Create 0001-strategy-for-maintaining-OS-versions.rst (#72) * Create 0001-strategy-for-maintaining-OS-versions.rst * Update 0001-strategy-for-maintaining-OS-versions.rst --- ...1-strategy-for-maintaining-OS-versions.rst | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/0001-strategy-for-maintaining-OS-versions.rst diff --git a/docs/0001-strategy-for-maintaining-OS-versions.rst b/docs/0001-strategy-for-maintaining-OS-versions.rst new file mode 100644 index 000000000..17aea4b7d --- /dev/null +++ b/docs/0001-strategy-for-maintaining-OS-versions.rst @@ -0,0 +1,62 @@ +Title: Strategy for maintaining iOS versions in the OpenEdx Project +================================================== +Date: 13 September 2023 + +Status +------ +Accepted + +Context +------ +In the OpenEdx project, we are developing a mobile application on the SwiftUI platform for iOS users. +To ensure optimal support and security of the application, we need to make a decision regarding which +versions of the iOS operating system will be supported. This document outlines the decision to support +only the current iOS version and the two previous versions. + +Decision +------ +We decide to support only the current iOS version and the two previous versions. This means that our +application will be optimized and tested to work on the three most recent iOS versions at the time +of the application's release. + +Why is this important? + +1. Streamlined Development and Testing +------ +Supporting multiple iOS versions requires significant development and testing resources. By restricting +the number of supported versions, we can focus our efforts on developing new features and improving the +application's quality, without spreading ourselves too thin trying to maintain compatibility +with outdated iOS versions. + +2. Performance and User Experience Enhancement +------ +With each new iOS version, Apple introduces performance and functionality improvements. By limiting support +to older iOS versions, we can leverage new capabilities and libraries to create faster and more feature-rich +application versions. This also enhances the user experience and user satisfaction. + +3. Security +------ +The most crucial aspect of mobile application development is ensuring user security. New iOS versions +contain critical security updates, and supporting old iOS versions can leave the application vulnerable +to known threats. By supporting only the current version and the two previous ones, we can quickly respond +to security updates and provide robust data protection for users. + +Project Impact +------ + +This decision will impact the project in the following ways: +------ +Enhanced application security. +Improved performance and functionality. +Reduced development and testing burden. +Implementation + +To implement this decision, we will monitor the releases of new iOS versions and update our application +accordingly, considering the limitation of supporting only the current version and the two previous versions. +We will also inform users about the need to update their operating systems for optimal application performance. + +Alternatives +------ +Continuing to support older iOS versions would demand more resources, pose security and performance risks, +and limit our ability to adopt modern technologies and innovations, potentially slowing down development +and compromising user experience. From 4c2ac95494b2047c457fe6deb505552e8ac1dba1 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 15 Sep 2023 12:06:24 +0300 Subject: [PATCH 15/19] Deprecate iOS 14 (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Deprecate iOS 14 Remove isIOS14 in ViewExtension
 Change deployment target to iOS 15
 Add a new RefreshableScrollView for better pull to refresh animation on any iOS update loading logic in DiscoveryViewModel, ThreadViewModel, and PostsViewModel for a smooth refreshing --- .../Authorization.xcodeproj/project.pbxproj | 32 +- Core/Core.xcodeproj/project.pbxproj | 20 +- Core/Core/Extensions/ViewExtension.swift | 8 - .../View/Base/RefreshableScrollView.swift | 593 +++++++----------- .../Base/RefreshableScrollViewCompat.swift | 40 -- Course/Course.xcodeproj/project.pbxproj | 40 +- ...ilsEndpoint.swift => CourseEndpoint.swift} | 0 .../Details/CourseDetailsView.swift | 13 +- .../Outline/CourseOutlineView.swift | 11 +- Dashboard/Dashboard.xcodeproj/project.pbxproj | 32 +- .../Presentation/DashboardView.swift | 105 ++-- Discovery/Discovery.xcodeproj/project.pbxproj | 32 +- .../Presentation/DiscoveryView.swift | 95 ++- .../Presentation/DiscoveryViewModel.swift | 8 +- .../Discussion.xcodeproj/project.pbxproj | 32 +- .../Comments/Responses/ResponsesView.swift | 245 ++++---- .../Comments/Thread/ThreadView.swift | 11 +- .../Comments/Thread/ThreadViewModel.swift | 6 +- .../DiscussionTopicsView.swift | 19 +- .../Presentation/Posts/PostsView.swift | 20 +- .../Presentation/Posts/PostsViewModel.swift | 146 +++-- OpenEdX.xcodeproj/project.pbxproj | 12 +- Profile/Profile.xcodeproj/project.pbxproj | 32 +- .../Presentation/Profile/ProfileView.swift | 26 +- 24 files changed, 722 insertions(+), 856 deletions(-) delete mode 100644 Core/Core/View/Base/RefreshableScrollViewCompat.swift rename Course/Course/Data/Network/{CourseDetailsEndpoint.swift => CourseEndpoint.swift} (100%) diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index b4c539d33..fba945920 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -583,7 +583,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -611,7 +611,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -694,7 +694,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -721,7 +721,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -739,7 +739,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -757,7 +757,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -775,7 +775,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -793,7 +793,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -811,7 +811,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -829,7 +829,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -918,7 +918,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1011,7 +1011,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1109,7 +1109,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1202,7 +1202,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1358,7 +1358,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1393,7 +1393,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 680eb2cf4..a9463cdcc 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -32,7 +32,6 @@ 024D865E28F02C6B0077E0A0 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024D865D28F02C6B0077E0A0 /* WebView.swift */; }; 024FCD0028EF1CD300232339 /* WebBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024FCCFF28EF1CD300232339 /* WebBrowser.swift */; }; 02512FF0299533DF0024D438 /* CoreDataHandlerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02512FEF299533DE0024D438 /* CoreDataHandlerProtocol.swift */; }; - 0251ED0C299D16BD00E70450 /* RefreshableScrollViewCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0251ED0B299D16BC00E70450 /* RefreshableScrollViewCompat.swift */; }; 0255D5582936283A004DBC1A /* UploadBodyEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0255D55729362839004DBC1A /* UploadBodyEncoding.swift */; }; 0259104A29C4A5B6004B5A55 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259104929C4A5B6004B5A55 /* UserSettings.swift */; }; 025B36752A13B7D5001A640E /* UnitButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025B36742A13B7D5001A640E /* UnitButtonView.swift */; }; @@ -152,7 +151,6 @@ 024D865D28F02C6B0077E0A0 /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; 024FCCFF28EF1CD300232339 /* WebBrowser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebBrowser.swift; sourceTree = ""; }; 02512FEF299533DE0024D438 /* CoreDataHandlerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHandlerProtocol.swift; sourceTree = ""; }; - 0251ED0B299D16BC00E70450 /* RefreshableScrollViewCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollViewCompat.swift; sourceTree = ""; }; 0255D55729362839004DBC1A /* UploadBodyEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadBodyEncoding.swift; sourceTree = ""; }; 0259104929C4A5B6004B5A55 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = ""; }; 025B36742A13B7D5001A640E /* UnitButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitButtonView.swift; sourceTree = ""; }; @@ -521,7 +519,6 @@ 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */, 024FCCFF28EF1CD300232339 /* WebBrowser.swift */, 028CE96829858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift */, - 0251ED0B299D16BC00E70450 /* RefreshableScrollViewCompat.swift */, 023A4DD3299E66BD006C0E48 /* OfflineSnackBarView.swift */, 024D865D28F02C6B0077E0A0 /* WebView.swift */, 02C2DC0729B63D6200F4445D /* WebViewHTML.swift */, @@ -802,7 +799,6 @@ 027BD3B42909475900392132 /* KeyboardState.swift in Sources */, 027BD3922907D88F00392132 /* Data_RegistrationFields.swift in Sources */, 07460FE3294B72D700F70538 /* Notification.swift in Sources */, - 0251ED0C299D16BD00E70450 /* RefreshableScrollViewCompat.swift in Sources */, 0727877F28D25B24002E9142 /* Alamofire+Error.swift in Sources */, 02A4833829B8A8F900D33F33 /* CoreDataModel.xcdatamodeld in Sources */, 0259104A29C4A5B6004B5A55 /* UserSettings.swift in Sources */, @@ -951,7 +947,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1064,7 +1060,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1302,7 +1298,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1395,7 +1391,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1493,7 +1489,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1586,7 +1582,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1742,7 +1738,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1777,7 +1773,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 3beb78550..d6584cbcf 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -166,14 +166,6 @@ public extension View { ) } - var isIOS14: Bool { - if #available(iOS 15.0, *) { - return false - } else { - return true - } - } - func onFirstAppear(_ action: @escaping () -> Void) -> some View { modifier(FirstAppear(action: action)) } diff --git a/Core/Core/View/Base/RefreshableScrollView.swift b/Core/Core/View/Base/RefreshableScrollView.swift index 3a942d130..d72580b5e 100644 --- a/Core/Core/View/Base/RefreshableScrollView.swift +++ b/Core/Core/View/Base/RefreshableScrollView.swift @@ -6,418 +6,249 @@ // import SwiftUI +import Combine -// There are two type of positioning views - one that scrolls with the content, -// and one that stays fixed -private enum PositionType { - case fixed, moving -} - -// This struct is the currency of the Preferences, and has a type -// (fixed or moving) and the actual Y-axis value. -// It's Equatable because Swift requires it to be. -private struct Position: Equatable { - let type: PositionType - let y: CGFloat -} - -// This might seem weird, but it's necessary due to the funny nature of -// how Preferences work. We can't just store the last position and merge -// it with the next one - instead we have a queue of all the latest positions. -private struct PositionPreferenceKey: PreferenceKey { - typealias Value = [Position] - - static var defaultValue = [Position]() - - static func reduce(value: inout [Position], nextValue: () -> [Position]) { - value.append(contentsOf: nextValue()) - } -} - -private struct PositionIndicator: View { - let type: PositionType - - var body: some View { - GeometryReader { proxy in - // the View itself is an invisible Shape that fills as much as possible - Color.clear - // Compute the top Y position and emit it to the Preferences queue - .preference(key: PositionPreferenceKey.self, value: [Position(type: type, y: proxy.frame(in: .global).minY)]) - } - } -} - -// Callback that'll trigger once refreshing is done -public typealias RefreshComplete = () -> Void - -// The actual refresh action that's called once refreshing starts. It has the -// RefreshComplete callback to let the refresh action let the View know -// once it's done refreshing. -public typealias OnRefresh = (@escaping RefreshComplete) -> Void - -// The offset threshold. 68 is a good number, but you can play -// with it to your liking. -public let defaultRefreshThreshold: CGFloat = 68 - -// Tracks the state of the RefreshableScrollView - it's either: -// 1. waiting for a scroll to happen -// 2. has been primed by pulling down beyond THRESHOLD -// 3. is doing the refreshing. -public enum RefreshState { - case waiting, primed, loading -} - -// ViewBuilder for the custom progress View, that may render itself -// based on the current RefreshState. -public typealias RefreshProgressBuilder = (RefreshState) -> Progress - -// Default color of the rectangle behind the progress spinner -public let defaultLoadingViewBackgroundColor = Color(UIColor.clear) - -public struct RefreshableScrollView: View where Progress: View, Content: View { - let showsIndicators: Bool // if the ScrollView should show indicators - let shouldTriggerHapticFeedback: Bool // if key actions should trigger haptic feedback - let loadingViewBackgroundColor: Color - let threshold: CGFloat // what height do you have to pull down to trigger the refresh - let onRefresh: OnRefresh // the refreshing action - let progress: RefreshProgressBuilder // custom progress view - let content: () -> Content // the ScrollView content - @State private var offset: CGFloat = 0 - @State private var state = RefreshState.waiting // the current state - // Haptic Feedback - let finishedReloadingFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) - let primedFeedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) - - // We use a custom constructor to allow for usage of a @ViewBuilder for the content - public init(showsIndicators: Bool = true, - shouldTriggerHapticFeedback: Bool = false, - loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, - threshold: CGFloat = defaultRefreshThreshold, - onRefresh: @escaping OnRefresh, - @ViewBuilder progress: @escaping RefreshProgressBuilder, - @ViewBuilder content: @escaping () -> Content) { - self.showsIndicators = showsIndicators - self.shouldTriggerHapticFeedback = shouldTriggerHapticFeedback - self.loadingViewBackgroundColor = loadingViewBackgroundColor - self.threshold = threshold - self.onRefresh = onRefresh - self.progress = progress - self.content = content - } - - public var body: some View { - // The root view is a regular ScrollView - ScrollView(showsIndicators: showsIndicators) { - // The ZStack allows us to position the PositionIndicator, - // the content and the loading view, all on top of each other. - ZStack(alignment: .top) { - // The moving positioning indicator, that sits at the top - // of the ScrollView and scrolls down with the content - PositionIndicator(type: .moving) - .frame(height: 0) - - // Your ScrollView content. If we're loading, we want - // to keep it below the loading view, hence the alignmentGuide. - content() - .alignmentGuide(.top, computeValue: { _ in - (state == .loading) ? -threshold + max(0, offset) : 0 - }) - - // The loading view. It's offset to the top of the content unless we're loading. - ZStack { - Rectangle() - .foregroundColor(loadingViewBackgroundColor) - .frame(height: threshold) - progress(state) - }.offset(y: (state == .loading) ? -max(0, offset) : -threshold) - } - } - // Put a fixed PositionIndicator in the background so that we have - // a reference point to compute the scroll offset. - .background(PositionIndicator(type: .fixed)) - // Once the scrolling offset changes, we want to see if there should - // be a state change. - .onPreferenceChange(PositionPreferenceKey.self) { values in - DispatchQueue.main.async { - // Compute the offset between the moving and fixed PositionIndicators - let movingY = values.first { $0.type == .moving }?.y ?? 0 - let fixedY = values.first { $0.type == .fixed }?.y ?? 0 - offset = movingY - fixedY - if state != .loading { // If we're already loading, ignore everything - // Map the preference change action to the UI thread - // If the user pulled down below the threshold, prime the view - if offset > threshold && state == .waiting { - state = .primed - if shouldTriggerHapticFeedback { - self.primedFeedbackGenerator.impactOccurred() - } - - // If the view is primed and we've crossed the threshold again on the - // way back, trigger the refresh - } else if offset < threshold && state == .primed { - state = .loading - onRefresh { // trigger the refreshing callback - // once refreshing is done, smoothly move the loading view - // back to the offset position - withAnimation { - self.state = .waiting - } - if shouldTriggerHapticFeedback { - self.finishedReloadingFeedbackGenerator.impactOccurred() - } - } - } - } - } - } - } -} - -// Extension that uses default RefreshActivityIndicator so that you don't have to -// specify it every time. -public extension RefreshableScrollView where Progress == RefreshActivityIndicator { - init(showsIndicators: Bool = true, - loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, - threshold: CGFloat = defaultRefreshThreshold, - onRefresh: @escaping OnRefresh, - @ViewBuilder content: @escaping () -> Content) { - self.init(showsIndicators: showsIndicators, - loadingViewBackgroundColor: loadingViewBackgroundColor, - threshold: threshold, - onRefresh: onRefresh, - progress: { state in - RefreshActivityIndicator(isAnimating: state == .loading) { - $0.hidesWhenStopped = false - } - }, - content: content) - } -} - -// Wraps a UIActivityIndicatorView as a loading spinner that works on all SwiftUI versions. -public struct RefreshActivityIndicator: UIViewRepresentable { - public typealias UIView = UIActivityIndicatorView - public var isAnimating: Bool = true - public var configuration = { (indicator: UIView) in } - - public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) { - self.isAnimating = isAnimating - if let configuration = configuration { - self.configuration = configuration - } - } - - public func makeUIView(context: UIViewRepresentableContext) -> UIView { - UIView() - } - - public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) { - isAnimating ? uiView.startAnimating() : uiView.stopAnimating() - configuration(uiView) - } -} - -#if compiler(>=5.5) -// Allows using RefreshableScrollView with an async block. -@available(iOS 15.0, *) -public extension RefreshableScrollView { - init(showsIndicators: Bool = true, - loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, - threshold: CGFloat = defaultRefreshThreshold, - action: @escaping @Sendable () async -> Void, - @ViewBuilder progress: @escaping RefreshProgressBuilder, - @ViewBuilder content: @escaping () -> Content) { - self.init(showsIndicators: showsIndicators, - loadingViewBackgroundColor: loadingViewBackgroundColor, - threshold: threshold, - onRefresh: { refreshComplete in - Task { - await action() - refreshComplete() - } - }, - progress: progress, - content: content) - } -} -#endif - -public struct RefreshableCompat: ViewModifier where Progress: View { +public struct RefreshableScrollView: View { + @StateObject private var viewModel = RefreshableScrollViewModel() + + private let content: () -> Content private let showsIndicators: Bool - private let loadingViewBackgroundColor: Color - private let threshold: CGFloat - private let onRefresh: OnRefresh - private let progress: RefreshProgressBuilder - - public init(showsIndicators: Bool = true, - loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, - threshold: CGFloat = defaultRefreshThreshold, - onRefresh: @escaping OnRefresh, - @ViewBuilder progress: @escaping RefreshProgressBuilder) { + private let onRefresh: () async -> Void + + public init(showsIndicators: Bool = true, + @ViewBuilder content: @escaping () -> Content, + onRefresh: @escaping () async -> Void) { + self.content = content self.showsIndicators = showsIndicators - self.loadingViewBackgroundColor = loadingViewBackgroundColor - self.threshold = threshold self.onRefresh = onRefresh - self.progress = progress } - - public func body(content: Content) -> some View { - RefreshableScrollView(showsIndicators: showsIndicators, - loadingViewBackgroundColor: loadingViewBackgroundColor, - threshold: threshold, - onRefresh: onRefresh, - progress: progress) { - content + + private var topGeometryReader: some View { + GeometryReader { geometry in + Color.clear + .framePreferenceKey(geometry.frame(in: .global)) { frame in + self.viewModel.update(topFrame: frame) + } } } -} - -#if compiler(>=5.5) -@available(iOS 15.0, *) -public extension List { - @ViewBuilder func refreshableCompat(showsIndicators: Bool = true, - loadingViewBackgroundColor: - Color = defaultLoadingViewBackgroundColor, - threshold: CGFloat = defaultRefreshThreshold, - onRefresh: @escaping OnRefresh, - @ViewBuilder progress: - @escaping RefreshProgressBuilder) -> some View { - if #available(iOS 15.0, macOS 12.0, *) { - self.refreshable { - await withCheckedContinuation { cont in - onRefresh { - cont.resume() - } + + private var scrollViewGeometryReader: some View { + GeometryReader { geometry in + Color.clear + .framePreferenceKey(geometry.frame(in: .global)) { frame in + self.viewModel.update(scrollFrame: frame) + } + } + } + + public var body: some View { + VStack() { +// ProgressView() +// .progressViewStyle(.circular) +// .opacity(self.viewModel.isRefreshing ? 1 : 0) +// Activity + ActivityIndicator(size: self.$viewModel.progressViewHeight, isAnimating: self.$viewModel.isRefreshing) + .frame(width: self.viewModel.progressViewHeight, height: self.viewModel.progressViewHeight) + .background { self.topGeometryReader } + + ScrollView(.vertical, showsIndicators: self.showsIndicators) { + self.content() + .background { self.scrollViewGeometryReader } + } + } + .onChange(of: self.viewModel.isRefreshing) { isRefreshing in + guard isRefreshing else { return } + + Task { + await self.onRefresh() + + // In case the async method returns quickly. + // We want to keep it refreshing for some time so it is smooth. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.viewModel.endRefreshing() } } - } else { - self.modifier(RefreshableCompat(showsIndicators: showsIndicators, - loadingViewBackgroundColor: loadingViewBackgroundColor, - threshold: threshold, - onRefresh: onRefresh, - progress: progress)) } } } -#endif -public extension View { - @ViewBuilder func refreshableCompat(showsIndicators: Bool = true, - loadingViewBackgroundColor: - Color = defaultLoadingViewBackgroundColor, - threshold: CGFloat = defaultRefreshThreshold, - onRefresh: @escaping OnRefresh, - @ViewBuilder progress: - @escaping RefreshProgressBuilder) -> some View { - self.modifier(RefreshableCompat(showsIndicators: showsIndicators, - loadingViewBackgroundColor: loadingViewBackgroundColor, - threshold: threshold, - onRefresh: onRefresh, - progress: progress)) +struct RefreshableScrollView_Previews: PreviewProvider { + static var previews: some View { + RefreshableScrollView(showsIndicators: true) { + Text("Hi") + Text("World") + Text("Hello") + } onRefresh: { + print("Refreshing") + } } } -public struct RefreshableScrollViewIOS14: View { - public let onRefresh: OnRefresh // the refreshing action - public let content: Content // the ScrollView content - private let THRESHOLD: CGFloat = 100 +final class RefreshableScrollViewModel: ObservableObject { + @Published var progressViewHeight: CGFloat = 0 + @Published var isRefreshing = false - @State private var state = RefreshState.waiting // the current state + let progressViewMaxHeight: CGFloat + private let scrollPositionSubject = CurrentValueSubject(0) + private let closingAnimationDuration: Double = 0.15 + private var subscriptions: Set = [] - // We use a custom constructor to allow for usage of a @ViewBuilder for the content - public init(onRefresh: @escaping OnRefresh, @ViewBuilder content: () -> Content) { - self.onRefresh = onRefresh - self.content = content() + private var topYValue: CGFloat? + private var scrollYValue: CGFloat? + private var startingDistance: CGFloat? + private var isClosing = false + + /// - Parameter activityIndicatorStyle: Used to derive the size of the indicator. Might be better to get in another way. In case Apple changes the sizes + init(activityIndicatorStyle: UIActivityIndicatorView.Style = .medium) { + self.progressViewMaxHeight = activityIndicatorStyle == .large ? 35 : 27 + self.reactToScrollEnding() } - public var body: some View { - // The root view is a regular ScrollView - ScrollView { - // The ZStack allows us to position the PositionIndicator, - // the content and the loading view, all on top of each other. - ZStack(alignment: .top) { - // The moving positioning indicator, that sits at the top - // of the ScrollView and scrolls down with the content - PositionIndicator(type: .moving) - .frame(height: 0) - - // Your ScrollView content. If we're loading, we want - // to keep it below the loading view, hence the alignmentGuide. - content - .alignmentGuide(.top, computeValue: { _ in - (state == .loading) ? -THRESHOLD : 0 - }) + private func reactToScrollEnding() { + self.scrollPositionSubject + .debounce(for: 0.1, scheduler: RunLoop.main, options: nil) + .sink { [weak self] _ in + guard self?.progressViewHeight != 0, + self?.isRefreshing != true + else { return } - // The loading view. It's offset to the top of the content unless we're loading. - ZStack { - Rectangle() - .foregroundColor(.clear) - .frame(height: THRESHOLD) - - ActivityIndicator(isAnimating: state == .loading) { - $0.hidesWhenStopped = false - } - }.offset(y: (state == .loading) ? 0 : -THRESHOLD) + self?.reset() } + .store(in: &self.subscriptions) + } + + /// Updates the progressViewHeight and progressViewIsAnimating properties based on the given topFrame and any existing scrollYValue, if any + /// - Parameter topFrame: CGRect + func update(topFrame: CGRect) { + let topY = topFrame.minY + self.topYValue = topY + guard let scrollY = self.scrollYValue else { return } + + self.update(topY: topY, scrollY: scrollY) + } + + /// Updates the progressViewHeight and progressViewIsAnimating properties based on the given scrollFrame and any existing topYValue, if any + /// - Parameter scrollFrame: CGRect + func update(scrollFrame: CGRect) { + let scrollY = scrollFrame.minY + self.scrollYValue = scrollY + self.scrollPositionSubject.send(scrollY) + guard let topY = self.topYValue else { return } + + self.update(topY: topY, scrollY: scrollY) + } + + /// Stops refreshing and hides the progress view + func endRefreshing() { + self.reset() + + DispatchQueue.main.asyncAfter(deadline: .now() + self.closingAnimationDuration) { + self.isRefreshing = false } - // Put a fixed PositionIndicator in the background so that we have - // a reference point to compute the scroll offset. - .background(PositionIndicator(type: .fixed)) - // Once the scrolling offset changes, we want to see if there should - // be a state change. - .onPreferenceChange(PositionPreferenceKey.self) { values in - if state != .loading { // If we're already loading, ignore everything - // Map the preference change action to the UI thread - DispatchQueue.main.async { - // Compute the offset between the moving and fixed PositionIndicators - let movingY = values.first { $0.type == .moving }?.y ?? 0 - let fixedY = values.first { $0.type == .fixed }?.y ?? 0 - let offset = movingY - fixedY - - // If the user pulled down below the threshold, prime the view - if offset > THRESHOLD && state == .waiting { - state = .primed - - // If the view is primed and we've crossed the threshold again on the - // way back, trigger the refresh - } else if offset < THRESHOLD && state == .primed { - state = .loading - self.state = .waiting - - onRefresh { // trigger the refreshing callback - // once refreshing is done, smoothly move the loading view - // back to the offset position - // withAnimation { - // self.state = .waiting - // } - } - } - } - } + } + + private func reset() { + self.isClosing = true + let topY = self.topYValue ?? 0 + let startDistance = self.startingDistance ?? 0 + let startingScrollYValue = topY + startDistance + self.scrollYValue = startingScrollYValue + + withAnimation(.linear(duration: self.closingAnimationDuration)) { + self.progressViewHeight = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + self.closingAnimationDuration) { + self.isClosing = false + } + } + + private func update(topY: CGFloat, scrollY: CGFloat) { + // Don't react to updates while animating closed + guard !self.isClosing else { return } + + let newDistance = max(scrollY - topY, 0) + + if self.startingDistance == nil { + self.startingDistance = newDistance + } + + let differenceFromStart = newDistance - self.startingDistance! + let constrainedDifference = min(max(differenceFromStart, 0), self.progressViewMaxHeight) + + // Don't change the height of the progress view if we are refreshing + guard !isRefreshing else { return } + + DispatchQueue.main.async { + self.progressViewHeight = constrainedDifference + self.isRefreshing = constrainedDifference == self.progressViewMaxHeight } } } +struct FramePreferenceKey: PreferenceKey { + static var defaultValue: CGRect = .zero + + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + value = nextValue() + } +} + +extension View { + func framePreferenceKey(_ value: CGRect, onFrameChange: @escaping (CGRect) -> Void) -> some View { + self + .preference(key: FramePreferenceKey.self, value: value) + .onPreferenceChange(FramePreferenceKey.self, perform: onFrameChange) + } +} + struct ActivityIndicator: UIViewRepresentable { - public typealias UIView = UIActivityIndicatorView - public var isAnimating: Bool = true - public var configuration = { (indicator: UIView) in } + @Binding var size: CGFloat + @Binding var isAnimating: Bool + private let style: UIActivityIndicatorView.Style - public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) { - self.isAnimating = isAnimating - if let configuration = configuration { - self.configuration = configuration - } + init(style: UIActivityIndicatorView.Style = .medium, size: Binding, isAnimating: Binding) { + self._size = size + self._isAnimating = isAnimating + self.style = style } - public func makeUIView(context: UIViewRepresentableContext) -> UIView { - let uiView = UIView() - uiView.startAnimating() - return uiView + func makeUIView(context: Context) -> UIView { + let activityIndicator = UIActivityIndicatorView(style: self.style) + activityIndicator.hidesWhenStopped = false + + if self.isAnimating { + activityIndicator.startAnimating() + } + + let containerView = UIView() + containerView.layer.cornerRadius = self.size / 2 + containerView.clipsToBounds = true + + containerView.addSubview(activityIndicator) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator + .centerXAnchor + .constraint(equalTo: containerView.centerXAnchor) + .isActive = true + activityIndicator + .centerYAnchor + .constraint(equalTo: containerView.centerYAnchor) + .isActive = true + + return containerView } - public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) { -// isAnimating ? uiView.startAnimating() : uiView.stopAnimating() - configuration(uiView) + func updateUIView(_ uiView: UIView, context: Context) { + uiView.layer.cornerRadius = self.size / 2 + + guard let activityIndicator = uiView.subviews.first(where: { $0 is UIActivityIndicatorView }) as? UIActivityIndicatorView + else { return } + + if self.isAnimating { + activityIndicator.startAnimating() + } else { + activityIndicator.stopAnimating() + } } - } +} diff --git a/Core/Core/View/Base/RefreshableScrollViewCompat.swift b/Core/Core/View/Base/RefreshableScrollViewCompat.swift deleted file mode 100644 index 446e472be..000000000 --- a/Core/Core/View/Base/RefreshableScrollViewCompat.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// RefreshableScrollViewCompat.swift -// Core -// -// Created by  Stepanok Ivan on 15.02.2023. -// - -import SwiftUI - -public struct RefreshableScrollViewCompat: View where Content: View { - private let content: () -> Content - private let action: () async -> Void - - public init(action: @escaping () async -> Void, @ViewBuilder content: @escaping () -> Content) { - self.action = action - self.content = content - } - - public var body: some View { - if #available(iOS 15.0, *) { - return RefreshableScrollView(onRefresh: { done in - Task { - await action() - done() - } - }) { - content() - } - } else { - return RefreshableScrollViewIOS14(onRefresh: { done in - Task { - await action() - done() - } - }) { - content() - } - } - } -} diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 910e26671..c80d63032 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -44,7 +44,7 @@ 02B6B3B228E1C49400232911 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 02B6B3B428E1C49400232911 /* Localizable.strings */; }; 02B6B3B728E1D11E00232911 /* CourseInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B6B3B628E1D11E00232911 /* CourseInteractor.swift */; }; 02B6B3BC28E1D14F00232911 /* CourseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B6B3BB28E1D14F00232911 /* CourseRepository.swift */; }; - 02B6B3BE28E1D15C00232911 /* CourseDetailsEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B6B3BD28E1D15C00232911 /* CourseDetailsEndpoint.swift */; }; + 02B6B3BE28E1D15C00232911 /* CourseEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B6B3BD28E1D15C00232911 /* CourseEndpoint.swift */; }; 02B6B3C128E1DBA100232911 /* Data_CourseDetailsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B6B3C028E1DBA100232911 /* Data_CourseDetailsResponse.swift */; }; 02B6B3C328E1DCD100232911 /* CourseDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B6B3C228E1DCD100232911 /* CourseDetails.swift */; }; 02B6B3C928E1E68100232911 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02B6B3C828E1E68100232911 /* Core.framework */; }; @@ -115,7 +115,7 @@ 02B6B3B328E1C49400232911 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 02B6B3B628E1D11E00232911 /* CourseInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseInteractor.swift; sourceTree = ""; }; 02B6B3BB28E1D14F00232911 /* CourseRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRepository.swift; sourceTree = ""; }; - 02B6B3BD28E1D15C00232911 /* CourseDetailsEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDetailsEndpoint.swift; sourceTree = ""; }; + 02B6B3BD28E1D15C00232911 /* CourseEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseEndpoint.swift; sourceTree = ""; }; 02B6B3C028E1DBA100232911 /* Data_CourseDetailsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDetailsResponse.swift; sourceTree = ""; }; 02B6B3C228E1DCD100232911 /* CourseDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDetails.swift; sourceTree = ""; }; 02B6B3C828E1E68100232911 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -278,7 +278,7 @@ 02B6B3B928E1D13500232911 /* Network */ = { isa = PBXGroup; children = ( - 02B6B3BD28E1D15C00232911 /* CourseDetailsEndpoint.swift */, + 02B6B3BD28E1D15C00232911 /* CourseEndpoint.swift */, ); path = Network; sourceTree = ""; @@ -715,7 +715,7 @@ 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02E685BE28E4B60A000AE015 /* CourseDetailsView.swift in Sources */, 02F175372A4DAFD20019CD70 /* CourseAnalytics.swift in Sources */, - 02B6B3BE28E1D15C00232911 /* CourseDetailsEndpoint.swift in Sources */, + 02B6B3BE28E1D15C00232911 /* CourseEndpoint.swift in Sources */, 02B6B3C328E1DCD100232911 /* CourseDetails.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -752,7 +752,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -773,7 +773,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -794,7 +794,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -815,7 +815,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -836,7 +836,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -857,7 +857,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1007,7 +1007,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1042,7 +1042,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1140,7 +1140,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1239,7 +1239,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1332,7 +1332,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1424,7 +1424,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1522,7 +1522,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1550,7 +1550,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1636,7 +1636,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1663,7 +1663,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; diff --git a/Course/Course/Data/Network/CourseDetailsEndpoint.swift b/Course/Course/Data/Network/CourseEndpoint.swift similarity index 100% rename from Course/Course/Data/Network/CourseDetailsEndpoint.swift rename to Course/Course/Data/Network/CourseEndpoint.swift diff --git a/Course/Course/Presentation/Details/CourseDetailsView.swift b/Course/Course/Presentation/Details/CourseDetailsView.swift index f8f9ebf0c..5c5edada2 100644 --- a/Course/Course/Presentation/Details/CourseDetailsView.swift +++ b/Course/Course/Presentation/Details/CourseDetailsView.swift @@ -47,9 +47,7 @@ public struct CourseDetailsView: View { .padding(.horizontal) }.frame(width: proxy.size.width) } else { - RefreshableScrollViewCompat(action: { - await viewModel.getCourseDetail(courseID: courseID, withProgress: isIOS14) - }) { + RefreshableScrollView { VStack(alignment: .leading) { if let courseDetails = viewModel.courseDetails { @@ -133,7 +131,12 @@ public struct CourseDetailsView: View { } } } - }.frameLimit() + } onRefresh: { + Task { + await viewModel.getCourseDetail(courseID: courseID, withProgress: false) + } + }.coordinateSpace(name: "pullToRefresh") + .frameLimit() .onRightSwipeGesture { viewModel.router.back() } @@ -154,7 +157,7 @@ public struct CourseDetailsView: View { // MARK: - Offline mode SnackBar OfflineSnackBarView(connectivity: viewModel.connectivity, reloadAction: { - await viewModel.getCourseDetail(courseID: courseID, withProgress: isIOS14) + await viewModel.getCourseDetail(courseID: courseID, withProgress: false) }) // MARK: - Error Alert diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index a0c38d78c..c07019c96 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -37,9 +37,7 @@ public struct CourseOutlineView: View { GeometryReader { proxy in VStack(alignment: .center) { // MARK: - Page Body - RefreshableScrollViewCompat(action: { - await viewModel.getCourseBlocks(courseID: courseID, withProgress: isIOS14) - }) { + RefreshableScrollView { VStack(alignment: .leading) { ZStack { // MARK: - Course Banner @@ -136,7 +134,10 @@ public struct CourseOutlineView: View { } Spacer(minLength: 84) } - }.frameLimit() + } onRefresh: { + await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) + }.coordinateSpace(name: "pullToRefresh") + .frameLimit() .onRightSwipeGesture { viewModel.router.back() } @@ -146,7 +147,7 @@ public struct CourseOutlineView: View { OfflineSnackBarView( connectivity: viewModel.connectivity, reloadAction: { - await viewModel.getCourseBlocks(courseID: courseID, withProgress: isIOS14) + await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) } ) diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index 7f943cdf7..9eb3ade8c 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -488,7 +488,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -509,7 +509,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -530,7 +530,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -551,7 +551,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -572,7 +572,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -593,7 +593,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -685,7 +685,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -713,7 +713,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -799,7 +799,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -826,7 +826,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -976,7 +976,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1011,7 +1011,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1109,7 +1109,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1202,7 +1202,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1300,7 +1300,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1393,7 +1393,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/DashboardView.swift index d4e63949c..59747da5d 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/DashboardView.swift @@ -33,63 +33,68 @@ public struct DashboardView: View { // MARK: - Page body VStack(alignment: .center) { - RefreshableScrollViewCompat(action: { - await viewModel.getMyCourses(page: 1, refresh: true) - }) { - if viewModel.courses.isEmpty && !viewModel.fetchInProgress { - EmptyPageIcon() - } else { - LazyVStack(spacing: 0) { - HStack { - dashboardCourses + RefreshableScrollView { + Group { + if viewModel.courses.isEmpty && !viewModel.fetchInProgress { + EmptyPageIcon() + } else { + LazyVStack(spacing: 0) { + HStack { + dashboardCourses + .padding(.horizontal, 20) + .padding(.bottom, 20) + Spacer() + }.padding(.leading, 10) + ForEach(Array(viewModel.courses.enumerated()), + id: \.offset) { index, course in + + CourseCellView( + model: course, + type: .dashboard, + index: index, + cellsCount: viewModel.courses.count + ) .padding(.horizontal, 20) - .padding(.bottom, 20) - Spacer() - }.padding(.leading, 10) - ForEach(Array(viewModel.courses.enumerated()), - id: \.offset) { index, course in - - CourseCellView( - model: course, - type: .dashboard, - index: index, - cellsCount: viewModel.courses.count - ) - .padding(.horizontal, 20) - .listRowBackground(Color.clear) - .onAppear { - Task { - await viewModel.getMyCoursesPagination(index: index) + .listRowBackground(Color.clear) + .onAppear { + Task { + await viewModel.getMyCoursesPagination(index: index) + } + } + .onTapGesture { + viewModel.trackDashboardCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + router.showCourseScreens( + courseID: course.courseID, + isActive: course.isActive, + courseStart: course.courseStart, + courseEnd: course.courseEnd, + enrollmentStart: course.enrollmentStart, + enrollmentEnd: course.enrollmentEnd, + title: course.name + ) } } - .onTapGesture { - viewModel.trackDashboardCourseClicked( - courseID: course.courseID, - courseName: course.name - ) - router.showCourseScreens( - courseID: course.courseID, - isActive: course.isActive, - courseStart: course.courseStart, - courseEnd: course.courseEnd, - enrollmentStart: course.enrollmentStart, - enrollmentEnd: course.enrollmentEnd, - title: course.name - ) + // MARK: - ProgressBar + if viewModel.nextPage <= viewModel.totalPages { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) } + VStack {}.frame(height: 40) } - // MARK: - ProgressBar - if viewModel.nextPage <= viewModel.totalPages { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) - } - VStack {}.frame(height: 40) } } - }.frameLimit() + } onRefresh: { + Task { + await viewModel.getMyCourses(page: 1, refresh: true) + } + }.coordinateSpace(name: "pullToRefresh") + .frameLimit() }.padding(.top, 8) // MARK: - Offline mode SnackBar diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index 632cd869e..05974ad4f 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -514,7 +514,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -535,7 +535,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -556,7 +556,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -577,7 +577,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -598,7 +598,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -619,7 +619,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -711,7 +711,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -739,7 +739,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -825,7 +825,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -852,7 +852,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -1002,7 +1002,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1037,7 +1037,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1135,7 +1135,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1234,7 +1234,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1327,7 +1327,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1419,7 +1419,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Discovery/Discovery/Presentation/DiscoveryView.swift b/Discovery/Discovery/Presentation/DiscoveryView.swift index 7341e8684..e8f6f52d4 100644 --- a/Discovery/Discovery/Presentation/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/DiscoveryView.swift @@ -66,54 +66,56 @@ public struct DiscoveryView: View { .padding(.bottom, 20) ZStack { - RefreshableScrollViewCompat(action: { - viewModel.courses = [] - viewModel.totalPages = 1 - viewModel.nextPage = 1 - await viewModel.discovery(page: 1) - }) { - LazyVStack(spacing: 0) { - HStack { - discoveryNew - .padding(.horizontal, 20) - .padding(.bottom, 20) - Spacer() - }.padding(.leading, 10) - ForEach(Array(viewModel.courses.enumerated()), id: \.offset) { index, course in - CourseCellView( - model: course, - type: .discovery, - index: index, - cellsCount: viewModel.courses.count - ).padding(.horizontal, 24) - .onAppear { - Task { - await viewModel.getDiscoveryCourses(index: index) + RefreshableScrollView { + LazyVStack(spacing: 0) { + HStack { + discoveryNew + .padding(.horizontal, 20) + .padding(.bottom, 20) + Spacer() + }.padding(.leading, 10) + ForEach(Array(viewModel.courses.enumerated()), id: \.offset) { index, course in + CourseCellView( + model: course, + type: .discovery, + index: index, + cellsCount: viewModel.courses.count + ).padding(.horizontal, 24) + .onAppear { + Task { + await viewModel.getDiscoveryCourses(index: index) + } } - } - .onTapGesture { - viewModel.discoveryCourseClicked( - courseID: course.courseID, - courseName: course.name - ) - router.showCourseDetais( - courseID: course.courseID, - title: course.name - ) - } - } - - // MARK: - ProgressBar - if viewModel.nextPage <= viewModel.totalPages { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) + .onTapGesture { + viewModel.discoveryCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + router.showCourseDetais( + courseID: course.courseID, + title: course.name + ) + } + } + + // MARK: - ProgressBar + if viewModel.nextPage <= viewModel.totalPages { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) + } + VStack {}.frame(height: 40) } - VStack {}.frame(height: 40) + } onRefresh: { + viewModel.totalPages = 1 + viewModel.nextPage = 1 + Task { + await viewModel.discovery(page: 1, withProgress: false) } }.frameLimit() + .coordinateSpace(name: "pullToRefresh") } }.padding(.top, 8) @@ -121,10 +123,7 @@ public struct DiscoveryView: View { OfflineSnackBarView( connectivity: viewModel.connectivity, reloadAction: { - viewModel.courses = [] - viewModel.totalPages = 1 - viewModel.nextPage = 1 - await viewModel.discovery(page: 1, withProgress: isIOS14) + await viewModel.discovery(page: 1, withProgress: false) }) // MARK: - Error Alert diff --git a/Discovery/Discovery/Presentation/DiscoveryViewModel.swift b/Discovery/Discovery/Presentation/DiscoveryViewModel.swift index 568a37a9b..37514275b 100644 --- a/Discovery/Discovery/Presentation/DiscoveryViewModel.swift +++ b/Discovery/Discovery/Presentation/DiscoveryViewModel.swift @@ -60,7 +60,13 @@ public class DiscoveryViewModel: ObservableObject { fetchInProgress = withProgress do { if connectivity.isInternetAvaliable { - await courses += try interactor.discovery(page: page) + if page == 1 { + await courses = try interactor.discovery(page: page) + self.totalPages = 1 + self.nextPage = 1 + } else { + await courses += try interactor.discovery(page: page) + } self.nextPage += 1 if !courses.isEmpty { totalPages = courses[0].numPages diff --git a/Discussion/Discussion.xcodeproj/project.pbxproj b/Discussion/Discussion.xcodeproj/project.pbxproj index cfd6abaff..9a659e4a7 100644 --- a/Discussion/Discussion.xcodeproj/project.pbxproj +++ b/Discussion/Discussion.xcodeproj/project.pbxproj @@ -882,7 +882,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -916,7 +916,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1013,7 +1013,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1111,7 +1111,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1203,7 +1203,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1294,7 +1294,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1321,7 +1321,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1342,7 +1342,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1363,7 +1363,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1384,7 +1384,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1405,7 +1405,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1426,7 +1426,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1517,7 +1517,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1545,7 +1545,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1630,7 +1630,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1657,7 +1657,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 2be19cf06..b079cb94c 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -36,145 +36,148 @@ public struct ResponsesView: View { public var body: some View { ZStack(alignment: .top) { - // MARK: - Page Body - ScrollViewReader { scroll in - VStack { - ZStack(alignment: .top) { - RefreshableScrollViewCompat(action: { - viewModel.comments = [] - _ = await viewModel.getComments(commentID: commentID, - parentComment: parentComment, page: 1) - }) { - VStack { - if let comments = viewModel.postComments { - ParentCommentView( - comments: comments, - isThread: false, + // MARK: - Page Body + ScrollViewReader { scroll in + VStack { + ZStack(alignment: .top) { + RefreshableScrollView { + VStack { + if let comments = viewModel.postComments { + ParentCommentView( + comments: comments, + isThread: false, + onLikeTap: { + Task { + if await viewModel.vote( + id: parentComment.commentID, + isThread: false, + voted: comments.voted, + index: nil + ) { + viewModel.sendThreadLikeState() + } + } + }, + onReportTap: { + Task { + if await viewModel.flag( + id: parentComment.commentID, + isThread: false, + abuseFlagged: comments.abuseFlagged, + index: nil + ) { + viewModel.sendThreadReportState() + } + + } + }, + onFollowTap: {} + ) + HStack { + Text("\(viewModel.itemsCount)") + Text(DiscussionLocalization.commentsCount(viewModel.itemsCount)) + Spacer() + }.padding(.top, 40) + .padding(.bottom, 14) + .padding(.leading, 24) + .font(Theme.Fonts.titleMedium) + ForEach( + Array(comments.comments.enumerated()), id: \.offset + ) { index, comment in + CommentCell( + comment: comment, + addCommentAvailable: false, leftLineEnabled: true, onLikeTap: { Task { - if await viewModel.vote( - id: parentComment.commentID, + await viewModel.vote( + id: comment.commentID, isThread: false, - voted: comments.voted, - index: nil - ) { - viewModel.sendThreadLikeState() - } + voted: comment.voted, + index: index + ) } }, onReportTap: { Task { - if await viewModel.flag( - id: parentComment.commentID, + await viewModel.flag( + id: comment.commentID, isThread: false, - abuseFlagged: comments.abuseFlagged, - index: nil - ) { - viewModel.sendThreadReportState() - } - + abuseFlagged: comment.abuseFlagged, + index: index + ) } }, - onFollowTap: {} - ) - HStack { - Text("\(viewModel.itemsCount)") - Text(DiscussionLocalization.commentsCount(viewModel.itemsCount)) - Spacer() - }.padding(.top, 40) - .padding(.bottom, 14) - .padding(.leading, 24) - .font(Theme.Fonts.titleMedium) - ForEach( - Array(comments.comments.enumerated()), id: \.offset - ) { index, comment in - CommentCell( - comment: comment, - addCommentAvailable: false, leftLineEnabled: true, - onLikeTap: { - Task { - await viewModel.vote( - id: comment.commentID, - isThread: false, - voted: comment.voted, - index: index - ) - } - }, - onReportTap: { - Task { - await viewModel.flag( - id: comment.commentID, - isThread: false, - abuseFlagged: comment.abuseFlagged, - index: index - ) - } - }, - onCommentsTap: {}, - onFetchMore: { - Task { - await viewModel.fetchMorePosts( - commentID: commentID, - parentComment: parentComment, - index: index - ) - } + onCommentsTap: {}, + onFetchMore: { + Task { + await viewModel.fetchMorePosts( + commentID: commentID, + parentComment: parentComment, + index: index + ) } - ) - .id(index) - .padding(.bottom, -8) - } - if viewModel.nextPage <= viewModel.totalPages { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) - } + } + ) + .id(index) + .padding(.bottom, -8) + } + if viewModel.nextPage <= viewModel.totalPages { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) } - Spacer(minLength: 84) - } - .onRightSwipeGesture { - viewModel.router.back() } - }.frameLimit() - - if !parentComment.closed { - FlexibleKeyboardInputView( - hint: DiscussionLocalization.Response.addComment, - sendText: { commentText in - if let threadID = viewModel.postComments?.threadID { - Task { - await viewModel.postComment( - threadID: threadID, - rawBody: commentText, - parentID: commentID - ) - } + Spacer(minLength: 84) + } + .onRightSwipeGesture { + viewModel.router.back() + } + } onRefresh: { + viewModel.comments = [] + Task { + _ = await viewModel.getComments(commentID: commentID, + parentComment: parentComment, page: 1) + } + }.coordinateSpace(name: "pullToRefresh") + .frameLimit() + + if !parentComment.closed { + FlexibleKeyboardInputView( + hint: DiscussionLocalization.Response.addComment, + sendText: { commentText in + if let threadID = viewModel.postComments?.threadID { + Task { + await viewModel.postComment( + threadID: threadID, + rawBody: commentText, + parentID: commentID + ) } } - ) - } + } + ) } } - .onReceive(viewModel.addPostSubject, perform: { newComment in - guard let newComment else { return } - viewModel.sendThreadPostsCountState() - if viewModel.nextPage - 1 == viewModel.totalPages { - viewModel.addNewPost(newComment) - withAnimation { - guard let count = viewModel.postComments?.comments.count else { return } - scroll.scrollTo(count - 2, anchor: .top) - } - } else { - viewModel.alertMessage = DiscussionLocalization.Response.Alert.commentAdded - viewModel.showAlert = true + } + .onReceive(viewModel.addPostSubject, perform: { newComment in + guard let newComment else { return } + viewModel.sendThreadPostsCountState() + if viewModel.nextPage - 1 == viewModel.totalPages { + viewModel.addNewPost(newComment) + withAnimation { + guard let count = viewModel.postComments?.comments.count else { return } + scroll.scrollTo(count - 2, anchor: .top) } - }) - .frame(maxWidth: .infinity, maxHeight: .infinity) - }.scrollAvoidKeyboard(dismissKeyboardByTap: true) - .padding(.top, 8) + } else { + viewModel.alertMessage = DiscussionLocalization.Response.Alert.commentAdded + viewModel.showAlert = true + } + }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + }.scrollAvoidKeyboard(dismissKeyboardByTap: true) + .padding(.top, 8) // MARK: - Error Alert if viewModel.showError { VStack { diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index f942a4b6c..7bbbdae0a 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -34,10 +34,7 @@ public struct ThreadView: View { ScrollViewReader { scroll in VStack { ZStack(alignment: .top) { - RefreshableScrollViewCompat(action: { - viewModel.comments = [] - _ = await viewModel.getPosts(thread: thread, page: 1) - }) { + RefreshableScrollView { VStack { if let comments = viewModel.postComments { ParentCommentView( @@ -143,7 +140,11 @@ public struct ThreadView: View { onBackTapped() viewModel.sendUpdateUnreadState() } - } + } onRefresh: { + Task { + _ = await viewModel.getPosts(thread: thread, page: 1) + } + }.coordinateSpace(name: "pullToRefresh") if !thread.closed { FlexibleKeyboardInputView( hint: DiscussionLocalization.Thread.addResponse, diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift index bd8edf468..35870b6ee 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift @@ -135,7 +135,11 @@ public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { .getQuestionComments(threadID: thread.id, page: page) self.totalPages = pagination.numPages self.itemsCount = pagination.count - self.comments += comments + if page == 1 { + self.comments = comments + } else { + self.comments += comments + } postComments = generateComments(comments: self.comments, thread: thread) case .discussion: let (comments, pagination) = try await interactor diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index 73ac31b33..4629b9956 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -53,11 +53,8 @@ public struct DiscussionTopicsView: View { // MARK: - Page Body VStack { ZStack(alignment: .top) { - RefreshableScrollViewCompat(action: { - await viewModel.getTopics(courseID: self.courseID, withProgress: isIOS14) - }) { + RefreshableScrollView { VStack { - if let topics = viewModel.discussionTopics { HStack { Text(DiscussionLocalization.Topics.mainCategories) @@ -128,10 +125,16 @@ public struct DiscussionTopicsView: View { } Spacer(minLength: 84) } - }.frameLimit() - .onRightSwipeGesture { - router.back() - } + } onRefresh: { + Task { + await viewModel.getTopics(courseID: self.courseID, withProgress: false) + } + }.coordinateSpace(name: "pullToRefresh") + .frameLimit() + .onRightSwipeGesture { + router.back() + } + } }.frame(maxWidth: .infinity) }.padding(.top, 8) diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 49d90aab2..55f0d09ab 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -83,13 +83,7 @@ public struct PostsView: View { Divider().offset(y: -8) } .frameLimit() - RefreshableScrollViewCompat(action: { - listAnimation = nil - viewModel.resetPosts() - _ = await viewModel.getPosts(courseID: courseID, - pageNumber: 1, - withProgress: isIOS14) - }) { + RefreshableScrollView { let posts = Array(viewModel.filteredPosts.enumerated()) if posts.count >= 1 { LazyVStack { @@ -174,7 +168,15 @@ public struct PostsView: View { .padding(.top, 100) } } - } + } onRefresh: { + listAnimation = nil + viewModel.resetPosts() + Task { + _ = await viewModel.getPosts(courseID: courseID, + pageNumber: 1, + withProgress: false) + } + }.coordinateSpace(name: "pullToRefresh") }.frameLimit() .animation(listAnimation) .onRightSwipeGesture { @@ -217,7 +219,7 @@ public struct PostsView: View { viewModel.resetPosts() _ = await viewModel.getPosts(courseID: courseID, pageNumber: 1, - withProgress: isIOS14) + withProgress: false) onSuccess() } } diff --git a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift index a618bdff9..f8baeba06 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift @@ -188,59 +188,112 @@ public class PostsViewModel: ObservableObject { } } + // swiftlint:disable function_body_length @MainActor public func getPosts(courseID: String, pageNumber: Int, withProgress: Bool = true) async -> Bool { fetchInProgress = true isShowProgress = withProgress do { - switch type { - case .allPosts: - threads.threads += try await interactor - .getThreadsList(courseID: courseID, - type: .allPosts, - sort: sortTitle, - filter: filterTitle, - page: pageNumber).threads - if threads.threads.indices.contains(0) { - self.totalPages = threads.threads[0].numPages - self.nextPage += 1 - } - case .followingPosts: - threads.threads += try await interactor - .getThreadsList(courseID: courseID, - type: .followingPosts, - sort: sortTitle, - filter: filterTitle, - page: pageNumber).threads - if threads.threads.indices.contains(0) { - self.totalPages = threads.threads[0].numPages - self.nextPage += 1 - } - case .nonCourseTopics: - threads.threads += try await interactor - .getThreadsList(courseID: courseID, - type: .nonCourseTopics, - sort: sortTitle, - filter: filterTitle, - page: pageNumber).threads - if threads.threads.indices.contains(0) { - self.totalPages = threads.threads[0].numPages - self.nextPage += 1 + if pageNumber == 1 { + switch type { + case .allPosts: + threads.threads = try await interactor + .getThreadsList(courseID: courseID, + type: .allPosts, + sort: sortTitle, + filter: filterTitle, + page: pageNumber).threads + if threads.threads.indices.contains(0) { + self.totalPages = threads.threads[0].numPages + self.nextPage = 2 + } + case .followingPosts: + threads.threads = try await interactor + .getThreadsList(courseID: courseID, + type: .followingPosts, + sort: sortTitle, + filter: filterTitle, + page: pageNumber).threads + if threads.threads.indices.contains(0) { + self.totalPages = threads.threads[0].numPages + self.nextPage = 2 + } + case .nonCourseTopics: + threads.threads = try await interactor + .getThreadsList(courseID: courseID, + type: .nonCourseTopics, + sort: sortTitle, + filter: filterTitle, + page: pageNumber).threads + if threads.threads.indices.contains(0) { + self.totalPages = threads.threads[0].numPages + self.nextPage = 2 + } + case .courseTopics(topicID: let topicID): + threads.threads = try await interactor + .getThreadsList(courseID: courseID, + type: .courseTopics(topicID: topicID), + sort: sortTitle, + filter: filterTitle, + page: pageNumber).threads + if threads.threads.indices.contains(0) { + self.totalPages = threads.threads[0].numPages + self.nextPage = 2 + } + case .none: + isShowProgress = false + return false } - case .courseTopics(topicID: let topicID): - threads.threads += try await interactor - .getThreadsList(courseID: courseID, - type: .courseTopics(topicID: topicID), - sort: sortTitle, - filter: filterTitle, - page: pageNumber).threads - if threads.threads.indices.contains(0) { - self.totalPages = threads.threads[0].numPages - self.nextPage += 1 + } else { + switch type { + case .allPosts: + threads.threads += try await interactor + .getThreadsList(courseID: courseID, + type: .allPosts, + sort: sortTitle, + filter: filterTitle, + page: pageNumber).threads + if threads.threads.indices.contains(0) { + self.totalPages = threads.threads[0].numPages + self.nextPage += 1 + } + case .followingPosts: + threads.threads += try await interactor + .getThreadsList(courseID: courseID, + type: .followingPosts, + sort: sortTitle, + filter: filterTitle, + page: pageNumber).threads + if threads.threads.indices.contains(0) { + self.totalPages = threads.threads[0].numPages + self.nextPage += 1 + } + case .nonCourseTopics: + threads.threads += try await interactor + .getThreadsList(courseID: courseID, + type: .nonCourseTopics, + sort: sortTitle, + filter: filterTitle, + page: pageNumber).threads + if threads.threads.indices.contains(0) { + self.totalPages = threads.threads[0].numPages + self.nextPage += 1 + } + case .courseTopics(topicID: let topicID): + threads.threads += try await interactor + .getThreadsList(courseID: courseID, + type: .courseTopics(topicID: topicID), + sort: sortTitle, + filter: filterTitle, + page: pageNumber).threads + if threads.threads.indices.contains(0) { + self.totalPages = threads.threads[0].numPages + self.nextPage += 1 + } + case .none: + isShowProgress = false + return false } - case .none: - isShowProgress = false - return false } discussionPosts = generatePosts(threads: threads) filteredPosts = discussionPosts @@ -259,6 +312,7 @@ public class PostsViewModel: ObservableObject { return false } } + // swiftlint:enable function_body_length private func updateUnreadCommentsCount(id: String) { var threads = threads.threads diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 715431a35..8aecb5c84 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -511,7 +511,7 @@ INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -600,7 +600,7 @@ INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -695,7 +695,7 @@ INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -784,7 +784,7 @@ INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -933,7 +933,7 @@ INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -968,7 +968,7 @@ INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index ef60fd5ab..3ba3e0531 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -737,7 +737,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -772,7 +772,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -869,7 +869,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -967,7 +967,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1059,7 +1059,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1150,7 +1150,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1177,7 +1177,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1198,7 +1198,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1219,7 +1219,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1240,7 +1240,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1261,7 +1261,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1282,7 +1282,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1373,7 +1373,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1401,7 +1401,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1486,7 +1486,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1513,7 +1513,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index e1b961bf5..fca2e192f 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -22,9 +22,7 @@ public struct ProfileView: View { public var body: some View { ZStack(alignment: .top) { // MARK: - Page Body - RefreshableScrollViewCompat(action: { - await viewModel.getMyProfile(withProgress: isIOS14) - }) { + RefreshableScrollView { VStack { if viewModel.isShowProgress { ProgressBar(size: 40, lineWidth: 8) @@ -79,16 +77,17 @@ public struct ProfileView: View { .padding(.horizontal, 24) .font(Theme.Fonts.labelLarge) VStack(alignment: .leading, spacing: 27) { - HStack { Button(action: { viewModel.trackProfileVideoSettingsClicked() viewModel.router.showSettings() }, label: { + HStack { Text(ProfileLocalization.settingsVideo) Spacer() Image(systemName: "chevron.right") + } }) - } + }.cardStyle( bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear @@ -156,7 +155,6 @@ public struct ProfileView: View { // MARK: - Log out VStack { - HStack { Button(action: { viewModel.router.presentView(transitionStyle: .crossDissolve) { AlertView( @@ -175,12 +173,15 @@ public struct ProfileView: View { ) } }, label: { + HStack { Text(ProfileLocalization.logout) Spacer() Image(systemName: "rectangle.portrait.and.arrow.right") + } }) - } - }.foregroundColor(Theme.Colors.alert) + + } + .foregroundColor(Theme.Colors.alert) .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear) .padding(.top, 24) @@ -189,7 +190,12 @@ public struct ProfileView: View { Spacer() } } - }.frameLimit(sizePortrait: 420) + } onRefresh: { + Task { + await viewModel.getMyProfile(withProgress: false) + } + }.coordinateSpace(name: "pullToRefresh") + .frameLimit(sizePortrait: 420) .padding(.top, 8) .onChange(of: settingsTapped, perform: { _ in if let userModel = viewModel.userModel { @@ -214,7 +220,7 @@ public struct ProfileView: View { // MARK: - Offline mode SnackBar OfflineSnackBarView(connectivity: viewModel.connectivity, reloadAction: { - await viewModel.getMyProfile(withProgress: isIOS14) + await viewModel.getMyProfile(withProgress: false) }) // MARK: - Error Alert From 7bc93b972005432c97c82b32c00d3b8cab62c28f Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 15 Sep 2023 17:04:33 +0300 Subject: [PATCH 16/19] Update refreshable ScrollView to native for iOS 16+ (#75) * Update refreshable ScrollView to native for iOS 16+ --- Core/Core.xcodeproj/project.pbxproj | 4 + .../View/Base/RefreshableScrollView.swift | 526 ++++++++++-------- .../Base/RefreshableScrollViewCompat.swift | 39 ++ .../Details/CourseDetailsView.swift | 11 +- .../Outline/CourseOutlineView.swift | 9 +- .../Presentation/DashboardView.swift | 11 +- .../Presentation/DiscoveryView.swift | 87 ++- .../Comments/Responses/ResponsesView.swift | 18 +- .../Responses/ResponsesViewModel.swift | 6 +- .../Comments/Thread/ThreadView.swift | 10 +- .../Comments/Thread/ThreadViewModel.swift | 6 +- .../DiscussionTopicsView.swift | 11 +- .../Presentation/Posts/PostsView.swift | 99 ++-- .../Presentation/Profile/ProfileView.swift | 11 +- 14 files changed, 486 insertions(+), 362 deletions(-) create mode 100644 Core/Core/View/Base/RefreshableScrollViewCompat.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index a9463cdcc..eed739e2e 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -68,6 +68,7 @@ 02A4833C29B8C57800D33F33 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833B29B8C57800D33F33 /* DownloadView.swift */; }; 02B2B594295C5C7A00914876 /* Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2B593295C5C7A00914876 /* Thread.swift */; }; 02B3E3B32930198600A50475 /* AVPlayerViewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */; }; + 02B3F16E2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */; }; 02C2DC0829B63D6200F4445D /* WebViewHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C2DC0729B63D6200F4445D /* WebViewHTML.swift */; }; 02C917F029CDA99E00DBB8BD /* Data_Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C917EF29CDA99E00DBB8BD /* Data_Dashboard.swift */; }; 02CF46C829546AA200A698EE /* NoCachedDataError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CF46C729546AA200A698EE /* NoCachedDataError.swift */; }; @@ -186,6 +187,7 @@ 02A4833B29B8C57800D33F33 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; 02B2B593295C5C7A00914876 /* Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thread.swift; sourceTree = ""; }; 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayerViewControllerExtension.swift; sourceTree = ""; }; + 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollViewCompat.swift; sourceTree = ""; }; 02C2DC0729B63D6200F4445D /* WebViewHTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewHTML.swift; sourceTree = ""; }; 02C917EF29CDA99E00DBB8BD /* Data_Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Dashboard.swift; sourceTree = ""; }; 02CF46C729546AA200A698EE /* NoCachedDataError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCachedDataError.swift; sourceTree = ""; }; @@ -524,6 +526,7 @@ 02C2DC0729B63D6200F4445D /* WebViewHTML.swift */, 021D925628DCF12900ACC565 /* AlertView.swift */, 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */, + 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */, 0236F3B628F4351E0050F09B /* CourseButton.swift */, 0241666A28F5A78B00082765 /* HTMLFormattedText.swift */, 0282DA7228F98CC9003C3F07 /* WebUnitView.swift */, @@ -821,6 +824,7 @@ 028CE96929858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift in Sources */, 027BD3A92909474200392132 /* KeyboardAvoidingViewControllerRepr.swift in Sources */, 02F98A7F28F81EE900DE94C0 /* Container+App.swift in Sources */, + 02B3F16E2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift in Sources */, 0727877B28D24A1D002E9142 /* HeadersRedirectHandler.swift in Sources */, 0236961B28F9A28B00EEF206 /* AuthInteractor.swift in Sources */, 0770DE3028D09793006D8A5D /* EndPointType.swift in Sources */, diff --git a/Core/Core/View/Base/RefreshableScrollView.swift b/Core/Core/View/Base/RefreshableScrollView.swift index d72580b5e..0905bdba6 100644 --- a/Core/Core/View/Base/RefreshableScrollView.swift +++ b/Core/Core/View/Base/RefreshableScrollView.swift @@ -6,249 +6,337 @@ // import SwiftUI -import Combine -public struct RefreshableScrollView: View { - @StateObject private var viewModel = RefreshableScrollViewModel() - - private let content: () -> Content - private let showsIndicators: Bool - private let onRefresh: () async -> Void - - public init(showsIndicators: Bool = true, - @ViewBuilder content: @escaping () -> Content, - onRefresh: @escaping () async -> Void) { - self.content = content - self.showsIndicators = showsIndicators - self.onRefresh = onRefresh - } - - private var topGeometryReader: some View { - GeometryReader { geometry in - Color.clear - .framePreferenceKey(geometry.frame(in: .global)) { frame in - self.viewModel.update(topFrame: frame) - } - } - } - - private var scrollViewGeometryReader: some View { - GeometryReader { geometry in - Color.clear - .framePreferenceKey(geometry.frame(in: .global)) { frame in - self.viewModel.update(scrollFrame: frame) - } - } - } - - public var body: some View { - VStack() { -// ProgressView() -// .progressViewStyle(.circular) -// .opacity(self.viewModel.isRefreshing ? 1 : 0) -// Activity - ActivityIndicator(size: self.$viewModel.progressViewHeight, isAnimating: self.$viewModel.isRefreshing) - .frame(width: self.viewModel.progressViewHeight, height: self.viewModel.progressViewHeight) - .background { self.topGeometryReader } - - ScrollView(.vertical, showsIndicators: self.showsIndicators) { - self.content() - .background { self.scrollViewGeometryReader } - } - } - .onChange(of: self.viewModel.isRefreshing) { isRefreshing in - guard isRefreshing else { return } - - Task { - await self.onRefresh() - - // In case the async method returns quickly. - // We want to keep it refreshing for some time so it is smooth. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.viewModel.endRefreshing() - } - } - } - } +// There are two type of positioning views - one that scrolls with the content, +// and one that stays fixed +private enum PositionType { + case fixed, moving } -struct RefreshableScrollView_Previews: PreviewProvider { - static var previews: some View { - RefreshableScrollView(showsIndicators: true) { - Text("Hi") - Text("World") - Text("Hello") - } onRefresh: { - print("Refreshing") +// This struct is the currency of the Preferences, and has a type +// (fixed or moving) and the actual Y-axis value. +// It's Equatable because Swift requires it to be. +private struct Position: Equatable { + let type: PositionType + let y: CGFloat +} + +// This might seem weird, but it's necessary due to the funny nature of +// how Preferences work. We can't just store the last position and merge +// it with the next one - instead we have a queue of all the latest positions. +private struct PositionPreferenceKey: PreferenceKey { + typealias Value = [Position] + + static var defaultValue = [Position]() + + static func reduce(value: inout [Position], nextValue: () -> [Position]) { + value.append(contentsOf: nextValue()) + } +} + +private struct PositionIndicator: View { + let type: PositionType + + var body: some View { + GeometryReader { proxy in + // the View itself is an invisible Shape that fills as much as possible + Color.clear + // Compute the top Y position and emit it to the Preferences queue + .preference(key: PositionPreferenceKey.self, value: [Position(type: type, y: proxy.frame(in: .global).minY)]) + } + } +} + +// Callback that'll trigger once refreshing is done +public typealias RefreshComplete = () -> Void + +// The actual refresh action that's called once refreshing starts. It has the +// RefreshComplete callback to let the refresh action let the View know +// once it's done refreshing. +public typealias OnRefresh = (@escaping RefreshComplete) -> Void + +// The offset threshold. 68 is a good number, but you can play +// with it to your liking. +public let defaultRefreshThreshold: CGFloat = 68 + +// Tracks the state of the RefreshableScrollView - it's either: +// 1. waiting for a scroll to happen +// 2. has been primed by pulling down beyond THRESHOLD +// 3. is doing the refreshing. +public enum RefreshState { + case waiting, primed, loading +} + +// ViewBuilder for the custom progress View, that may render itself +// based on the current RefreshState. +public typealias RefreshProgressBuilder = (RefreshState) -> Progress + +// Default color of the rectangle behind the progress spinner +public let defaultLoadingViewBackgroundColor = Color(UIColor.clear) + +public struct RefreshableScrollView: View where Progress: View, Content: View { + let showsIndicators: Bool // if the ScrollView should show indicators + let shouldTriggerHapticFeedback: Bool // if key actions should trigger haptic feedback + let loadingViewBackgroundColor: Color + let threshold: CGFloat // what height do you have to pull down to trigger the refresh + let onRefresh: OnRefresh // the refreshing action + let progress: RefreshProgressBuilder // custom progress view + let content: () -> Content // the ScrollView content + @State private var offset: CGFloat = 0 + @State private var state = RefreshState.waiting // the current state + // Haptic Feedback + let finishedReloadingFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) + let primedFeedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) + + // We use a custom constructor to allow for usage of a @ViewBuilder for the content + public init(showsIndicators: Bool = true, + shouldTriggerHapticFeedback: Bool = false, + loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, + threshold: CGFloat = defaultRefreshThreshold, + onRefresh: @escaping OnRefresh, + @ViewBuilder progress: @escaping RefreshProgressBuilder, + @ViewBuilder content: @escaping () -> Content) { + self.showsIndicators = showsIndicators + self.shouldTriggerHapticFeedback = shouldTriggerHapticFeedback + self.loadingViewBackgroundColor = loadingViewBackgroundColor + self.threshold = threshold + self.onRefresh = onRefresh + self.progress = progress + self.content = content + } + + public var body: some View { + // The root view is a regular ScrollView + ScrollView(showsIndicators: showsIndicators) { + // The ZStack allows us to position the PositionIndicator, + // the content and the loading view, all on top of each other. + ZStack(alignment: .top) { + // The moving positioning indicator, that sits at the top + // of the ScrollView and scrolls down with the content + PositionIndicator(type: .moving) + .frame(height: 0) + + // Your ScrollView content. If we're loading, we want + // to keep it below the loading view, hence the alignmentGuide. + content() + .alignmentGuide(.top, computeValue: { _ in + (state == .loading) ? -threshold + max(0, offset) : 0 + }) + + // The loading view. It's offset to the top of the content unless we're loading. + ZStack { + Rectangle() + .foregroundColor(loadingViewBackgroundColor) + .frame(height: threshold) + progress(state) + }.offset(y: (state == .loading) ? -max(0, offset) : -threshold) } - } + } + // Put a fixed PositionIndicator in the background so that we have + // a reference point to compute the scroll offset. + .background(PositionIndicator(type: .fixed)) + // Once the scrolling offset changes, we want to see if there should + // be a state change. + .onPreferenceChange(PositionPreferenceKey.self) { values in + DispatchQueue.main.async { + // Compute the offset between the moving and fixed PositionIndicators + let movingY = values.first { $0.type == .moving }?.y ?? 0 + let fixedY = values.first { $0.type == .fixed }?.y ?? 0 + offset = movingY - fixedY + if state != .loading { // If we're already loading, ignore everything + // Map the preference change action to the UI thread + // If the user pulled down below the threshold, prime the view + if offset > threshold && state == .waiting { + state = .primed + if shouldTriggerHapticFeedback { + self.primedFeedbackGenerator.impactOccurred() + } + + // If the view is primed and we've crossed the threshold again on the + // way back, trigger the refresh + } else if offset < threshold && state == .primed { + state = .loading + onRefresh { // trigger the refreshing callback + // once refreshing is done, smoothly move the loading view + // back to the offset position + withAnimation { + self.state = .waiting + } + if shouldTriggerHapticFeedback { + self.finishedReloadingFeedbackGenerator.impactOccurred() + } + } + } + } + } + } + } } -final class RefreshableScrollViewModel: ObservableObject { - @Published var progressViewHeight: CGFloat = 0 - @Published var isRefreshing = false - - let progressViewMaxHeight: CGFloat - private let scrollPositionSubject = CurrentValueSubject(0) - private let closingAnimationDuration: Double = 0.15 - private var subscriptions: Set = [] - - private var topYValue: CGFloat? - private var scrollYValue: CGFloat? - private var startingDistance: CGFloat? - private var isClosing = false - - /// - Parameter activityIndicatorStyle: Used to derive the size of the indicator. Might be better to get in another way. In case Apple changes the sizes - init(activityIndicatorStyle: UIActivityIndicatorView.Style = .medium) { - self.progressViewMaxHeight = activityIndicatorStyle == .large ? 35 : 27 - self.reactToScrollEnding() - } - - private func reactToScrollEnding() { - self.scrollPositionSubject - .debounce(for: 0.1, scheduler: RunLoop.main, options: nil) - .sink { [weak self] _ in - guard self?.progressViewHeight != 0, - self?.isRefreshing != true - else { return } - - self?.reset() - } - .store(in: &self.subscriptions) +// Extension that uses default RefreshActivityIndicator so that you don't have to +// specify it every time. +public extension RefreshableScrollView where Progress == RefreshActivityIndicator { + init(showsIndicators: Bool = true, + loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, + threshold: CGFloat = defaultRefreshThreshold, + onRefresh: @escaping OnRefresh, + @ViewBuilder content: @escaping () -> Content) { + self.init(showsIndicators: showsIndicators, + loadingViewBackgroundColor: loadingViewBackgroundColor, + threshold: threshold, + onRefresh: onRefresh, + progress: { state in + RefreshActivityIndicator(isAnimating: state == .loading) { + $0.hidesWhenStopped = false + } + }, + content: content) } - - /// Updates the progressViewHeight and progressViewIsAnimating properties based on the given topFrame and any existing scrollYValue, if any - /// - Parameter topFrame: CGRect - func update(topFrame: CGRect) { - let topY = topFrame.minY - self.topYValue = topY - guard let scrollY = self.scrollYValue else { return } - - self.update(topY: topY, scrollY: scrollY) - } - - /// Updates the progressViewHeight and progressViewIsAnimating properties based on the given scrollFrame and any existing topYValue, if any - /// - Parameter scrollFrame: CGRect - func update(scrollFrame: CGRect) { - let scrollY = scrollFrame.minY - self.scrollYValue = scrollY - self.scrollPositionSubject.send(scrollY) - guard let topY = self.topYValue else { return } - - self.update(topY: topY, scrollY: scrollY) +} + +// Wraps a UIActivityIndicatorView as a loading spinner that works on all SwiftUI versions. +public struct RefreshActivityIndicator: UIViewRepresentable { + public typealias UIView = UIActivityIndicatorView + public var isAnimating: Bool = true + public var configuration = { (indicator: UIView) in } + + public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) { + self.isAnimating = isAnimating + if let configuration = configuration { + self.configuration = configuration } - - /// Stops refreshing and hides the progress view - func endRefreshing() { - self.reset() - - DispatchQueue.main.asyncAfter(deadline: .now() + self.closingAnimationDuration) { - self.isRefreshing = false - } + } + + public func makeUIView(context: UIViewRepresentableContext) -> UIView { + UIView() + } + + public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) { + isAnimating ? uiView.startAnimating() : uiView.stopAnimating() + configuration(uiView) + } +} + +#if compiler(>=5.5) +// Allows using RefreshableScrollView with an async block. +@available(iOS 15.0, *) +public extension RefreshableScrollView { + init(showsIndicators: Bool = true, + loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, + threshold: CGFloat = defaultRefreshThreshold, + action: @escaping @Sendable () async -> Void, + @ViewBuilder progress: @escaping RefreshProgressBuilder, + @ViewBuilder content: @escaping () -> Content) { + self.init(showsIndicators: showsIndicators, + loadingViewBackgroundColor: loadingViewBackgroundColor, + threshold: threshold, + onRefresh: { refreshComplete in + Task { + await action() + refreshComplete() + } + }, + progress: progress, + content: content) } - - private func reset() { - self.isClosing = true - let topY = self.topYValue ?? 0 - let startDistance = self.startingDistance ?? 0 - let startingScrollYValue = topY + startDistance - self.scrollYValue = startingScrollYValue - - withAnimation(.linear(duration: self.closingAnimationDuration)) { - self.progressViewHeight = 0 - } - - DispatchQueue.main.asyncAfter(deadline: .now() + self.closingAnimationDuration) { - self.isClosing = false - } +} +#endif + +public struct RefreshableCompat: ViewModifier where Progress: View { + private let showsIndicators: Bool + private let loadingViewBackgroundColor: Color + private let threshold: CGFloat + private let onRefresh: OnRefresh + private let progress: RefreshProgressBuilder + + public init(showsIndicators: Bool = true, + loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, + threshold: CGFloat = defaultRefreshThreshold, + onRefresh: @escaping OnRefresh, + @ViewBuilder progress: @escaping RefreshProgressBuilder) { + self.showsIndicators = showsIndicators + self.loadingViewBackgroundColor = loadingViewBackgroundColor + self.threshold = threshold + self.onRefresh = onRefresh + self.progress = progress } - - private func update(topY: CGFloat, scrollY: CGFloat) { - // Don't react to updates while animating closed - guard !self.isClosing else { return } - - let newDistance = max(scrollY - topY, 0) - - if self.startingDistance == nil { - self.startingDistance = newDistance - } - - let differenceFromStart = newDistance - self.startingDistance! - let constrainedDifference = min(max(differenceFromStart, 0), self.progressViewMaxHeight) - - // Don't change the height of the progress view if we are refreshing - guard !isRefreshing else { return } - - DispatchQueue.main.async { - self.progressViewHeight = constrainedDifference - self.isRefreshing = constrainedDifference == self.progressViewMaxHeight + + public func body(content: Content) -> some View { + RefreshableScrollView(showsIndicators: showsIndicators, + loadingViewBackgroundColor: loadingViewBackgroundColor, + threshold: threshold, + onRefresh: onRefresh, + progress: progress) { + content } } } -struct FramePreferenceKey: PreferenceKey { - static var defaultValue: CGRect = .zero - - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() +#if compiler(>=5.5) +@available(iOS 15.0, *) +public extension List { + @ViewBuilder func refreshableCompat(showsIndicators: Bool = true, + loadingViewBackgroundColor: + Color = defaultLoadingViewBackgroundColor, + threshold: CGFloat = defaultRefreshThreshold, + onRefresh: @escaping OnRefresh, + @ViewBuilder progress: + @escaping RefreshProgressBuilder) -> some View { + if #available(iOS 15.0, macOS 12.0, *) { + self.refreshable { + await withCheckedContinuation { cont in + onRefresh { + cont.resume() + } + } + } + } else { + self.modifier(RefreshableCompat(showsIndicators: showsIndicators, + loadingViewBackgroundColor: loadingViewBackgroundColor, + threshold: threshold, + onRefresh: onRefresh, + progress: progress)) + } } } +#endif -extension View { - func framePreferenceKey(_ value: CGRect, onFrameChange: @escaping (CGRect) -> Void) -> some View { - self - .preference(key: FramePreferenceKey.self, value: value) - .onPreferenceChange(FramePreferenceKey.self, perform: onFrameChange) +public extension View { + @ViewBuilder func refreshableCompat(showsIndicators: Bool = true, + loadingViewBackgroundColor: + Color = defaultLoadingViewBackgroundColor, + threshold: CGFloat = defaultRefreshThreshold, + onRefresh: @escaping OnRefresh, + @ViewBuilder progress: + @escaping RefreshProgressBuilder) -> some View { + self.modifier(RefreshableCompat(showsIndicators: showsIndicators, + loadingViewBackgroundColor: loadingViewBackgroundColor, + threshold: threshold, + onRefresh: onRefresh, + progress: progress)) } } struct ActivityIndicator: UIViewRepresentable { - @Binding var size: CGFloat - @Binding var isAnimating: Bool - private let style: UIActivityIndicatorView.Style + public typealias UIView = UIActivityIndicatorView + public var isAnimating: Bool = true + public var configuration = { (indicator: UIView) in } - init(style: UIActivityIndicatorView.Style = .medium, size: Binding, isAnimating: Binding) { - self._size = size - self._isAnimating = isAnimating - self.style = style + public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) { + self.isAnimating = isAnimating + if let configuration = configuration { + self.configuration = configuration + } } - func makeUIView(context: Context) -> UIView { - let activityIndicator = UIActivityIndicatorView(style: self.style) - activityIndicator.hidesWhenStopped = false - - if self.isAnimating { - activityIndicator.startAnimating() - } - - let containerView = UIView() - containerView.layer.cornerRadius = self.size / 2 - containerView.clipsToBounds = true - - containerView.addSubview(activityIndicator) - activityIndicator.translatesAutoresizingMaskIntoConstraints = false - activityIndicator - .centerXAnchor - .constraint(equalTo: containerView.centerXAnchor) - .isActive = true - activityIndicator - .centerYAnchor - .constraint(equalTo: containerView.centerYAnchor) - .isActive = true - - return containerView + public func makeUIView(context: UIViewRepresentableContext) -> UIView { + let uiView = UIView() + uiView.startAnimating() + return uiView } - func updateUIView(_ uiView: UIView, context: Context) { - uiView.layer.cornerRadius = self.size / 2 - - guard let activityIndicator = uiView.subviews.first(where: { $0 is UIActivityIndicatorView }) as? UIActivityIndicatorView - else { return } - - if self.isAnimating { - activityIndicator.startAnimating() - } else { - activityIndicator.stopAnimating() - } + public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) { +// isAnimating ? uiView.startAnimating() : uiView.stopAnimating() + configuration(uiView) } -} + } diff --git a/Core/Core/View/Base/RefreshableScrollViewCompat.swift b/Core/Core/View/Base/RefreshableScrollViewCompat.swift new file mode 100644 index 000000000..768aa08b9 --- /dev/null +++ b/Core/Core/View/Base/RefreshableScrollViewCompat.swift @@ -0,0 +1,39 @@ +// +// RefreshableScrollViewCompat.swift +// Core +// +// Created by  Stepanok Ivan on 15.09.2023. +// + +import SwiftUI + +public struct RefreshableScrollViewCompat: View where Content: View { + private let content: () -> Content + private let action: () async -> Void + + public init(action: @escaping () async -> Void, @ViewBuilder content: @escaping () -> Content) { + self.action = action + self.content = content + } + + public var body: some View { + if #available(iOS 16.0, *) { + return ScrollView { + content() + }.refreshable { + Task { + await action() + } + } + } else { + return RefreshableScrollView(onRefresh: { done in + Task { + await action() + done() + } + }) { + content() + } + } + } +} diff --git a/Course/Course/Presentation/Details/CourseDetailsView.swift b/Course/Course/Presentation/Details/CourseDetailsView.swift index 5c5edada2..168dd8de1 100644 --- a/Course/Course/Presentation/Details/CourseDetailsView.swift +++ b/Course/Course/Presentation/Details/CourseDetailsView.swift @@ -47,7 +47,9 @@ public struct CourseDetailsView: View { .padding(.horizontal) }.frame(width: proxy.size.width) } else { - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + await viewModel.getCourseDetail(courseID: courseID, withProgress: false) + }) { VStack(alignment: .leading) { if let courseDetails = viewModel.courseDetails { @@ -131,12 +133,7 @@ public struct CourseDetailsView: View { } } } - } onRefresh: { - Task { - await viewModel.getCourseDetail(courseID: courseID, withProgress: false) - } - }.coordinateSpace(name: "pullToRefresh") - .frameLimit() + }.frameLimit() .onRightSwipeGesture { viewModel.router.back() } diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index c07019c96..f58b2438e 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -37,7 +37,9 @@ public struct CourseOutlineView: View { GeometryReader { proxy in VStack(alignment: .center) { // MARK: - Page Body - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) + }) { VStack(alignment: .leading) { ZStack { // MARK: - Course Banner @@ -134,10 +136,7 @@ public struct CourseOutlineView: View { } Spacer(minLength: 84) } - } onRefresh: { - await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) - }.coordinateSpace(name: "pullToRefresh") - .frameLimit() + }.frameLimit() .onRightSwipeGesture { viewModel.router.back() } diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/DashboardView.swift index 59747da5d..4be6e62e7 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/DashboardView.swift @@ -33,7 +33,9 @@ public struct DashboardView: View { // MARK: - Page body VStack(alignment: .center) { - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + await viewModel.getMyCourses(page: 1, refresh: true) + }) { Group { if viewModel.courses.isEmpty && !viewModel.fetchInProgress { EmptyPageIcon() @@ -89,12 +91,7 @@ public struct DashboardView: View { } } } - } onRefresh: { - Task { - await viewModel.getMyCourses(page: 1, refresh: true) - } - }.coordinateSpace(name: "pullToRefresh") - .frameLimit() + }.frameLimit() }.padding(.top, 8) // MARK: - Offline mode SnackBar diff --git a/Discovery/Discovery/Presentation/DiscoveryView.swift b/Discovery/Discovery/Presentation/DiscoveryView.swift index e8f6f52d4..8ca698572 100644 --- a/Discovery/Discovery/Presentation/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/DiscoveryView.swift @@ -66,56 +66,55 @@ public struct DiscoveryView: View { .padding(.bottom, 20) ZStack { - RefreshableScrollView { - LazyVStack(spacing: 0) { - HStack { - discoveryNew - .padding(.horizontal, 20) - .padding(.bottom, 20) - Spacer() - }.padding(.leading, 10) - ForEach(Array(viewModel.courses.enumerated()), id: \.offset) { index, course in - CourseCellView( - model: course, - type: .discovery, - index: index, - cellsCount: viewModel.courses.count - ).padding(.horizontal, 24) - .onAppear { - Task { - await viewModel.getDiscoveryCourses(index: index) - } - } - .onTapGesture { - viewModel.discoveryCourseClicked( - courseID: course.courseID, - courseName: course.name - ) - router.showCourseDetais( - courseID: course.courseID, - title: course.name - ) - } - } - - // MARK: - ProgressBar - if viewModel.nextPage <= viewModel.totalPages { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) - } - VStack {}.frame(height: 40) - } - } onRefresh: { + RefreshableScrollViewCompat(action: { viewModel.totalPages = 1 viewModel.nextPage = 1 Task { await viewModel.discovery(page: 1, withProgress: false) } + }) { + LazyVStack(spacing: 0) { + HStack { + discoveryNew + .padding(.horizontal, 20) + .padding(.bottom, 20) + Spacer() + }.padding(.leading, 10) + ForEach(Array(viewModel.courses.enumerated()), id: \.offset) { index, course in + CourseCellView( + model: course, + type: .discovery, + index: index, + cellsCount: viewModel.courses.count + ).padding(.horizontal, 24) + .onAppear { + Task { + await viewModel.getDiscoveryCourses(index: index) + } + } + .onTapGesture { + viewModel.discoveryCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + router.showCourseDetais( + courseID: course.courseID, + title: course.name + ) + } + } + + // MARK: - ProgressBar + if viewModel.nextPage <= viewModel.totalPages { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) + } + VStack {}.frame(height: 40) + } }.frameLimit() - .coordinateSpace(name: "pullToRefresh") } }.padding(.top, 8) diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index b079cb94c..69e666844 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -40,7 +40,14 @@ public struct ResponsesView: View { ScrollViewReader { scroll in VStack { ZStack(alignment: .top) { - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + viewModel.comments = [] + _ = await viewModel.getComments( + commentID: commentID, + parentComment: parentComment, + page: 1 + ) + }) { VStack { if let comments = viewModel.postComments { ParentCommentView( @@ -134,14 +141,7 @@ public struct ResponsesView: View { .onRightSwipeGesture { viewModel.router.back() } - } onRefresh: { - viewModel.comments = [] - Task { - _ = await viewModel.getComments(commentID: commentID, - parentComment: parentComment, page: 1) - } - }.coordinateSpace(name: "pullToRefresh") - .frameLimit() + }.frameLimit() if !parentComment.closed { FlexibleKeyboardInputView( diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift index fc2012e6e..92555f692 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift @@ -95,7 +95,11 @@ public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { .getCommentResponses(commentID: commentID, page: page) self.totalPages = pagination.numPages self.itemsCount = pagination.count - self.comments += comments + if page == 1 { + self.comments = comments + } else { + self.comments += comments + } postComments = generateCommentsResponses(comments: self.comments, parentComment: parentComment) return true } catch let error { diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 7bbbdae0a..bdc5ae96a 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -34,7 +34,9 @@ public struct ThreadView: View { ScrollViewReader { scroll in VStack { ZStack(alignment: .top) { - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + _ = await viewModel.getPosts(thread: thread, page: 1) + }) { VStack { if let comments = viewModel.postComments { ParentCommentView( @@ -140,11 +142,7 @@ public struct ThreadView: View { onBackTapped() viewModel.sendUpdateUnreadState() } - } onRefresh: { - Task { - _ = await viewModel.getPosts(thread: thread, page: 1) - } - }.coordinateSpace(name: "pullToRefresh") + } if !thread.closed { FlexibleKeyboardInputView( hint: DiscussionLocalization.Thread.addResponse, diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift index 35870b6ee..db10d8039 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift @@ -146,7 +146,11 @@ public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { .getDiscussionComments(threadID: thread.id, page: page) self.totalPages = pagination.numPages self.itemsCount = pagination.count - self.comments += comments + if page == 1 { + self.comments = comments + } else { + self.comments += comments + } postComments = generateComments(comments: self.comments, thread: thread) } fetchInProgress = false diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index 4629b9956..722e1b9fd 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -53,7 +53,9 @@ public struct DiscussionTopicsView: View { // MARK: - Page Body VStack { ZStack(alignment: .top) { - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + await viewModel.getTopics(courseID: self.courseID, withProgress: false) + }) { VStack { if let topics = viewModel.discussionTopics { HStack { @@ -125,12 +127,7 @@ public struct DiscussionTopicsView: View { } Spacer(minLength: 84) } - } onRefresh: { - Task { - await viewModel.getTopics(courseID: self.courseID, withProgress: false) - } - }.coordinateSpace(name: "pullToRefresh") - .frameLimit() + }.frameLimit() .onRightSwipeGesture { router.back() } diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 55f0d09ab..d88441939 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -51,39 +51,47 @@ public struct PostsView: View { VStack { ZStack(alignment: .top) { VStack { - VStack { - HStack { - Group { - Button(action: { - listAnimation = .easeIn - viewModel.generateButtons(type: .filter) - showingAlert = true - }, label: { - CoreAssets.filter.swiftUIImage - Text(viewModel.filterTitle.localizedValue) - }) - Spacer() - Button(action: { - listAnimation = .easeIn - viewModel.generateButtons(type: .sort) - showingAlert = true - }, label: { - CoreAssets.sort.swiftUIImage - Text(viewModel.sortTitle.localizedValue) - }) - }.foregroundColor(Theme.Colors.accentColor) - } .font(Theme.Fonts.labelMedium) - .padding(.horizontal, 24) - .padding(.vertical, 12) - .shadow(color: Theme.Colors.shadowColor, - radius: 12, y: 4) - .background( - Theme.Colors.background - ) - Divider().offset(y: -8) - } + VStack { + HStack { + Group { + Button(action: { + listAnimation = .easeIn + viewModel.generateButtons(type: .filter) + showingAlert = true + }, label: { + CoreAssets.filter.swiftUIImage + Text(viewModel.filterTitle.localizedValue) + }) + Spacer() + Button(action: { + listAnimation = .easeIn + viewModel.generateButtons(type: .sort) + showingAlert = true + }, label: { + CoreAssets.sort.swiftUIImage + Text(viewModel.sortTitle.localizedValue) + }) + }.foregroundColor(Theme.Colors.accentColor) + } .font(Theme.Fonts.labelMedium) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .shadow(color: Theme.Colors.shadowColor, + radius: 12, y: 4) + .background( + Theme.Colors.background + ) + Divider().offset(y: -8) + } .frameLimit() - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + listAnimation = nil + viewModel.resetPosts() + _ = await viewModel.getPosts( + courseID: courseID, + pageNumber: 1, + withProgress: false + ) + }) { let posts = Array(viewModel.filteredPosts.enumerated()) if posts.count >= 1 { LazyVStack { @@ -95,15 +103,16 @@ public struct PostsView: View { .foregroundColor(Theme.Colors.textPrimary) Spacer() Button(action: { - router.createNewThread(courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } + router.createNewThread( + courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage(onSuccess: { + withAnimation { + scroll.scrollTo(1) + } + }) }) - }) }, label: { VStack { CoreAssets.addComment.swiftUIImage @@ -168,15 +177,7 @@ public struct PostsView: View { .padding(.top, 100) } } - } onRefresh: { - listAnimation = nil - viewModel.resetPosts() - Task { - _ = await viewModel.getPosts(courseID: courseID, - pageNumber: 1, - withProgress: false) - } - }.coordinateSpace(name: "pullToRefresh") + } }.frameLimit() .animation(listAnimation) .onRightSwipeGesture { diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index fca2e192f..2d61ed18c 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -22,7 +22,9 @@ public struct ProfileView: View { public var body: some View { ZStack(alignment: .top) { // MARK: - Page Body - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + await viewModel.getMyProfile(withProgress: false) + }) { VStack { if viewModel.isShowProgress { ProgressBar(size: 40, lineWidth: 8) @@ -190,12 +192,7 @@ public struct ProfileView: View { Spacer() } } - } onRefresh: { - Task { - await viewModel.getMyProfile(withProgress: false) - } - }.coordinateSpace(name: "pullToRefresh") - .frameLimit(sizePortrait: 420) + }.frameLimit(sizePortrait: 420) .padding(.top, 8) .onChange(of: settingsTapped, perform: { _ in if let userModel = viewModel.userModel { From a92a22cd4168f3c98cf99f90667b032ebbae7663 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Fri, 15 Sep 2023 18:34:43 +0300 Subject: [PATCH 17/19] Codestyle improvements (#76) * codestyle improvements * remove unused code --- .../Core/Extensions/CollectionExtension.swift | 2 +- .../Data/Network/DashboardEndpoint.swift | 8 +- .../Presentation/Posts/PostsView.swift | 21 ++- .../Presentation/Posts/PostsViewModel.swift | 152 ++++-------------- .../Posts/PostViewModelTests.swift | 26 +-- 5 files changed, 58 insertions(+), 151 deletions(-) diff --git a/Core/Core/Extensions/CollectionExtension.swift b/Core/Core/Extensions/CollectionExtension.swift index 73fbeba15..ba2ff088a 100644 --- a/Core/Core/Extensions/CollectionExtension.swift +++ b/Core/Core/Extensions/CollectionExtension.swift @@ -7,7 +7,7 @@ import Foundation -extension Collection { +public extension Collection { /// Returns the element at the specified index if it is within bounds, otherwise nil. subscript (safe index: Index) -> Element? { return indices.contains(index) ? self[index] : nil diff --git a/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift b/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift index 02903fab6..d9e4dec06 100644 --- a/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift +++ b/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift @@ -11,25 +11,25 @@ import Alamofire enum DashboardEndpoint: EndPointType { case getMyCourses(username: String, page: Int) - + var path: String { switch self { case let .getMyCourses(username, _): return "/mobile_api_extensions/v1/users/\(username)/course_enrollments" } } - + var httpMethod: HTTPMethod { switch self { case .getMyCourses: return .get } } - + var headers: HTTPHeaders? { nil } - + var task: HTTPTask { switch self { case let .getMyCourses(_, page): diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index d88441939..dc7145a3a 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -14,7 +14,6 @@ public struct PostsView: View { @ObservedObject private var viewModel: PostsViewModel @State private var isShowProgress: Bool = true @State private var showingAlert = false - @State private var listAnimation: Animation? private let router: DiscussionRouter private let title: String private let currentBlockID: String @@ -55,7 +54,6 @@ public struct PostsView: View { HStack { Group { Button(action: { - listAnimation = .easeIn viewModel.generateButtons(type: .filter) showingAlert = true }, label: { @@ -64,7 +62,6 @@ public struct PostsView: View { }) Spacer() Button(action: { - listAnimation = .easeIn viewModel.generateButtons(type: .sort) showingAlert = true }, label: { @@ -84,10 +81,8 @@ public struct PostsView: View { } .frameLimit() RefreshableScrollViewCompat(action: { - listAnimation = nil viewModel.resetPosts() _ = await viewModel.getPosts( - courseID: courseID, pageNumber: 1, withProgress: false ) @@ -134,7 +129,6 @@ public struct PostsView: View { .onAppear { Task { await viewModel.getPostsPagination( - courseID: self.courseID, index: index ) } @@ -179,7 +173,7 @@ public struct PostsView: View { } } }.frameLimit() - .animation(listAnimation) + .animation(nil) .onRightSwipeGesture { router.back() } @@ -197,7 +191,10 @@ public struct PostsView: View { } .onFirstAppear { Task { - await viewModel.getPosts(courseID: courseID, pageNumber: 1, withProgress: true) + await viewModel.getPosts( + pageNumber: 1, + withProgress: true + ) } } .navigationBarHidden(!showTopMenu) @@ -216,11 +213,11 @@ public struct PostsView: View { @MainActor private func reloadPage(onSuccess: @escaping () -> Void) { Task { - listAnimation = nil viewModel.resetPosts() - _ = await viewModel.getPosts(courseID: courseID, - pageNumber: 1, - withProgress: false) + _ = await viewModel.getPosts( + pageNumber: 1, + withProgress: false + ) onSuccess() } } diff --git a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift index f8baeba06..baad91ffc 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift @@ -28,7 +28,7 @@ public class PostsViewModel: ObservableObject { public var nextPage = 1 public var totalPages = 1 - public private(set) var fetchInProgress = false + @Published public private(set) var fetchInProgress = false public enum ButtonType { case sort @@ -43,7 +43,7 @@ public class PostsViewModel: ObservableObject { if let courseID { resetPosts() Task { - _ = await getPosts(courseID: courseID, pageNumber: 1) + _ = await getPosts(pageNumber: 1) } } } @@ -53,7 +53,7 @@ public class PostsViewModel: ObservableObject { if let courseID { resetPosts() Task { - _ = await getPosts(courseID: courseID, pageNumber: 1) + _ = await getPosts(pageNumber: 1) } } } @@ -109,9 +109,6 @@ public class PostsViewModel: ObservableObject { } public func resetPosts() { - filteredPosts = [] - discussionPosts = [] - threads.threads = [] nextPage = 1 totalPages = 1 } @@ -170,134 +167,36 @@ public class PostsViewModel: ObservableObject { } @MainActor - func getPostsPagination(courseID: String, index: Int, withProgress: Bool = true) async { - if !fetchInProgress { - if totalPages > 1 { - if index == filteredPosts.count - 3 { - if totalPages != 1 { - if nextPage <= totalPages { - _ = await getPosts( - courseID: courseID, - pageNumber: self.nextPage, - withProgress: withProgress - ) - } - } - } - } + func getPostsPagination(index: Int, withProgress: Bool = true) async { + guard !fetchInProgress else { return } + if totalPages > 1, index >= filteredPosts.count - 3, nextPage <= totalPages { + _ = await getPosts( + pageNumber: self.nextPage, + withProgress: withProgress + ) } } - // swiftlint:disable function_body_length @MainActor - public func getPosts(courseID: String, pageNumber: Int, withProgress: Bool = true) async -> Bool { + public func getPosts(pageNumber: Int, withProgress: Bool = true) async -> Bool { fetchInProgress = true isShowProgress = withProgress do { if pageNumber == 1 { - switch type { - case .allPosts: - threads.threads = try await interactor - .getThreadsList(courseID: courseID, - type: .allPosts, - sort: sortTitle, - filter: filterTitle, - page: pageNumber).threads - if threads.threads.indices.contains(0) { - self.totalPages = threads.threads[0].numPages - self.nextPage = 2 - } - case .followingPosts: - threads.threads = try await interactor - .getThreadsList(courseID: courseID, - type: .followingPosts, - sort: sortTitle, - filter: filterTitle, - page: pageNumber).threads - if threads.threads.indices.contains(0) { - self.totalPages = threads.threads[0].numPages - self.nextPage = 2 - } - case .nonCourseTopics: - threads.threads = try await interactor - .getThreadsList(courseID: courseID, - type: .nonCourseTopics, - sort: sortTitle, - filter: filterTitle, - page: pageNumber).threads - if threads.threads.indices.contains(0) { - self.totalPages = threads.threads[0].numPages - self.nextPage = 2 - } - case .courseTopics(topicID: let topicID): - threads.threads = try await interactor - .getThreadsList(courseID: courseID, - type: .courseTopics(topicID: topicID), - sort: sortTitle, - filter: filterTitle, - page: pageNumber).threads - if threads.threads.indices.contains(0) { - self.totalPages = threads.threads[0].numPages - self.nextPage = 2 - } - case .none: - isShowProgress = false - return false + threads.threads = try await getThreadsList(type: type, page: pageNumber) + if threads.threads.indices.contains(0) { + totalPages = threads.threads[0].numPages + nextPage = 2 } } else { - switch type { - case .allPosts: - threads.threads += try await interactor - .getThreadsList(courseID: courseID, - type: .allPosts, - sort: sortTitle, - filter: filterTitle, - page: pageNumber).threads - if threads.threads.indices.contains(0) { - self.totalPages = threads.threads[0].numPages - self.nextPage += 1 - } - case .followingPosts: - threads.threads += try await interactor - .getThreadsList(courseID: courseID, - type: .followingPosts, - sort: sortTitle, - filter: filterTitle, - page: pageNumber).threads - if threads.threads.indices.contains(0) { - self.totalPages = threads.threads[0].numPages - self.nextPage += 1 - } - case .nonCourseTopics: - threads.threads += try await interactor - .getThreadsList(courseID: courseID, - type: .nonCourseTopics, - sort: sortTitle, - filter: filterTitle, - page: pageNumber).threads - if threads.threads.indices.contains(0) { - self.totalPages = threads.threads[0].numPages - self.nextPage += 1 - } - case .courseTopics(topicID: let topicID): - threads.threads += try await interactor - .getThreadsList(courseID: courseID, - type: .courseTopics(topicID: topicID), - sort: sortTitle, - filter: filterTitle, - page: pageNumber).threads - if threads.threads.indices.contains(0) { - self.totalPages = threads.threads[0].numPages - self.nextPage += 1 - } - case .none: - isShowProgress = false - return false + threads.threads += try await getThreadsList(type: type, page: pageNumber) + if threads.threads.indices.contains(0) { + totalPages = threads.threads[0].numPages + nextPage += 1 } } discussionPosts = generatePosts(threads: threads) filteredPosts = discussionPosts - self.filteredPosts = self.discussionPosts isShowProgress = false fetchInProgress = false return true @@ -312,7 +211,18 @@ public class PostsViewModel: ObservableObject { return false } } - // swiftlint:enable function_body_length + + @MainActor + private func getThreadsList(type: ThreadType, page: Int) async throws -> [UserThread] { + guard let courseID else { return [] } + return try await interactor.getThreadsList( + courseID: courseID, + type: type, + sort: sortTitle, + filter: filterTitle, + page: page + ).threads + } private func updateUnreadCommentsCount(id: String) { var threads = threads.threads diff --git a/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift index cd4dbab3c..d079c8944 100644 --- a/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift @@ -108,33 +108,30 @@ final class PostViewModelTests: XCTestCase { var result = false let viewModel = PostsViewModel(interactor: interactor, router: router, config: config) + viewModel.courseID = "1" viewModel.type = .allPosts Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any, willReturn: threads)) viewModel.type = .allPosts - result = await viewModel.getPosts(courseID: "1", pageNumber: 1) + result = await viewModel.getPosts(pageNumber: 1) XCTAssertTrue(result) result = false viewModel.type = .courseTopics(topicID: "") - result = await viewModel.getPosts(courseID: "1", pageNumber: 1) + result = await viewModel.getPosts(pageNumber: 1) XCTAssertTrue(result) result = false viewModel.type = .followingPosts - result = await viewModel.getPosts(courseID: "1", pageNumber: 1) + result = await viewModel.getPosts(pageNumber: 1) XCTAssertTrue(result) result = false viewModel.type = .nonCourseTopics - result = await viewModel.getPosts(courseID: "1", pageNumber: 1) + result = await viewModel.getPosts(pageNumber: 1) XCTAssertTrue(result) result = false - - viewModel.type = .none - result = await viewModel.getPosts(courseID: "1", pageNumber: 1) - XCTAssertFalse(result) Verify(interactor, 4, .getThreadsList(courseID: .value("1"), type: .any, sort: .any, filter: .any, page: .value(1))) @@ -154,8 +151,9 @@ final class PostViewModelTests: XCTestCase { Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any, willThrow: noInternetError)) + viewModel.courseID = "1" viewModel.type = .allPosts - result = await viewModel.getPosts(courseID: "1", pageNumber: 1) + result = await viewModel.getPosts(pageNumber: 1) Verify(interactor, 1, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any)) @@ -174,8 +172,9 @@ final class PostViewModelTests: XCTestCase { Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any, willThrow: NSError())) + viewModel.courseID = "1" viewModel.type = .allPosts - result = await viewModel.getPosts(courseID: "1", pageNumber: 1) + result = await viewModel.getPosts(pageNumber: 1) Verify(interactor, 1, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any)) @@ -193,9 +192,10 @@ final class PostViewModelTests: XCTestCase { Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any, willReturn: threads)) + viewModel.courseID = "1" viewModel.type = .allPosts viewModel.sortTitle = .mostActivity - _ = await viewModel.getPosts(courseID: "1", pageNumber: 1) + _ = await viewModel.getPosts(pageNumber: 1) XCTAssertTrue(viewModel.filteredPosts[0].title == "1") Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .value(.recentActivity), filter: .any, page: .any, @@ -203,7 +203,7 @@ final class PostViewModelTests: XCTestCase { viewModel.filterTitle = .unread viewModel.sortTitle = .recentActivity - _ = await viewModel.getPosts(courseID: "1", pageNumber: 1) + _ = await viewModel.getPosts(pageNumber: 1) XCTAssertTrue(viewModel.filteredPosts[0].title == "1") XCTAssertNotNil(viewModel.filteredPosts.first(where: {$0.unreadCommentCount == 4})) @@ -212,7 +212,7 @@ final class PostViewModelTests: XCTestCase { viewModel.filterTitle = .unanswered viewModel.sortTitle = .mostVotes - _ = await viewModel.getPosts(courseID: "1", pageNumber: 1) + _ = await viewModel.getPosts(pageNumber: 1) XCTAssertTrue(viewModel.filteredPosts[0].title == "1") XCTAssertNotNil(viewModel.filteredPosts.first(where: { $0.hasEndorsed })) From 9cbde2d81068c8ec2210a4f13f4b879df4954431 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 22 Sep 2023 10:48:10 +0300 Subject: [PATCH 18/19] ios 17 bugfixes (#77) * bugfixes --- .../CoreDataModel.xcdatamodel/contents | 3 +- .../Persistence/CorePersistenceProtocol.swift | 2 +- Core/Core/Network/DownloadManager.swift | 43 +++---- Core/Core/View/Base/WebBrowser.swift | 20 ++-- .../Data/Model/Data_UpdatesResponse.swift | 2 +- Course/Course/Domain/Model/CourseUpdate.swift | 4 +- .../Handouts/HandoutsUpdatesDetailView.swift | 21 ++-- .../Presentation/Handouts/HandoutsView.swift | 5 +- .../Outline/CourseOutlineView.swift | 1 + .../Presentation/Unit/CourseUnitView.swift | 105 +++++++++++------- .../CourseContainerViewModelTests.swift | 3 - OpenEdX/Data/CorePersistence.swift | 11 +- OpenEdX/uk.lproj/languages.json | 2 +- .../EditProfile/EditProfileView.swift | 14 ++- 14 files changed, 126 insertions(+), 110 deletions(-) diff --git a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index ed12a0c8a..3dc8a8e03 100644 --- a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,10 +1,9 @@ - + - diff --git a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift index e7256850c..f250fc49c 100644 --- a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift +++ b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift @@ -15,7 +15,7 @@ public protocol CorePersistenceProtocol { func getNextBlockForDownloading() -> DownloadData? func getDownloadsForCourse(_ courseId: String) -> [DownloadData] func downloadData(by blockId: String) -> DownloadData? - func updateDownloadState(id: String, state: DownloadState, path: String?, resumeData: Data?) + func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) func deleteDownloadData(id: String) throws func saveDownloadData(data: DownloadData) } diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 4b8ed6df8..774c767f0 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -24,7 +24,6 @@ public struct DownloadData { public let id: String public let courseId: String public let url: String - public let path: String? public let fileName: String public let progress: Double public let resumeData: Data? @@ -35,7 +34,6 @@ public struct DownloadData { id: String, courseId: String, url: String, - path: String?, fileName: String, progress: Double, resumeData: Data?, @@ -45,7 +43,6 @@ public struct DownloadData { self.id = id self.courseId = courseId self.url = url - self.path = path self.fileName = fileName self.progress = progress self.resumeData = resumeData @@ -148,28 +145,27 @@ public class DownloadManager: DownloadManagerProtocol { persistence.updateDownloadState( id: download.id, state: .inProgress, - path: nil, resumeData: download.resumeData ) self.isDownloadingInProgress = true - let fileName = url.lastPathComponent if let resumeData = download.resumeData { downloadRequest = AF.download(resumingWith: resumeData) } else { downloadRequest = AF.download(url) } -// downloadRequest?.downloadProgress { prog in -// let completed = Double(prog.fractionCompleted * 100) -// print(">>>>> Downloading", download.url, completed, "%") -// } + #if DEBUG + downloadRequest?.downloadProgress { prog in + let completed = Double(prog.fractionCompleted * 100) + print(">>>>> Downloading", download.url, completed, "%") + } + #endif downloadRequest?.responseData(completionHandler: { [weak self] data in guard let self else { return } if let data = data.value, let url = self.videosFolderUrl() { - let fileUrl = self.saveFile(fileName: fileName, data: data, folderURL: url) + self.saveFile(fileName: download.fileName, data: data, folderURL: url) self.persistence.updateDownloadState( id: download.id, state: .finished, - path: fileUrl?.absoluteString, resumeData: nil ) try? self.newDownload() @@ -188,7 +184,6 @@ public class DownloadManager: DownloadManagerProtocol { self.persistence.updateDownloadState( id: currentDownload.id, state: .paused, - path: nil, resumeData: resumeData ) }) @@ -196,16 +191,13 @@ public class DownloadManager: DownloadManagerProtocol { public func deleteFile(blocks: [CourseBlock]) { for block in blocks { - let downloadData = persistence.downloadData(by: block.id) - guard let path = persistence.downloadData(by: block.id)?.path, - let fileUrl = URL(string: path) else { return } - do { try persistence.deleteDownloadData(id: block.id) - try FileManager.default.removeItem(at: fileUrl) - print("File deleted successfully") + if let fileUrl = fileUrl(for: block.id) { + try FileManager.default.removeItem(at: fileUrl) + } } catch { - print("Error deleting file: \(error.localizedDescription)") + NSLog("Error deleting file: \(error.localizedDescription)") } } } @@ -213,7 +205,7 @@ public class DownloadManager: DownloadManagerProtocol { public func deleteAllFiles() { let downloadData = persistence.getAllDownloadData() downloadData.forEach { - if let path = $0.path, let fileURL = URL(string: path) { + if let fileURL = fileUrl(for: $0.id) { do { try FileManager.default.removeItem(at: fileURL) } catch { @@ -227,8 +219,9 @@ public class DownloadManager: DownloadManagerProtocol { guard let data = persistence.downloadData(by: blockId), data.url.count > 0, data.state == .finished else { return nil } - - return URL(string: data.path ?? "") + let path = videosFolderUrl() + let fileName = data.fileName + return path?.appendingPathComponent(fileName) } private func videosFolderUrl() -> URL? { @@ -252,15 +245,13 @@ public class DownloadManager: DownloadManagerProtocol { } } - private func saveFile(fileName: String, data: Data, folderURL: URL) -> URL? { + private func saveFile(fileName: String, data: Data, folderURL: URL) { let fileURL = folderURL.appendingPathComponent(fileName) do { try data.write(to: fileURL) - return fileURL } catch { - print("SaveFile Error", error.localizedDescription) + NSLog("SaveFile Error", error.localizedDescription) } - return nil } } diff --git a/Core/Core/View/Base/WebBrowser.swift b/Core/Core/View/Base/WebBrowser.swift index 3c0ae6ec4..ffea4335f 100644 --- a/Core/Core/View/Base/WebBrowser.swift +++ b/Core/Core/View/Base/WebBrowser.swift @@ -22,28 +22,28 @@ public struct WebBrowser: View { public var body: some View { ZStack(alignment: .top) { - + CoreAssets.background.swiftUIColor.ignoresSafeArea() // MARK: - Page name VStack(alignment: .center) { + NavigationBar(title: pageTitle, + leftButtonAction: { presentationMode.wrappedValue.dismiss() }) + // MARK: - Page Body VStack { ZStack(alignment: .top) { - NavigationView { +// NavigationView { WebView( viewModel: .init(url: url, baseURL: ""), isLoading: $isShowProgress, refreshCookies: {} ) - .navigationBarTitle(Text("")) // Needed for hide navBar on ios 14, 15 - .navigationBarHidden(true) - .ignoresSafeArea() - } - } + +// } + }.navigationBarTitle(Text("")) // Needed for hide navBar on ios 14, 15 + .navigationBarHidden(true) + .ignoresSafeArea() } } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .navigationTitle(pageTitle) } } } diff --git a/Course/Course/Data/Model/Data_UpdatesResponse.swift b/Course/Course/Data/Model/Data_UpdatesResponse.swift index 88cdc883f..bb9824e7b 100644 --- a/Course/Course/Data/Model/Data_UpdatesResponse.swift +++ b/Course/Course/Data/Model/Data_UpdatesResponse.swift @@ -13,7 +13,7 @@ public extension DataLayer { public let id: Int public let date: String public let content: String - public let status: String + public let status: String? } typealias CourseUpdates = [CourseUpdate] } diff --git a/Course/Course/Domain/Model/CourseUpdate.swift b/Course/Course/Domain/Model/CourseUpdate.swift index 345d1e2a3..2ef09f210 100644 --- a/Course/Course/Domain/Model/CourseUpdate.swift +++ b/Course/Course/Domain/Model/CourseUpdate.swift @@ -11,9 +11,9 @@ public struct CourseUpdate { public let id: Int public let date: String public var content: String - public let status: String + public let status: String? - public init(id: Int, date: String, content: String, status: String) { + public init(id: Int, date: String, content: String, status: String?) { self.id = id self.date = date self.content = content diff --git a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift index eaf467484..57b12b542 100644 --- a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift @@ -18,7 +18,6 @@ public struct HandoutsUpdatesDetailView: View { private var handouts: String? private var announcements: [CourseUpdate]? private let title: String - @State private var height: [Int: CGFloat] = [:] public init( handouts: String?, @@ -68,6 +67,8 @@ public struct HandoutsUpdatesDetailView: View { public var body: some View { ZStack(alignment: .top) { + Theme.Colors.background + .ignoresSafeArea() GeometryReader { reader in // MARK: - Page Body @@ -98,12 +99,12 @@ public struct HandoutsUpdatesDetailView: View { type: .discovery, screenWidth: reader.size.width ) - HTMLFormattedText( - fixBrokenLinks(in: formattedAnnouncements), - isScrollEnabled: true, - textViewHeight: $height[index] - ) - .frame(height: height[index]) + HStack { + HTMLFormattedText(formattedAnnouncements) + Spacer() + } + + .id(UUID()) if index != announcements.count - 1 { Divider() @@ -120,13 +121,7 @@ public struct HandoutsUpdatesDetailView: View { router.back() } Spacer(minLength: 84) - - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } - } .navigationBarHidden(false) .navigationBarBackButtonHidden(false) diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index 81cd06f6a..d6563b46f 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -12,7 +12,7 @@ struct HandoutsView: View { private let courseID: String - @ObservedObject + @StateObject private var viewModel: HandoutsViewModel public init( @@ -20,7 +20,8 @@ struct HandoutsView: View { viewModel: HandoutsViewModel ) { self.courseID = courseID - self.viewModel = viewModel +// self.viewModel = viewModel + self._viewModel = StateObject(wrappedValue: { viewModel }()) } public var body: some View { diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index f58b2438e..19a6fa413 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -71,6 +71,7 @@ public struct CourseOutlineView: View { ) .frame(width: 141) .padding(.top, 8) + .fullScreenCover( isPresented: $openCertificateView, content: { diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 1d9637440..e9afacbc1 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -45,7 +45,6 @@ public struct CourseUnitView: View { GeometryReader { reader in VStack(spacing: 0) { VStack {}.frame(height: 100) - if viewModel.connectivity.isInternetAvaliable { LazyVStack(spacing: 0) { let data = Array(viewModel.verticals[viewModel.verticalIndex].childs.enumerated()) ForEach(data, id: \.offset) { index, block in @@ -54,40 +53,58 @@ public struct CourseUnitView: View { switch LessonType.from(block) { // MARK: YouTube case let .youtube(url, blockID): - YouTubeView( - name: block.displayName, - url: url, - courseID: viewModel.courseID, - blockID: blockID, - playerStateSubject: playerStateSubject, - languages: block.subtitles ?? [], - isOnScreen: index == viewModel.index - ).frameLimit() - Spacer(minLength: 100) + if viewModel.connectivity.isInternetAvaliable { + YouTubeView( + name: block.displayName, + url: url, + courseID: viewModel.courseID, + blockID: blockID, + playerStateSubject: playerStateSubject, + languages: block.subtitles ?? [], + isOnScreen: index == viewModel.index + ).frameLimit() + Spacer(minLength: 100) + } else { + NoInternetView(playerStateSubject: playerStateSubject) + } // MARK: Encoded Video case let .video(encodedUrl, blockID): - EncodedVideoView( - name: block.displayName, - url: viewModel.urlForVideoFileOrFallback( - blockId: blockID, - url: encodedUrl - ), - courseID: viewModel.courseID, - blockID: blockID, - playerStateSubject: playerStateSubject, - languages: block.subtitles ?? [], - isOnScreen: index == viewModel.index - ).frameLimit() - Spacer(minLength: 100) + let url = viewModel.urlForVideoFileOrFallback( + blockId: blockID, + url: encodedUrl + ) + if viewModel.connectivity.isInternetAvaliable || url?.isFileURL == true { + EncodedVideoView( + name: block.displayName, + url: url, + courseID: viewModel.courseID, + blockID: blockID, + playerStateSubject: playerStateSubject, + languages: block.subtitles ?? [], + isOnScreen: index == viewModel.index + ).frameLimit() + Spacer(minLength: 100) + } else { + NoInternetView(playerStateSubject: playerStateSubject) + } // MARK: Web case .web(let url): - WebView(url: url, viewModel: viewModel) + if viewModel.connectivity.isInternetAvaliable { + WebView(url: url, viewModel: viewModel) + } else { + NoInternetView(playerStateSubject: playerStateSubject) + } // MARK: Unknown case .unknown(let url): + if viewModel.connectivity.isInternetAvaliable { UnknownView(url: url, viewModel: viewModel) Spacer() + } else { + NoInternetView(playerStateSubject: playerStateSubject) + } // MARK: Discussion case let .discussion(blockID, blockKey, title): + if viewModel.connectivity.isInternetAvaliable { VStack { if showDiscussion { DiscussionView( @@ -104,6 +121,9 @@ public struct CourseUnitView: View { } } }.frameLimit() + } else { + NoInternetView(playerStateSubject: playerStateSubject) + } } } else { EmptyView() @@ -126,21 +146,7 @@ public struct CourseUnitView: View { } }) - } else { - - // MARK: No internet view - VStack(spacing: 28) { - Image(systemName: "wifi").resizable() - .scaledToFit() - .frame(width: 100) - Text(CourseLocalization.Error.noInternet) - .multilineTextAlignment(.center) - .padding(.horizontal, 20) - UnitButtonView(type: .reload, action: { - playerStateSubject.send(VideoPlayerState.kill) - }).frame(width: 100) - }.frame(maxWidth: .infinity, maxHeight: .infinity) - } + }.frame(maxWidth: .infinity) .clipped() @@ -334,3 +340,22 @@ struct CourseUnitView_Previews: PreviewProvider { } //swiftlint:enable all #endif + +struct NoInternetView: View { + + let playerStateSubject: CurrentValueSubject + + var body: some View { + VStack(spacing: 28) { + Image(systemName: "wifi").resizable() + .scaledToFit() + .frame(width: 100) + Text(CourseLocalization.Error.noInternet) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + UnitButtonView(type: .reload, action: { + playerStateSubject.send(VideoPlayerState.kill) + }).frame(width: 100) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 63632ac3b..86c12aa7a 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -631,7 +631,6 @@ final class CourseContainerViewModelTests: XCTestCase { id: "1", courseId: "course123", url: "https://example.com/file.mp4", - path: nil, fileName: "file.mp4", progress: 0, resumeData: nil, @@ -740,7 +739,6 @@ final class CourseContainerViewModelTests: XCTestCase { id: "1", courseId: "course123", url: "https://example.com/file.mp4", - path: "file://../file.mp4", fileName: "file.mp4", progress: 0, resumeData: nil, @@ -862,7 +860,6 @@ final class CourseContainerViewModelTests: XCTestCase { id: "1", courseId: "course123", url: "https://example.com/file.mp4", - path: nil, fileName: "file.mp4", progress: 0, resumeData: nil, diff --git a/OpenEdX/Data/CorePersistence.swift b/OpenEdX/Data/CorePersistence.swift index 38605c97b..75b136eb5 100644 --- a/OpenEdX/Data/CorePersistence.swift +++ b/OpenEdX/Data/CorePersistence.swift @@ -49,7 +49,6 @@ public class CorePersistence: CorePersistenceProtocol { id: $0.id ?? "", courseId: $0.courseId ?? "", url: $0.url ?? "", - path: $0.path, fileName: $0.fileName ?? "", progress: $0.progress, resumeData: $0.resumeData, @@ -65,7 +64,9 @@ public class CorePersistence: CorePersistenceProtocol { request.predicate = NSPredicate(format: "id = %@", block.id) guard (try? context.fetch(request).first) == nil else { continue } guard let url = block.videoUrl, - let fileName = URL(string: url)?.lastPathComponent else { continue } + let fileExtension = URL(string: url)?.pathExtension + else { continue } + let fileName = "\(block.id).\(fileExtension)" context.performAndWait { let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump @@ -90,7 +91,6 @@ public class CorePersistence: CorePersistenceProtocol { id: data.id ?? "", courseId: data.courseId ?? "", url: data.url ?? "", - path: data.path, fileName: data.fileName ?? "", progress: data.progress, resumeData: data.resumeData, @@ -108,7 +108,6 @@ public class CorePersistence: CorePersistenceProtocol { id: $0.id ?? "", courseId: $0.courseId ?? "", url: $0.url ?? "", - path: $0.path, fileName: $0.fileName ?? "", progress: $0.progress, resumeData: $0.resumeData, @@ -126,7 +125,6 @@ public class CorePersistence: CorePersistenceProtocol { id: downloadData.id ?? "", courseId: downloadData.courseId ?? "", url: downloadData.url ?? "", - path: downloadData.path, fileName: downloadData.fileName ?? "", progress: downloadData.progress, resumeData: downloadData.resumeData, @@ -135,13 +133,12 @@ public class CorePersistence: CorePersistenceProtocol { ) } - public func updateDownloadState(id: String, state: DownloadState, path: String?, resumeData: Data?) { + 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 - downloadData.path = path downloadData.resumeData = resumeData do { try context.save() diff --git a/OpenEdX/uk.lproj/languages.json b/OpenEdX/uk.lproj/languages.json index 5c07f7b8c..971a7c53b 100644 --- a/OpenEdX/uk.lproj/languages.json +++ b/OpenEdX/uk.lproj/languages.json @@ -1,6 +1,6 @@ [ { - "аа": "Афарська" + "aa": "Афарська" }, { "ab": "Абхазька" diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index 3a1b79761..f0a439d48 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -199,9 +199,18 @@ public struct EditProfileView: View { } } .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) + .navigationBarBackButtonHidden(true) .navigationTitle(ProfileLocalization.editProfile) .toolbar { + ToolbarItem(placement: .navigationBarLeading, content: { + Button(action: { + viewModel.backButtonTapped() + }, label: { + CoreAssets.arrowLeft.swiftUIImage + .renderingMode(.template) + .foregroundColor(Theme.Colors.accentColor) + }).opacity(viewModel.isChanged ? 1 : 0.3) + }) ToolbarItem(placement: .navigationBarTrailing, content: { Button(action: { if viewModel.isChanged { @@ -212,7 +221,8 @@ public struct EditProfileView: View { } }, label: { HStack(spacing: 2) { - CoreAssets.done.swiftUIImage + CoreAssets.done.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.accentColor) Text(CoreLocalization.done) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.accentColor) From d02973466026553463e35453fdd49805e6b92e4f Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Fri, 22 Sep 2023 14:43:24 +0300 Subject: [PATCH 19/19] Change license to Apache (#78) --- LICENSE | 862 +++++++++++++----------------------------------------- README.md | 18 +- 2 files changed, 202 insertions(+), 678 deletions(-) diff --git a/LICENSE b/LICENSE index 0ad25db4b..f49a4e16e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,661 +1,201 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index 2495c8fae..668a7a554 100644 --- a/README.md +++ b/README.md @@ -24,23 +24,7 @@ This project uses custom APIs to improve performance and reduce the number of re You can find the plugin with the API and installation guide [here](https://github.com/raccoongang/mobile-api-extensions). -## Roadmap -Please feel welcome to develop any of the suggested features below and submit a pull request. - -- ✅ ~~Migrate to the new APIs~~ -- ✅ ~~New Navigation~~ -- ✅ ~~Analytics and Crashlytics~~ -- Recent searches -- Migrate to the Olive and JWT token -- UnAuth User mode -- Prerequisite course -- Prerequisite sections -- Scorm XBlocks -- Native Programs -- New discovery (catalog) -- E-Commerce - ## License -The code in this repository is licensed under the AGPL v3 license unless otherwise noted. +The code in this repository is licensed under the Apache-2.0 license unless otherwise noted. Please see [LICENSE](https://github.com/raccoongang/educationx-app-ios/blob/main/LICENSE) file for details.