diff --git a/.gitignore b/.gitignore index 3e3e2a9d2..998e24696 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,9 @@ ## User settings xcuserdata/* -/NewEdX.xcodeproj/xcuserdata/ -/NewEdX.xcworkspace/xcuserdata/ -/NewEdX.xcworkspace/xcshareddata/swiftpm/Package.resolved +/OpenEdX.xcodeproj/xcuserdata/ +/OpenEdX.xcworkspace/xcuserdata/ +/OpenEdX.xcworkspace/xcshareddata/swiftpm/Package.resolved ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) *.xcscmblueprint diff --git a/.swiftlint.yml b/.swiftlint.yml index 207446104..5cf8633aa 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -36,7 +36,8 @@ excluded: # paths to ignore during linting. Takes precedence over `included`. force_try: error -line_length: 125 +line_length: 120 +type_body_length: 300 trailing_whitespace: ignores_empty_lines: true @@ -50,7 +51,7 @@ function_parameter_count: error: 12 type_name: - min_length: 4 # only warning + min_length: 3 # only warning max_length: # warning and error warning: 40 error: 50 diff --git a/Authorization/Authorization.xcodeproj.xcworkspace/contents.xcworkspacedata b/Authorization/Authorization.xcodeproj.xcworkspace/contents.xcworkspacedata index 080e833b6..7ae574247 100644 --- a/Authorization/Authorization.xcodeproj.xcworkspace/contents.xcworkspacedata +++ b/Authorization/Authorization.xcodeproj.xcworkspace/contents.xcworkspacedata @@ -8,7 +8,7 @@ location = "group:../Core/Core.xcodeproj"> + location = "group:../OpenEdX.xcodeproj"> diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index 1ca667fa4..b4c539d33 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 022D04962976DA6500E0059B /* AuthorizationMock.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022D04952976DA6500E0059B /* AuthorizationMock.generated.swift */; }; 025F40E029D1E2FC0064C183 /* ResetPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025F40DF29D1E2FC0064C183 /* ResetPasswordView.swift */; }; 025F40E229D360E20064C183 /* ResetPasswordViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025F40E129D360E20064C183 /* ResetPasswordViewModel.swift */; }; + 02A2ACDB2A4B016100FBBBBB /* AuthorizationAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A2ACDA2A4B016100FBBBBB /* AuthorizationAnalytics.swift */; }; 02E0618429DC2373006E9024 /* ResetPasswordViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E0618329DC2373006E9024 /* ResetPasswordViewModelTests.swift */; }; 02F3BFE5292533720051930C /* AuthorizationRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFE4292533720051930C /* AuthorizationRouter.swift */; }; 071009C728D1DA4F00344290 /* SignInViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 071009C628D1DA4F00344290 /* SignInViewModel.swift */; }; @@ -46,6 +47,7 @@ 022D04952976DA6500E0059B /* AuthorizationMock.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthorizationMock.generated.swift; sourceTree = ""; }; 025F40DF29D1E2FC0064C183 /* ResetPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPasswordView.swift; sourceTree = ""; }; 025F40E129D360E20064C183 /* ResetPasswordViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPasswordViewModel.swift; sourceTree = ""; }; + 02A2ACDA2A4B016100FBBBBB /* AuthorizationAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationAnalytics.swift; sourceTree = ""; }; 02E0618329DC2373006E9024 /* ResetPasswordViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPasswordViewModelTests.swift; sourceTree = ""; }; 02ED50CC29A64B90008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F3BFE4292533720051930C /* AuthorizationRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationRouter.swift; sourceTree = ""; }; @@ -142,6 +144,7 @@ 07169462296D93E000E3DED6 /* Registration */, 025F40DE29D1C1350064C183 /* Reset Password */, 02F3BFE4292533720051930C /* AuthorizationRouter.swift */, + 02A2ACDA2A4B016100FBBBBB /* AuthorizationAnalytics.swift */, ); path = Presentation; sourceTree = ""; @@ -468,6 +471,7 @@ 0770DE7128D0C0E7006D8A5D /* Strings.swift in Sources */, 025F40E229D360E20064C183 /* ResetPasswordViewModel.swift in Sources */, 02066B462906D72F00F4307E /* SignUpViewModel.swift in Sources */, + 02A2ACDB2A4B016100FBBBBB /* AuthorizationAnalytics.swift in Sources */, 025F40E029D1E2FC0064C183 /* ResetPasswordView.swift in Sources */, 020C31CB290BF49900D6DEA2 /* FieldsView.swift in Sources */, 0770DE4E28D0A677006D8A5D /* SignInView.swift in Sources */, @@ -586,7 +590,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Authorization; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Authorization; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -609,7 +613,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.AuthorizationTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -697,7 +701,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Authorization; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Authorization; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -719,7 +723,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.AuthorizationTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -737,7 +741,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.AuthorizationTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -755,7 +759,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.AuthorizationTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -773,7 +777,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.AuthorizationTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -791,7 +795,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.AuthorizationTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -809,7 +813,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.AuthorizationTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -827,7 +831,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.AuthorizationTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -921,7 +925,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Authorization; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Authorization; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1014,7 +1018,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Authorization; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Authorization; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1112,7 +1116,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Authorization; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Authorization; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1205,7 +1209,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Authorization; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Authorization; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1361,7 +1365,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Authorization; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Authorization; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1396,7 +1400,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Authorization; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Authorization; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift new file mode 100644 index 000000000..6c7a60393 --- /dev/null +++ b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift @@ -0,0 +1,38 @@ +// +// AuthorizationAnalytics.swift +// Authorization +// +// Created by  Stepanok Ivan on 27.06.2023. +// + +import Foundation + +public enum LoginMethod: String { + case password = "Password" + case facebook = "Facebook" + case google = "Google" + case microsoft = "Microsoft" +} + +//sourcery: AutoMockable +public protocol AuthorizationAnalytics { + func setUserID(_ id: String) + func userLogin(method: LoginMethod) + func signUpClicked() + func createAccountClicked() + func registrationSuccess() + func forgotPasswordClicked() + func resetPasswordClicked(success: Bool) +} + +#if DEBUG +class AuthorizationAnalyticsMock: AuthorizationAnalytics { + public func setUserID(_ id: String) {} + public func userLogin(method: LoginMethod) {} + public func signUpClicked() {} + public func createAccountClicked() {} + public func registrationSuccess() {} + public func forgotPasswordClicked() {} + public func resetPasswordClicked(success: Bool) {} +} +#endif diff --git a/Authorization/Authorization/Presentation/Base/FieldsView.swift b/Authorization/Authorization/Presentation/Base/FieldsView.swift index f7b5fe513..c021b565c 100644 --- a/Authorization/Authorization/Presentation/Base/FieldsView.swift +++ b/Authorization/Authorization/Presentation/Base/FieldsView.swift @@ -19,49 +19,55 @@ struct FieldsView: View { @State private var text: String = "" var body: some View { - ForEach(0.. User { addInvocation(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))) let perform = methodPerformValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))) as? ([String: String]) -> Void perform?(`fields`) + var __value: User do { - _ = try methodReturnValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() as Void + __value = try methodReturnValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() } catch MockError.notStubed { - // do nothing + onFatalFailure("Stub return value not specified for registerUser(fields: [String: String]). Use given") + Failure("Stub return value not specified for registerUser(fields: [String: String]). Use given") } catch { throw error } + return __value } open func validateRegistrationFields(fields: [String: String]) throws -> [String: String] { @@ -233,6 +236,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func getRegistrationFields(willReturn: [PickerFields]...) -> MethodStub { return Given(method: .m_getRegistrationFields, products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func registerUser(fields: Parameter<[String: String]>, willReturn: User...) -> MethodStub { + return Given(method: .m_registerUser__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func validateRegistrationFields(fields: Parameter<[String: String]>, willReturn: [String: String]...) -> MethodStub { return Given(method: .m_validateRegistrationFields__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -281,10 +287,10 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func registerUser(fields: Parameter<[String: String]>, willThrow: Error...) -> MethodStub { return Given(method: .m_registerUser__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func registerUser(fields: Parameter<[String: String]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func registerUser(fields: Parameter<[String: String]>, willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] let given: Given = { return Given(method: .m_registerUser__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + let stubber = given.stubThrows(for: (User).self) willProduce(stubber) return given } @@ -410,6 +416,277 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { } } +// MARK: - AuthorizationAnalytics + +open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func setUserID(_ id: String) { + addInvocation(.m_setUserID__id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_setUserID__id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + } + + open func userLogin(method: LoginMethod) { + addInvocation(.m_userLogin__method_method(Parameter.value(`method`))) + let perform = methodPerformValue(.m_userLogin__method_method(Parameter.value(`method`))) as? (LoginMethod) -> Void + perform?(`method`) + } + + open func signUpClicked() { + addInvocation(.m_signUpClicked) + let perform = methodPerformValue(.m_signUpClicked) as? () -> Void + perform?() + } + + open func createAccountClicked() { + addInvocation(.m_createAccountClicked) + let perform = methodPerformValue(.m_createAccountClicked) as? () -> Void + perform?() + } + + open func registrationSuccess() { + addInvocation(.m_registrationSuccess) + let perform = methodPerformValue(.m_registrationSuccess) as? () -> Void + perform?() + } + + open func forgotPasswordClicked() { + addInvocation(.m_forgotPasswordClicked) + let perform = methodPerformValue(.m_forgotPasswordClicked) as? () -> Void + perform?() + } + + open func resetPasswordClicked(success: Bool) { + addInvocation(.m_resetPasswordClicked__success_success(Parameter.value(`success`))) + let perform = methodPerformValue(.m_resetPasswordClicked__success_success(Parameter.value(`success`))) as? (Bool) -> Void + perform?(`success`) + } + + + fileprivate enum MethodType { + case m_setUserID__id(Parameter) + case m_userLogin__method_method(Parameter) + case m_signUpClicked + case m_createAccountClicked + case m_registrationSuccess + case m_forgotPasswordClicked + case m_resetPasswordClicked__success_success(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_setUserID__id(let lhsId), .m_setUserID__id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "_ id")) + return Matcher.ComparisonResult(results) + + case (.m_userLogin__method_method(let lhsMethod), .m_userLogin__method_method(let rhsMethod)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsMethod, rhs: rhsMethod, with: matcher), lhsMethod, rhsMethod, "method")) + return Matcher.ComparisonResult(results) + + case (.m_signUpClicked, .m_signUpClicked): return .match + + case (.m_createAccountClicked, .m_createAccountClicked): return .match + + case (.m_registrationSuccess, .m_registrationSuccess): return .match + + case (.m_forgotPasswordClicked, .m_forgotPasswordClicked): return .match + + case (.m_resetPasswordClicked__success_success(let lhsSuccess), .m_resetPasswordClicked__success_success(let rhsSuccess)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSuccess, rhs: rhsSuccess, with: matcher), lhsSuccess, rhsSuccess, "success")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_setUserID__id(p0): return p0.intValue + case let .m_userLogin__method_method(p0): return p0.intValue + case .m_signUpClicked: return 0 + case .m_createAccountClicked: return 0 + case .m_registrationSuccess: return 0 + case .m_forgotPasswordClicked: return 0 + case let .m_resetPasswordClicked__success_success(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_setUserID__id: return ".setUserID(_:)" + case .m_userLogin__method_method: return ".userLogin(method:)" + case .m_signUpClicked: return ".signUpClicked()" + case .m_createAccountClicked: return ".createAccountClicked()" + case .m_registrationSuccess: return ".registrationSuccess()" + case .m_forgotPasswordClicked: return ".forgotPasswordClicked()" + case .m_resetPasswordClicked__success_success: return ".resetPasswordClicked(success:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func setUserID(_ id: Parameter) -> Verify { return Verify(method: .m_setUserID__id(`id`))} + public static func userLogin(method: Parameter) -> Verify { return Verify(method: .m_userLogin__method_method(`method`))} + public static func signUpClicked() -> Verify { return Verify(method: .m_signUpClicked)} + public static func createAccountClicked() -> Verify { return Verify(method: .m_createAccountClicked)} + public static func registrationSuccess() -> Verify { return Verify(method: .m_registrationSuccess)} + public static func forgotPasswordClicked() -> Verify { return Verify(method: .m_forgotPasswordClicked)} + public static func resetPasswordClicked(success: Parameter) -> Verify { return Verify(method: .m_resetPasswordClicked__success_success(`success`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func setUserID(_ id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_setUserID__id(`id`), performs: perform) + } + public static func userLogin(method: Parameter, perform: @escaping (LoginMethod) -> Void) -> Perform { + return Perform(method: .m_userLogin__method_method(`method`), performs: perform) + } + public static func signUpClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_signUpClicked, performs: perform) + } + public static func createAccountClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_createAccountClicked, performs: perform) + } + public static func registrationSuccess(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_registrationSuccess, performs: perform) + } + public static func forgotPasswordClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_forgotPasswordClicked, performs: perform) + } + public static func resetPasswordClicked(success: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_resetPasswordClicked__success_success(`success`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - AuthorizationRouter open class AuthorizationRouterMock: AuthorizationRouter, Mock { @@ -514,10 +791,10 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { perform?(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`) } - open func presentAlert(alertTitle: String, alertMessage: String, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void) { - addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) - let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) as? (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void - perform?(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`) + open func presentAlert(alertTitle: String, alertMessage: String, nextSectionName: String?, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, nextSectionTapped: @escaping () -> Void) { + addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) + let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) as? (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void + perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { @@ -544,7 +821,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case m_showRegisterScreen case m_showForgotPasswordScreen case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) - case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>) + case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) case m_presentView__transitionStyle_transitionStylecontent_content(Parameter, Parameter<() -> any View>) @@ -590,14 +867,16 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) return Matcher.ComparisonResult(results) - case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped)): + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsNextsectionname, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped, let lhsNextsectiontapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsNextsectionname, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped, let rhsNextsectiontapped)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlertmessage, rhs: rhsAlertmessage, with: matcher), lhsAlertmessage, rhsAlertmessage, "alertMessage")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectionname, rhs: rhsNextsectionname, with: matcher), lhsNextsectionname, rhsNextsectionname, "nextSectionName")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsImage, rhs: rhsImage, with: matcher), lhsImage, rhsImage, "image")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOnclosetapped, rhs: rhsOnclosetapped, with: matcher), lhsOnclosetapped, rhsOnclosetapped, "onCloseTapped")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOktapped, rhs: rhsOktapped, with: matcher), lhsOktapped, rhsOktapped, "okTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): @@ -627,7 +906,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue - case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue case let .m_presentView__transitionStyle_transitionStylecontent_content(p0, p1): return p0.intValue + p1.intValue } @@ -644,7 +923,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" - case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped: return ".presentAlert(alertTitle:alertMessage:action:image:onCloseTapped:okTapped:)" + case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" case .m_presentView__transitionStyle_transitionStylecontent_content: return ".presentView(transitionStyle:content:)" } @@ -675,7 +954,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`))} + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} public static func presentView(transitionStyle: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStylecontent_content(`transitionStyle`, `content`))} } @@ -714,8 +993,8 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, perform: @escaping (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { - return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`), performs: perform) + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { + return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) @@ -902,10 +1181,10 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`) } - open func presentAlert(alertTitle: String, alertMessage: String, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void) { - addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) - let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) as? (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void - perform?(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`) + open func presentAlert(alertTitle: String, alertMessage: String, nextSectionName: String?, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, nextSectionTapped: @escaping () -> Void) { + addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) + let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) as? (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void + perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { @@ -932,7 +1211,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showRegisterScreen case m_showForgotPasswordScreen case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) - case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>) + case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) case m_presentView__transitionStyle_transitionStylecontent_content(Parameter, Parameter<() -> any View>) @@ -978,14 +1257,16 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) return Matcher.ComparisonResult(results) - case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped)): + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsNextsectionname, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped, let lhsNextsectiontapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsNextsectionname, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped, let rhsNextsectiontapped)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlertmessage, rhs: rhsAlertmessage, with: matcher), lhsAlertmessage, rhsAlertmessage, "alertMessage")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectionname, rhs: rhsNextsectionname, with: matcher), lhsNextsectionname, rhsNextsectionname, "nextSectionName")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsImage, rhs: rhsImage, with: matcher), lhsImage, rhsImage, "image")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOnclosetapped, rhs: rhsOnclosetapped, with: matcher), lhsOnclosetapped, rhsOnclosetapped, "onCloseTapped")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOktapped, rhs: rhsOktapped, with: matcher), lhsOktapped, rhsOktapped, "okTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): @@ -1015,7 +1296,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue - case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue case let .m_presentView__transitionStyle_transitionStylecontent_content(p0, p1): return p0.intValue + p1.intValue } @@ -1032,7 +1313,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" - case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped: return ".presentAlert(alertTitle:alertMessage:action:image:onCloseTapped:okTapped:)" + case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" case .m_presentView__transitionStyle_transitionStylecontent_content: return ".presentView(transitionStyle:content:)" } @@ -1063,7 +1344,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`))} + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} public static func presentView(transitionStyle: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStylecontent_content(`transitionStyle`, `content`))} } @@ -1102,8 +1383,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, perform: @escaping (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { - return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`), performs: perform) + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { + return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) diff --git a/Authorization/AuthorizationTests/Presentation/Login/ResetPasswordViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/ResetPasswordViewModelTests.swift index 75be4e24f..6b09633f2 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/ResetPasswordViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/ResetPasswordViewModelTests.swift @@ -18,7 +18,11 @@ final class ResetPasswordViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = ResetPasswordViewModel(interactor: interactor, router: router, validator: validator) + let analytics = AuthorizationAnalyticsMock() + let viewModel = ResetPasswordViewModel(interactor: interactor, + router: router, + analytics: analytics, + validator: validator) var isRecoveryPassword = true let binding = Binding(get: { @@ -40,7 +44,11 @@ final class ResetPasswordViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = ResetPasswordViewModel(interactor: interactor, router: router, validator: validator) + let analytics = AuthorizationAnalyticsMock() + let viewModel = ResetPasswordViewModel(interactor: interactor, + router: router, + analytics: analytics, + validator: validator) var isRecoveryPassword = true let binding = Binding(get: { @@ -66,7 +74,11 @@ final class ResetPasswordViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = ResetPasswordViewModel(interactor: interactor, router: router, validator: validator) + let analytics = AuthorizationAnalyticsMock() + let viewModel = ResetPasswordViewModel(interactor: interactor, + router: router, + analytics: analytics, + validator: validator) let validationErrorMessage = "Some error" let validationError = CustomValidationError(statusCode: 400, data: ["value": validationErrorMessage]) @@ -94,7 +106,11 @@ final class ResetPasswordViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = ResetPasswordViewModel(interactor: interactor, router: router, validator: validator) + let analytics = AuthorizationAnalyticsMock() + let viewModel = ResetPasswordViewModel(interactor: interactor, + router: router, + analytics: analytics, + validator: validator) Given(interactor, .resetPassword(email: .any, willThrow: APIError.invalidGrant)) @@ -118,7 +134,11 @@ final class ResetPasswordViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = ResetPasswordViewModel(interactor: interactor, router: router, validator: validator) + let analytics = AuthorizationAnalyticsMock() + let viewModel = ResetPasswordViewModel(interactor: interactor, + router: router, + analytics: analytics, + validator: validator) Given(interactor, .resetPassword(email: .any, willThrow: NSError())) @@ -142,8 +162,12 @@ final class ResetPasswordViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = ResetPasswordViewModel(interactor: interactor, router: router, validator: validator) - + let analytics = AuthorizationAnalyticsMock() + let viewModel = ResetPasswordViewModel(interactor: interactor, + router: router, + analytics: analytics, + validator: validator) + let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) Given(interactor, .resetPassword(email: .any, willThrow: noInternetError)) diff --git a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift index 01748fb87..2fa56145d 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift @@ -26,7 +26,11 @@ final class SignInViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = SignInViewModel(interactor: interactor, router: router, validator: validator) + let analytics = AuthorizationAnalyticsMock() + let viewModel = SignInViewModel(interactor: interactor, + router: router, + analytics: analytics, + validator: validator) await viewModel.login(username: "email", password: "") @@ -41,8 +45,11 @@ final class SignInViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = SignInViewModel(interactor: interactor, router: router, validator: validator) - + let analytics = AuthorizationAnalyticsMock() + 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)) @@ -56,7 +63,11 @@ final class SignInViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = SignInViewModel(interactor: interactor, router: router, validator: validator) + let analytics = AuthorizationAnalyticsMock() + 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)) @@ -64,6 +75,7 @@ final class SignInViewModelTests: XCTestCase { await viewModel.login(username: "edxUser@edx.com", password: "password123") Verify(interactor, 1, .login(username: .any, password: .any)) + Verify(analytics, .userLogin(method: .any)) Verify(router, 1, .showMainScreen()) XCTAssertEqual(viewModel.errorMessage, nil) @@ -74,7 +86,11 @@ final class SignInViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = SignInViewModel(interactor: interactor, router: router, validator: validator) + let analytics = AuthorizationAnalyticsMock() + let viewModel = SignInViewModel(interactor: interactor, + router: router, + analytics: analytics, + validator: validator) let validationErrorMessage = "Some error" let validationError = CustomValidationError(statusCode: 400, data: ["error_description": validationErrorMessage]) @@ -95,7 +111,11 @@ final class SignInViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = SignInViewModel(interactor: interactor, router: router, validator: validator) + let analytics = AuthorizationAnalyticsMock() + let viewModel = SignInViewModel(interactor: interactor, + router: router, + analytics: analytics, + validator: validator) Given(interactor, .login(username: .any, password: .any, willThrow: APIError.invalidGrant)) @@ -112,7 +132,11 @@ final class SignInViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = SignInViewModel(interactor: interactor, router: router, validator: validator) + let analytics = AuthorizationAnalyticsMock() + let viewModel = SignInViewModel(interactor: interactor, + router: router, + analytics: analytics, + validator: validator) Given(interactor, .login(username: .any, password: .any, willThrow: NSError())) @@ -129,7 +153,11 @@ final class SignInViewModelTests: XCTestCase { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() let validator = Validator() - let viewModel = SignInViewModel(interactor: interactor, router: router, validator: validator) + let analytics = AuthorizationAnalyticsMock() + let viewModel = SignInViewModel(interactor: interactor, + router: router, + analytics: analytics, + validator: validator) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) diff --git a/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift index 853bb224d..ee463f6ef 100644 --- a/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift @@ -26,8 +26,10 @@ final class SignUpViewModelTests: XCTestCase { 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) @@ -53,8 +55,10 @@ final class SignUpViewModelTests: XCTestCase { 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) @@ -75,8 +79,10 @@ final class SignUpViewModelTests: XCTestCase { 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) @@ -95,13 +101,19 @@ final class SignUpViewModelTests: XCTestCase { 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) - Given(interactor, .registerUser(fields: .any, willProduce: {_ in})) + Given(interactor, .registerUser(fields: .any, willReturn: .init(id: 1, + username: "Name", + email: "mail", + name: "name", + userAvatar: "avatar"))) Given(interactor, .validateRegistrationFields(fields: .any, willReturn: [:])) await viewModel.registerUser() @@ -118,8 +130,10 @@ final class SignUpViewModelTests: XCTestCase { 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) @@ -151,8 +165,10 @@ final class SignUpViewModelTests: XCTestCase { 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) @@ -175,8 +191,10 @@ final class SignUpViewModelTests: XCTestCase { 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) @@ -199,8 +217,10 @@ final class SignUpViewModelTests: XCTestCase { 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) diff --git a/Core/Core.xcodeproj.xcworkspace/contents.xcworkspacedata b/Core/Core.xcodeproj.xcworkspace/contents.xcworkspacedata index f211704ff..0efc22dd7 100644 --- a/Core/Core.xcodeproj.xcworkspace/contents.xcworkspacedata +++ b/Core/Core.xcodeproj.xcworkspace/contents.xcworkspacedata @@ -5,7 +5,7 @@ location = "group:Core.xcodeproj"> + location = "group:../OpenEdX.xcodeproj"> diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index f8b06176f..a23953164 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 021D925028DC89D100ACC565 /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924F28DC89D100ACC565 /* UserProfile.swift */; }; 021D925728DCF12900ACC565 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D925628DCF12900ACC565 /* AlertView.swift */; }; 02280F5B294B4E6F0032823A /* Connectivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5A294B4E6F0032823A /* Connectivity.swift */; }; + 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */; }; 022C64E429AE0191000F532B /* TextWithUrls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64E329AE0191000F532B /* TextWithUrls.swift */; }; 0231CDBE2922422D00032416 /* CSSInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0231CDBD2922422D00032416 /* CSSInjector.swift */; }; 0236961928F9A26900EEF206 /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0236961828F9A26900EEF206 /* AuthRepository.swift */; }; @@ -34,6 +35,7 @@ 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 */; }; 025EF2F62971740000B838AB /* YouTubePlayerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 025EF2F52971740000B838AB /* YouTubePlayerKit */; }; 0260E58028FD792800BBBE18 /* WebUnitViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0260E57F28FD792800BBBE18 /* WebUnitViewModel.swift */; }; 027BD3922907D88F00392132 /* Data_RegistrationFields.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3912907D88F00392132 /* Data_RegistrationFields.swift */; }; @@ -50,7 +52,6 @@ 027BD3B92909476200392132 /* KeyboardAvoidingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3B72909476200392132 /* KeyboardAvoidingModifier.swift */; }; 027BD3BD2909478B00392132 /* UIView+EnclosingScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3BA2909478B00392132 /* UIView+EnclosingScrollView.swift */; }; 027BD3BE2909478B00392132 /* UIResponder+CurrentResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3BB2909478B00392132 /* UIResponder+CurrentResponder.swift */; }; - 027BD3BF2909478B00392132 /* UIApplication+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3BC2909478B00392132 /* UIApplication+.swift */; }; 027BD3C52909707700392132 /* Shake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3C42909707700392132 /* Shake.swift */; }; 027DB33528D8C8FE002B6862 /* Data_MyCourse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB33428D8C8FE002B6862 /* Data_MyCourse.swift */; }; 0282DA7328F98CC9003C3F07 /* WebUnitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0282DA7228F98CC9003C3F07 /* WebUnitView.swift */; }; @@ -62,7 +63,6 @@ 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 */; }; - 029B78F1292517860097ACD8 /* Sequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029B78F0292517860097ACD8 /* Sequence.swift */; }; 02A4833529B8A73400D33F33 /* CorePersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833429B8A73400D33F33 /* CorePersistence.swift */; }; 02A4833829B8A8F900D33F33 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833629B8A8F800D33F33 /* CoreDataModel.xcdatamodeld */; }; 02A4833A29B8A9AB00D33F33 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */; }; @@ -97,6 +97,7 @@ 072787B628D37A0E002E9142 /* Validator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072787B528D37A0E002E9142 /* Validator.swift */; }; 07460FE1294B706200F70538 /* CollectionExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07460FE0294B706200F70538 /* CollectionExtension.swift */; }; 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 */; }; 0770DE2A28D0929E006D8A5D /* HTTPTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE2928D0929E006D8A5D /* HTTPTask.swift */; }; @@ -133,6 +134,7 @@ 021D924F28DC89D100ACC565 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 021D925628DCF12900ACC565 /* AlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = ""; }; 02280F5A294B4E6F0032823A /* Connectivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.swift; sourceTree = ""; }; + 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.swift; sourceTree = ""; }; 022C64E329AE0191000F532B /* TextWithUrls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextWithUrls.swift; sourceTree = ""; }; 0231CDBD2922422D00032416 /* CSSInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSInjector.swift; sourceTree = ""; }; 0236961828F9A26900EEF206 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRepository.swift; sourceTree = ""; }; @@ -153,6 +155,7 @@ 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 = ""; }; 0260E57F28FD792800BBBE18 /* WebUnitViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebUnitViewModel.swift; sourceTree = ""; }; 027BD3912907D88F00392132 /* Data_RegistrationFields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_RegistrationFields.swift; sourceTree = ""; }; 027BD39B2908810C00392132 /* RegisterUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterUser.swift; sourceTree = ""; }; @@ -168,7 +171,6 @@ 027BD3B72909476200392132 /* KeyboardAvoidingModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardAvoidingModifier.swift; sourceTree = ""; }; 027BD3BA2909478B00392132 /* UIView+EnclosingScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+EnclosingScrollView.swift"; sourceTree = ""; }; 027BD3BB2909478B00392132 /* UIResponder+CurrentResponder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIResponder+CurrentResponder.swift"; sourceTree = ""; }; - 027BD3BC2909478B00392132 /* UIApplication+.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIApplication+.swift"; sourceTree = ""; }; 027BD3C42909707700392132 /* Shake.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shake.swift; sourceTree = ""; }; 027DB33428D8C8FE002B6862 /* Data_MyCourse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_MyCourse.swift; sourceTree = ""; }; 0282DA7228F98CC9003C3F07 /* WebUnitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebUnitView.swift; sourceTree = ""; }; @@ -180,7 +182,6 @@ 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 = ""; }; - 029B78F0292517860097ACD8 /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; 02A4833429B8A73400D33F33 /* CorePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePersistence.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 = ""; }; @@ -217,6 +218,7 @@ 07460FE0294B706200F70538 /* CollectionExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionExtension.swift; sourceTree = ""; }; 07460FE2294B72D700F70538 /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = ""; }; 0754BB7841E3C0F8D6464951 /* Pods-App-Core.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.releasestage.xcconfig"; sourceTree = ""; }; + 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 = ""; }; @@ -329,17 +331,16 @@ 0283347F28D4DCD200C828FC /* ViewExtension.swift */, 02F6EF4928D9F0A700835477 /* DateExtension.swift */, 02F98A7E28F81EE900DE94C0 /* Container+App.swift */, - 027BD3BC2909478B00392132 /* UIApplication+.swift */, 027BD3BB2909478B00392132 /* UIResponder+CurrentResponder.swift */, 027BD3BA2909478B00392132 /* UIView+EnclosingScrollView.swift */, 02E225AF291D29EB0067769A /* UrlExtension.swift */, - 029B78F0292517860097ACD8 /* Sequence.swift */, 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */, 07460FE0294B706200F70538 /* CollectionExtension.swift */, 07460FE2294B72D700F70538 /* Notification.swift */, 02B2B593295C5C7A00914876 /* Thread.swift */, 0727878228D31287002E9142 /* DispatchQueue+App.swift */, 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */, + 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -425,6 +426,7 @@ 027BD39B2908810C00392132 /* RegisterUser.swift */, 028F9F38293A452B00DE65D0 /* ResetPassword.swift */, 020C31C8290AC3F700D6DEA2 /* PickerFields.swift */, + 076F297E2A1F80C800967E7D /* Pagination.swift */, ); path = Model; sourceTree = ""; @@ -512,6 +514,7 @@ 02D800CB29348F460099CF16 /* ImagePicker.swift */, 024D723429C8BB1A006D36ED /* NavigationBar.swift */, 071009C328D1C9D000344290 /* StyledButton.swift */, + 025B36742A13B7D5001A640E /* UnitButtonView.swift */, 0727877C28D25212002E9142 /* ProgressBar.swift */, 022C64E329AE0191000F532B /* TextWithUrls.swift */, 0727878028D25EFD002E9142 /* SnackBarView.swift */, @@ -754,6 +757,7 @@ 027BD3B92909476200392132 /* KeyboardAvoidingModifier.swift in Sources */, 0770DE2C28D092B3006D8A5D /* NetworkLogger.swift in Sources */, 0770DE2A28D0929E006D8A5D /* HTTPTask.swift in Sources */, + 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */, 0255D5582936283A004DBC1A /* UploadBodyEncoding.swift in Sources */, 027BD3B32909475900392132 /* Publishers+KeyboardState.swift in Sources */, 0727877D28D25212002E9142 /* ProgressBar.swift in Sources */, @@ -763,7 +767,6 @@ 0282DA7328F98CC9003C3F07 /* WebUnitView.swift in Sources */, 0727878128D25EFD002E9142 /* SnackBarView.swift in Sources */, 021D924828DC860C00ACC565 /* Data_UserProfile.swift in Sources */, - 029B78F1292517860097ACD8 /* Sequence.swift in Sources */, 070019AC28F6FD0100D5FC78 /* CourseDetailBlock.swift in Sources */, 0727877028D23411002E9142 /* Config.swift in Sources */, CFC84952299F8B890055E497 /* Debounce.swift in Sources */, @@ -779,6 +782,7 @@ 027BD3AE2909475000392132 /* KeyboardScrollerOptions.swift in Sources */, 027BD3BE2909478B00392132 /* UIResponder+CurrentResponder.swift in Sources */, 070019AE28F701B200D5FC78 /* Certificate.swift in Sources */, + 076F297F2A1F80C800967E7D /* Pagination.swift in Sources */, 0770DE5F28D0B22C006D8A5D /* Strings.swift in Sources */, 02C917F029CDA99E00DBB8BD /* Data_Dashboard.swift in Sources */, 024FCD0028EF1CD300232339 /* WebBrowser.swift in Sources */, @@ -809,7 +813,6 @@ 0248C92329C075EF00DC8402 /* CourseBlockModel.swift in Sources */, 072787B628D37A0E002E9142 /* Validator.swift in Sources */, 0236961D28F9A2D200EEF206 /* Data_AuthResponse.swift in Sources */, - 027BD3BF2909478B00392132 /* UIApplication+.swift in Sources */, 02B2B594295C5C7A00914876 /* Thread.swift in Sources */, 027DB33528D8C8FE002B6862 /* Data_MyCourse.swift in Sources */, 027BD3BD2909478B00392132 /* UIView+EnclosingScrollView.swift in Sources */, @@ -834,6 +837,7 @@ 0236961928F9A26900EEF206 /* AuthRepository.swift in Sources */, 023A1136291432B200D0D354 /* RegistrationTextField.swift in Sources */, 02C2DC0829B63D6200F4445D /* WebViewHTML.swift in Sources */, + 025B36752A13B7D5001A640E /* UnitButtonView.swift in Sources */, 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */, 027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */, 024BE3DF29B2615500BCDEE2 /* CGColorExtension.swift in Sources */, @@ -954,7 +958,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Core; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Core; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -976,7 +980,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CoreTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1067,7 +1071,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Core; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Core; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1088,7 +1092,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CoreTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1108,7 +1112,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CoreTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1128,7 +1132,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CoreTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1148,7 +1152,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CoreTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1168,7 +1172,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CoreTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1188,7 +1192,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CoreTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1208,7 +1212,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CoreTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1305,7 +1309,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Core; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Core; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1398,7 +1402,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Core; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Core; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1496,7 +1500,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Core; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Core; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1589,7 +1593,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Core; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Core; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1745,7 +1749,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Core; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Core; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1780,7 +1784,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Core; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Core; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1847,8 +1851,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SvenTiigi/YouTubePlayerKit"; requirement = { - kind = upToNextMinorVersion; - minimumVersion = 1.3.0; + kind = upToNextMajorVersion; + minimumVersion = 1.5.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Core/Core/Assets.xcassets/Colors/Snackbar/SnackbarInfoAlert.colorset/Contents.json b/Core/Core/Assets.xcassets/Colors/Snackbar/SnackbarInfoAlert.colorset/Contents.json new file mode 100644 index 000000000..3e35599d4 --- /dev/null +++ b/Core/Core/Assets.xcassets/Colors/Snackbar/SnackbarInfoAlert.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.667", + "red" : "0.259" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.584", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/Discussions/discussion.imageset/Contents.json b/Core/Core/Assets.xcassets/Discussions/discussion.imageset/Contents.json index 484e2073b..f4a247408 100644 --- a/Core/Core/Assets.xcassets/Discussions/discussion.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/Discussions/discussion.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Frame-23.svg", + "filename" : "discussionBlack.svg", "idiom" : "universal" }, { @@ -11,7 +11,7 @@ "value" : "dark" } ], - "filename" : "Frame-22.svg", + "filename" : "discussion.svg", "idiom" : "universal" } ], diff --git a/Core/Core/Assets.xcassets/Discussions/discussion.imageset/Frame-22.svg b/Core/Core/Assets.xcassets/Discussions/discussion.imageset/Frame-22.svg deleted file mode 100644 index c6c9bf80f..000000000 --- a/Core/Core/Assets.xcassets/Discussions/discussion.imageset/Frame-22.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/Discussions/discussion.imageset/Frame-23.svg b/Core/Core/Assets.xcassets/Discussions/discussion.imageset/Frame-23.svg deleted file mode 100644 index 16b49b29c..000000000 --- a/Core/Core/Assets.xcassets/Discussions/discussion.imageset/Frame-23.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/Discussions/discussion.imageset/discussion.svg b/Core/Core/Assets.xcassets/Discussions/discussion.imageset/discussion.svg new file mode 100644 index 000000000..114054921 --- /dev/null +++ b/Core/Core/Assets.xcassets/Discussions/discussion.imageset/discussion.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/Discussions/discussion.imageset/discussionBlack.svg b/Core/Core/Assets.xcassets/Discussions/discussion.imageset/discussionBlack.svg new file mode 100644 index 000000000..294b287a3 --- /dev/null +++ b/Core/Core/Assets.xcassets/Discussions/discussion.imageset/discussionBlack.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/Discussions/discussionIcon.imageset/Contents.json b/Core/Core/Assets.xcassets/Discussions/discussionIcon.imageset/Contents.json new file mode 100644 index 000000000..9a7912c28 --- /dev/null +++ b/Core/Core/Assets.xcassets/Discussions/discussionIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "discussionIcon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/Discussions/discussionIcon.imageset/discussionIcon.svg b/Core/Core/Assets.xcassets/Discussions/discussionIcon.imageset/discussionIcon.svg new file mode 100644 index 000000000..48897c349 --- /dev/null +++ b/Core/Core/Assets.xcassets/Discussions/discussionIcon.imageset/discussionIcon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/Discussions/finished.imageset/Contents.json b/Core/Core/Assets.xcassets/Discussions/finished.imageset/Contents.json new file mode 100644 index 000000000..e1f0ce130 --- /dev/null +++ b/Core/Core/Assets.xcassets/Discussions/finished.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "checkLight.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "checkDark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/Discussions/finished.imageset/checkDark.svg b/Core/Core/Assets.xcassets/Discussions/finished.imageset/checkDark.svg new file mode 100644 index 000000000..f81143453 --- /dev/null +++ b/Core/Core/Assets.xcassets/Discussions/finished.imageset/checkDark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/Discussions/finished.imageset/checkLight.svg b/Core/Core/Assets.xcassets/Discussions/finished.imageset/checkLight.svg new file mode 100644 index 000000000..cad0ffe27 --- /dev/null +++ b/Core/Core/Assets.xcassets/Discussions/finished.imageset/checkLight.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Core/Core/Configuration/BaseRouter.swift b/Core/Core/Configuration/BaseRouter.swift index 168e094b9..c6c54ca67 100644 --- a/Core/Core/Configuration/BaseRouter.swift +++ b/Core/Core/Configuration/BaseRouter.swift @@ -41,10 +41,12 @@ public protocol BaseRouter { func presentAlert( alertTitle: String, alertMessage: String, + nextSectionName: String?, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, - okTapped: @escaping () -> Void + okTapped: @escaping () -> Void, + nextSectionTapped: @escaping () -> Void ) func presentView(transitionStyle: UIModalTransitionStyle, view: any View) @@ -99,10 +101,12 @@ open class BaseRouterMock: BaseRouter { public func presentAlert( alertTitle: String, alertMessage: String, + nextSectionName: String? = nil, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, - okTapped: @escaping () -> Void + okTapped: @escaping () -> Void, + nextSectionTapped: @escaping () -> Void ) {} public func presentView(transitionStyle: UIModalTransitionStyle, view: any View) {} diff --git a/Core/Core/Configuration/CSSInjector.swift b/Core/Core/Configuration/CSSInjector.swift index c1fcd96b8..9e7faf0ec 100644 --- a/Core/Core/Configuration/CSSInjector.swift +++ b/Core/Core/Configuration/CSSInjector.swift @@ -71,20 +71,29 @@ public class CSSInjector { } } - public func injectCSS(colorScheme: ColorScheme, html: String, - type: CssType, fontSize: Int = 150, screenWidth: CGFloat) -> String { - let meadiaReplace = html.replacingOccurrences(of: "/media/", - with: baseURL.absoluteString + "/media/") - var replacedHTML = meadiaReplace.replacingOccurrences(of: "../..", - with: baseURL.absoluteString) - .replacingOccurrences(of: "src=\"/", with: "src=\"" + baseURL.absoluteString + "/") + //swiftlint:disable function_body_length line_length + public func injectCSS( + colorScheme: ColorScheme, + html: String, + type: CssType, + fontSize: Int = 150, + screenWidth: CGFloat + ) -> String { + let meadiaReplace = html.replacingOccurrences( + of: "/media/", + with: baseURL.absoluteString + "/media/" + ) + var replacedHTML = meadiaReplace.replacingOccurrences( + of: "../..", + with: baseURL.absoluteString + ).replacingOccurrences(of: "src=\"/", with: "src=\"" + baseURL.absoluteString + "/") .replacingOccurrences(of: "href=\"/", with: "href=\"" + baseURL.absoluteString + "/") .replacingOccurrences(of: "href='/honor'", with: "href='\(baseURL.absoluteString)/honor'") .replacingOccurrences(of: "href='/privacy'", with: "href='\(baseURL.absoluteString)/privacy'") if colorScheme == .dark { replacedHTML = replaceHexColorsInHTML(html: replacedHTML) } - + var maxWidth: String switch type { case .discovery: @@ -130,6 +139,7 @@ public class CSSInjector { """ return style + replacedHTML } + //swiftlint:enable function_body_length line_length } diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 7a04f494a..b825a9ddb 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -1,6 +1,6 @@ // // Connectivity.swift -// NewEdX +// OpenEdX // // Created by  Stepanok Ivan on 15.12.2022. // diff --git a/Core/Core/Data/AppStorage.swift b/Core/Core/Data/AppStorage.swift index 7bd28811a..ee8bccccb 100644 --- a/Core/Core/Data/AppStorage.swift +++ b/Core/Core/Data/AppStorage.swift @@ -79,7 +79,7 @@ public class AppStorage { public var userSettings: UserSettings? { get { guard let userSettings = userDefaults.data(forKey: KEY_SETTINGS) else { - let defaultSettings = UserSettings(wifiOnly: false, downloadQuality: .auto) + let defaultSettings = UserSettings(wifiOnly: true, downloadQuality: .auto) let encoder = JSONEncoder() if let encoded = try? encoder.encode(defaultSettings) { userDefaults.set(encoded, forKey: KEY_SETTINGS) @@ -129,9 +129,9 @@ public class AppStorage { private let KEY_ACCESS_TOKEN = "accessToken" private let KEY_REFRESH_TOKEN = "refreshToken" private let KEY_COOKIES_DATE = "cookiesDate" - private let KEY_USER_PROFILE = "UserProfile" + private let KEY_USER_PROFILE = "userProfile" private let KEY_USER = "refreshToken" - private let KEY_SETTINGS = "UserSettings" + private let KEY_SETTINGS = "userSettings" } // Mark - For testing and SwiftUI preview diff --git a/Core/Core/Data/Model/Data_Dashboard.swift b/Core/Core/Data/Model/Data_Dashboard.swift index 86874824d..4133670a9 100644 --- a/Core/Core/Data/Model/Data_Dashboard.swift +++ b/Core/Core/Data/Model/Data_Dashboard.swift @@ -85,8 +85,12 @@ public extension DataLayer.CourseEnrollments { isActive: true, courseStart: course.course.start != nil ? Date(iso8601: course.course.start!) : nil, courseEnd: course.course.end != nil ? Date(iso8601: course.course.end!) : nil, - enrollmentStart: course.course.enrollmentStart != nil ? Date(iso8601: course.course.enrollmentStart!) : nil, - enrollmentEnd: course.course.enrollmentEnd != nil ? Date(iso8601: course.course.enrollmentEnd!) : nil, + enrollmentStart: course.course.enrollmentStart != nil + ? Date(iso8601: course.course.enrollmentStart!) + : nil, + enrollmentEnd: course.course.enrollmentEnd != nil + ? Date(iso8601: course.course.enrollmentEnd!) + : nil, courseID: course.course.id, numPages: numPages, coursesCount: count diff --git a/Core/Core/Data/Model/Data_Discovery.swift b/Core/Core/Data/Model/Data_Discovery.swift index e44786bc9..6bbc014b5 100644 --- a/Core/Core/Data/Model/Data_Discovery.swift +++ b/Core/Core/Data/Model/Data_Discovery.swift @@ -7,8 +7,6 @@ import Foundation -// MARK: "/api/courses/v1/courses/" - // MARK: - Pagination public extension DataLayer { struct Pagination: Codable { @@ -115,3 +113,9 @@ public extension DataLayer.DiscoveryResponce { return listReady } } + +public extension DataLayer.Pagination { + var domain: Pagination { + Pagination(next: next, previous: previous, count: count, numPages: numPages) + } +} diff --git a/Core/Core/Data/Repository/AuthRepository.swift b/Core/Core/Data/Repository/AuthRepository.swift index 0689bc2c5..e945c8ea4 100644 --- a/Core/Core/Data/Repository/AuthRepository.swift +++ b/Core/Core/Data/Repository/AuthRepository.swift @@ -30,7 +30,11 @@ public class AuthRepository: AuthRepositoryProtocol { public func login(username: String, password: String) async throws -> User { appStorage.cookiesDate = nil - let endPoint = AuthEndpoint.getAccessToken(username: username, password: password, clientId: config.oAuthClientId) + let endPoint = AuthEndpoint.getAccessToken( + username: username, + password: password, + clientId: config.oAuthClientId + ) let authResponse = try await api.requestData(endPoint).mapResponse(DataLayer.AuthResponse.self) guard let accessToken = authResponse.accessToken, let refreshToken = authResponse.refreshToken else { @@ -64,8 +68,8 @@ public class AuthRepository: AuthRepositoryProtocol { appStorage.cookiesDate = Date().dateToString(style: .iso8601) } } else { - appStorage.cookiesDate = Date().dateToString(style: .iso8601) _ = try await api.requestData(AuthEndpoint.getAuthCookies) + appStorage.cookiesDate = Date().dateToString(style: .iso8601) } } diff --git a/Core/Core/Domain/AuthInteractor.swift b/Core/Core/Domain/AuthInteractor.swift index 69bac7296..94e202364 100644 --- a/Core/Core/Domain/AuthInteractor.swift +++ b/Core/Core/Domain/AuthInteractor.swift @@ -14,7 +14,7 @@ public protocol AuthInteractorProtocol { func resetPassword(email: String) async throws -> ResetPassword func getCookies(force: Bool) async throws func getRegistrationFields() async throws -> [PickerFields] - func registerUser(fields: [String: String]) async throws + func registerUser(fields: [String: String]) async throws -> User func validateRegistrationFields(fields: [String: String]) async throws -> [String: String] } @@ -43,8 +43,8 @@ public class AuthInteractor: AuthInteractorProtocol { return try await repository.getRegistrationFields() } - public func registerUser(fields: [String: String]) async throws { - _ = try await repository.registerUser(fields: fields) + public func registerUser(fields: [String: String]) async throws -> User { + return try await repository.registerUser(fields: fields) } public func validateRegistrationFields(fields: [String: String]) async throws -> [String: String] { diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index 2187b2c8f..0165d9ffb 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -9,6 +9,7 @@ import Foundation public struct CourseStructure: Equatable { + public let courseID: String public let id: String public let graded: Bool public let completion: Double @@ -20,7 +21,8 @@ public struct CourseStructure: Equatable { public let media: DataLayer.CourseMedia public let certificate: Certificate? - public init(id: String, + public init(courseID: String, + id: String, graded: Bool, completion: Double, viewYouTubeUrl: String, @@ -30,6 +32,7 @@ public struct CourseStructure: Equatable { childs: [CourseChapter], media: DataLayer.CourseMedia, certificate: Certificate?) { + self.courseID = courseID self.id = id self.graded = graded self.completion = completion diff --git a/Core/Core/Domain/Model/CourseDetailBlock.swift b/Core/Core/Domain/Model/CourseDetailBlock.swift index 9b063eae5..8230d44df 100644 --- a/Core/Core/Domain/Model/CourseDetailBlock.swift +++ b/Core/Core/Domain/Model/CourseDetailBlock.swift @@ -43,13 +43,14 @@ public enum BlockType: String { public var image: Image { switch self { - case .problem: return CoreAssets.extra.swiftUIImage.renderingMode(.template) + case .problem: return CoreAssets.pen.swiftUIImage.renderingMode(.template) case .video: return CoreAssets.video.swiftUIImage.renderingMode(.template) - case .html: return CoreAssets.chapter.swiftUIImage.renderingMode(.template) + case .html: return CoreAssets.extra.swiftUIImage.renderingMode(.template) case .discussion: return CoreAssets.discussion.swiftUIImage.renderingMode(.template) - case .course: return CoreAssets.extra.swiftUIImage.renderingMode(.template) - case .chapter: return CoreAssets.chapter.swiftUIImage.renderingMode(.template) - case .sequential: return CoreAssets.pen.swiftUIImage.renderingMode(.template) + case .course: return CoreAssets.pen.swiftUIImage.renderingMode(.template) + case .chapter: return CoreAssets.pen.swiftUIImage.renderingMode(.template) + case .sequential: return CoreAssets.chapter.swiftUIImage.renderingMode(.template) + case .vertical: return CoreAssets.extra.swiftUIImage.renderingMode(.template) default: return CoreAssets.extra.swiftUIImage.renderingMode(.template) } } diff --git a/Core/Core/Domain/Model/Pagination.swift b/Core/Core/Domain/Model/Pagination.swift new file mode 100644 index 000000000..9cbad7b18 --- /dev/null +++ b/Core/Core/Domain/Model/Pagination.swift @@ -0,0 +1,22 @@ +// +// Pagination.swift +// Core +// +// Created by Vladimir Chekyrta on 25.05.2023. +// + +import Foundation + +public struct Pagination { + public let next: String? + public let previous: String? + public let count: Int + public let numPages: Int + + public init(next: String?, previous: String?, count: Int, numPages: Int) { + self.next = next + self.previous = previous + self.count = count + self.numPages = numPages + } +} diff --git a/Core/Core/Extensions/CGColorExtension.swift b/Core/Core/Extensions/CGColorExtension.swift index ac10df279..3086aaf61 100644 --- a/Core/Core/Extensions/CGColorExtension.swift +++ b/Core/Core/Extensions/CGColorExtension.swift @@ -16,10 +16,12 @@ public extension CGColor { let red = components[0] let green = components[1] let blue = components[2] - let hexString = String(format: "#%02lX%02lX%02lX", - lroundf(Float(red * 255)), - lroundf(Float(green * 255)), - lroundf(Float(blue * 255))) + let hexString = String( + format: "#%02lX%02lX%02lX", + lroundf(Float(red * 255)), + lroundf(Float(green * 255)), + lroundf(Float(blue * 255)) + ) return hexString } } diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index 5363bc31e..fae6cd14c 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -33,18 +33,18 @@ public extension Date { } init(subtitleTime: String) { - let calendar = Calendar.current - let now = Date() - var components = calendar.dateComponents([.year, .month, .day], from: now) - var dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss,SSS" - let dateString = "\(components.year!)-\(components.month!)-\(components.day!) \(subtitleTime)" - guard let date = dateFormatter.date(from: dateString) else { - self = now - return - } - self = date - } + let calendar = Calendar.current + let now = Date() + let components = calendar.dateComponents([.year, .month, .day], from: now) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss,SSS" + let dateString = "\(components.year!)-\(components.month!)-\(components.day!) \(subtitleTime)" + guard let date = dateFormatter.date(from: dateString) else { + self = now + return + } + self = date + } init(milliseconds: Double) { let now = Date() @@ -72,7 +72,7 @@ public enum DateStringStyle { public extension Date { func dateToString(style: DateStringStyle) -> String { let dateFormatter = DateFormatter() - dateFormatter.locale = .current + dateFormatter.locale = Locale(identifier: "en_US_POSIX") switch style { case .endedMonthDay: diff --git a/Core/Core/Extensions/Sequence.swift b/Core/Core/Extensions/Sequence.swift deleted file mode 100644 index 4eefef060..000000000 --- a/Core/Core/Extensions/Sequence.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Sequence.swift -// Core -// -// Created by  Stepanok Ivan on 16.11.2022. -// - -import Foundation - -public extension Sequence where Element: Hashable { - func uniqued() -> [Element] { - var set = Set() - return filter { set.insert($0).inserted } - } -} diff --git a/Core/Core/Extensions/StringExtension.swift b/Core/Core/Extensions/StringExtension.swift index d9e3b57f6..3a5e6c141 100644 --- a/Core/Core/Extensions/StringExtension.swift +++ b/Core/Core/Extensions/StringExtension.swift @@ -21,10 +21,12 @@ public extension String { guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { return self } - return detector.stringByReplacingMatches(in: self, - options: [], - range: NSRange(location: 0, length: self.utf16.count), - withTemplate: "") + return detector.stringByReplacingMatches( + in: self, + options: [], + range: NSRange(location: 0, length: self.utf16.count), + withTemplate: "" + ) .replacingOccurrences(of: "<[^>]+>", with: "", options: String.CompareOptions.regularExpression, range: nil) .replacingOccurrences(of: "

", with: "") .replacingOccurrences(of: "

", with: "") @@ -39,12 +41,15 @@ public extension String { var urls: [URL] = [] do { let detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - detector.enumerateMatches(in: self, options: [], - range: NSRange(location: 0, length: self.count), using: { (result, _, _) in - if let match = result, let url = match.url { - urls.append(url) + detector.enumerateMatches( + in: self, options: [], + range: NSRange(location: 0, length: self.count), + using: { (result, _, _) in + if let match = result, let url = match.url { + urls.append(url) + } } - }) + ) } catch let error as NSError { print(error.localizedDescription) } diff --git a/Core/Core/Extensions/UIApplication+.swift b/Core/Core/Extensions/UIApplication+.swift deleted file mode 100644 index 94ead3d50..000000000 --- a/Core/Core/Extensions/UIApplication+.swift +++ /dev/null @@ -1,13 +0,0 @@ -// - -import UIKit - -extension UIApplication { - var keyWindow: UIWindow? { - UIApplication.shared.windows.first { $0.isKeyWindow } - } - - func endEditing(force: Bool = true) { - windows.forEach { $0.endEditing(force) } - } -} diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift new file mode 100644 index 000000000..1c5dec36c --- /dev/null +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -0,0 +1,36 @@ +// +// UIApplicationExtension.swift +// Core +// +// Created by  Stepanok Ivan on 15.06.2023. +// + +import UIKit + +extension UIApplication { + + public var keyWindow: UIWindow? { + UIApplication.shared.windows.first { $0.isKeyWindow } + } + + public func endEditing(force: Bool = true) { + windows.forEach { $0.endEditing(force) } + } + + public class func topViewController( + controller: UIViewController? = UIApplication.shared.keyWindow?.rootViewController + ) -> UIViewController? { + if let navigationController = controller as? UINavigationController { + return topViewController(controller: navigationController.visibleViewController) + } + if let tabController = controller as? UITabBarController { + if let selected = tabController.selectedViewController { + return topViewController(controller: selected) + } + } + if let presented = controller?.presentedViewController { + return topViewController(controller: presented) + } + return controller + } +} diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index e5b1256cf..9dc0a8818 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -6,50 +6,19 @@ // import Foundation -import Introspect +import SwiftUIIntrospect import SwiftUI public extension View { - func introspectCollectionView(customize: @escaping (UICollectionView) -> Void) -> some View { - return inject(UIKitIntrospectionView( - selector: { introspectionView in - guard let viewHost = Introspect.findViewHost(from: introspectionView) else { - return nil - } - return Introspect.previousSibling(containing: UICollectionView.self, from: viewHost) - }, - customize: customize - )) - } - func introspectCollectionViewWithClipping(customize: @escaping (UICollectionView) -> Void) -> some View { - return inject(UIKitIntrospectionView( - selector: { introspectionView in - guard let viewHost = Introspect.findViewHost(from: introspectionView) else { - return nil - } - // first run Introspect as normal - if let selectedView = Introspect.previousSibling(containing: UICollectionView.self, - from: viewHost) { - return selectedView - } else if let superView = viewHost.superview { - // if no view was found and a superview exists, search the superview as well - return Introspect.previousSibling(containing: UICollectionView.self, from: superView) - } else { - // no view found at all - return nil - } - }, - customize: customize - )) - } - - func cardStyle(top: CGFloat? = 0, - bottom: CGFloat? = 0, - leftLineEnabled: Bool = false, - bgColor: Color = CoreAssets.background.swiftUIColor, - strokeColor: Color = CoreAssets.cardViewStroke.swiftUIColor, - textColor: Color = CoreAssets.textPrimary.swiftUIColor) -> some View { + func cardStyle( + top: CGFloat? = 0, + bottom: CGFloat? = 0, + leftLineEnabled: Bool = false, + bgColor: Color = CoreAssets.background.swiftUIColor, + strokeColor: Color = CoreAssets.cardViewStroke.swiftUIColor, + textColor: Color = CoreAssets.textPrimary.swiftUIColor + ) -> some View { return self .padding(.all, 20) .padding(.vertical, leftLineEnabled ? 0 : 6) @@ -81,10 +50,12 @@ public extension View { .padding(.bottom, bottom) } - func shadowCardStyle(top: CGFloat? = 0, - bottom: CGFloat? = 0, - bgColor: Color = CoreAssets.cardViewBackground.swiftUIColor, - textColor: Color = CoreAssets.textPrimary.swiftUIColor) -> some View { + func shadowCardStyle( + top: CGFloat? = 0, + bottom: CGFloat? = 0, + bgColor: Color = CoreAssets.cardViewBackground.swiftUIColor, + textColor: Color = CoreAssets.textPrimary.swiftUIColor + ) -> some View { return self .padding(.all, 16) .padding(.vertical, 6) @@ -103,8 +74,11 @@ public extension View { } - func titleSettings(top: CGFloat? = 10, bottom: CGFloat? = 20, - color: Color = CoreAssets.textPrimary.swiftUIColor) -> some View { + func titleSettings( + top: CGFloat? = 10, + bottom: CGFloat? = 20, + color: Color = CoreAssets.textPrimary.swiftUIColor + ) -> some View { return self .lineLimit(1) .truncationMode(.tail) @@ -123,10 +97,12 @@ public extension View { } } - func roundedBackground(_ color: Color = CoreAssets.background.swiftUIColor, - strokeColor: Color = CoreAssets.backgroundStroke.swiftUIColor, - ipadMaxHeight: CGFloat = .infinity, - maxIpadWidth: CGFloat = 420) -> some View { + func roundedBackground( + _ color: Color = CoreAssets.background.swiftUIColor, + strokeColor: Color = CoreAssets.backgroundStroke.swiftUIColor, + ipadMaxHeight: CGFloat = .infinity, + maxIpadWidth: CGFloat = 420 + ) -> some View { var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } return ZStack { RoundedCorners(tl: 24, tr: 24) @@ -146,8 +122,12 @@ public extension View { if #available(iOS 16.0, *) { return self.navigationBarHidden(true) } else { - return self.introspectNavigationController { $0.isNavigationBarHidden = true } - .navigationBarHidden(true) + return self.introspect( + .navigationView(style: .stack), + on: .iOS(.v14, .v15, .v16, .v17), + scope: .ancestor) { + $0.isNavigationBarHidden = true + } } } diff --git a/Core/Core/Network/API.swift b/Core/Core/Network/API.swift index 19bd2edc0..d891c7af5 100644 --- a/Core/Core/Network/API.swift +++ b/Core/Core/Network/API.swift @@ -7,6 +7,7 @@ import Foundation import Alamofire +import WebKit public final class API { @@ -127,6 +128,12 @@ public final class API { let cookies = HTTPCookie.cookies(withResponseHeaderFields: fields, for: url) HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } HTTPCookieStorage.shared.setCookies(cookies, for: url, mainDocumentURL: nil) + DispatchQueue.main.async { + let cookies = HTTPCookieStorage.shared.cookies ?? [] + for c in cookies { + WKWebsiteDataStore.default().httpCookieStore.setCookie(c) + } + } } private func callResponse( diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 0392c13f5..7166f5b90 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -188,12 +188,14 @@ public class DownloadManager: DownloadManagerProtocol { let directoryURL = documentDirectoryURL.appendingPathComponent("Files", isDirectory: true) if FileManager.default.fileExists(atPath: directoryURL.path) { - print(directoryURL.path) return URL(fileURLWithPath: directoryURL.path) } else { do { - try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) - print(directoryURL.path) + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: nil + ) return URL(fileURLWithPath: directoryURL.path) } catch { print(error.localizedDescription) diff --git a/Core/Core/Network/HeadersRedirectHandler.swift b/Core/Core/Network/HeadersRedirectHandler.swift index ccb60e29e..3653392bd 100644 --- a/Core/Core/Network/HeadersRedirectHandler.swift +++ b/Core/Core/Network/HeadersRedirectHandler.swift @@ -17,16 +17,17 @@ public class HeadersRedirectHandler: RedirectHandler { _ task: URLSessionTask, willBeRedirectedTo request: URLRequest, for response: HTTPURLResponse, - completion: @escaping (URLRequest?) -> Void) { - var redirectedRequest = request - - if let originalRequest = task.originalRequest, - let headers = originalRequest.allHTTPHeaderFields { - for (key, value) in headers { - redirectedRequest.setValue(value, forHTTPHeaderField: key) - } + completion: @escaping (URLRequest?) -> Void + ) { + var redirectedRequest = request + + if let originalRequest = task.originalRequest, + let headers = originalRequest.allHTTPHeaderFields { + for (key, value) in headers { + redirectedRequest.setValue(value, forHTTPHeaderField: key) } - - completion(redirectedRequest) } + + completion(redirectedRequest) + } } diff --git a/Core/Core/Network/RequestInterceptor.swift b/Core/Core/Network/RequestInterceptor.swift index 0915d4f84..3f8e80b9a 100644 --- a/Core/Core/Network/RequestInterceptor.swift +++ b/Core/Core/Network/RequestInterceptor.swift @@ -27,10 +27,10 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { _ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { - // guard urlRequest.url?.absoluteString.hasPrefix("https://api.authenticated.com") == true else { - // /// If the request does not require authentication, we can directly return it as unmodified. - // return completion(.success(urlRequest)) - // } +// guard urlRequest.url?.absoluteString.hasPrefix("https://api.authenticated.com") == true else { +// // If the request does not require authentication, we can directly return it as unmodified. +// return completion(.success(urlRequest)) +// } var urlRequest = urlRequest // Set the Authorization header value using the access token. diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index 829d65a8f..887501306 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -38,6 +38,7 @@ public enum CoreAssets { public static let shadowColor = ColorAsset(name: "ShadowColor") public static let snackbarErrorColor = ColorAsset(name: "SnackbarErrorColor") public static let snackbarErrorTextColor = ColorAsset(name: "SnackbarErrorTextColor") + public static let snackbarInfoAlert = ColorAsset(name: "SnackbarInfoAlert") public static let styledButtonBackground = ColorAsset(name: "StyledButtonBackground") public static let styledButtonText = ColorAsset(name: "StyledButtonText") public static let textPrimary = ColorAsset(name: "TextPrimary") @@ -56,8 +57,10 @@ public enum CoreAssets { public static let allPosts = ImageAsset(name: "allPosts") public static let chapter = ImageAsset(name: "chapter") public static let discussion = ImageAsset(name: "discussion") + public static let discussionIcon = ImageAsset(name: "discussionIcon") public static let extra = ImageAsset(name: "extra") public static let filter = ImageAsset(name: "filter") + public static let finished = ImageAsset(name: "finished") public static let followed = ImageAsset(name: "followed") public static let pen = ImageAsset(name: "pen") public static let question = ImageAsset(name: "question") diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index 6790017a0..0197b0494 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -24,6 +24,36 @@ public enum CoreLocalization { /// Log out public static let logout = CoreLocalization.tr("Localizable", "ALERT.LOGOUT", fallback: "Log out") } + public enum Courseware { + /// Back to outline + public static let backToOutline = CoreLocalization.tr("Localizable", "COURSEWARE.BACK_TO_OUTLINE", fallback: "Back to outline") + /// Continue + public static let `continue` = CoreLocalization.tr("Localizable", "COURSEWARE.CONTINUE", fallback: "Continue") + /// Continue with: + public static let continueWith = CoreLocalization.tr("Localizable", "COURSEWARE.CONTINUE_WITH", fallback: "Continue with:") + /// Course content + public static let courseContent = CoreLocalization.tr("Localizable", "COURSEWARE.COURSE_CONTENT", fallback: "Course content") + /// Course units + public static let courseUnits = CoreLocalization.tr("Localizable", "COURSEWARE.COURSE_UNITS", fallback: "Course units") + /// Finish + public static let finish = CoreLocalization.tr("Localizable", "COURSEWARE.FINISH", fallback: "Finish") + /// Good Work! + public static let goodWork = CoreLocalization.tr("Localizable", "COURSEWARE.GOOD_WORK", fallback: "Good Work!") + /// “ is finished. + public static let isFinished = CoreLocalization.tr("Localizable", "COURSEWARE.IS_FINISHED", fallback: "“ is finished.") + /// Next + public static let next = CoreLocalization.tr("Localizable", "COURSEWARE.NEXT", fallback: "Next") + /// Next section + public static let nextSection = CoreLocalization.tr("Localizable", "COURSEWARE.NEXT_SECTION", fallback: "Next section") + /// To proceed with “ + public static let nextSectionDescriptionFirst = CoreLocalization.tr("Localizable", "COURSEWARE.NEXT_SECTION_DESCRIPTION_FIRST", fallback: "To proceed with “") + /// ” press “Next section”. + public static let nextSectionDescriptionLast = CoreLocalization.tr("Localizable", "COURSEWARE.NEXT_SECTION_DESCRIPTION_LAST", fallback: "” press “Next section”.") + /// Prev + public static let previous = CoreLocalization.tr("Localizable", "COURSEWARE.PREVIOUS", fallback: "Prev") + /// Section “ + public static let section = CoreLocalization.tr("Localizable", "COURSEWARE.SECTION", fallback: "Section “") + } public enum Date { /// Ended public static let ended = CoreLocalization.tr("Localizable", "DATE.ENDED", fallback: "Ended") @@ -53,6 +83,8 @@ public enum CoreLocalization { public static let invalidCredentials = CoreLocalization.tr("Localizable", "ERROR.INVALID_CREDENTIALS", fallback: "Invalid credentials") /// No cached data for offline mode public static let noCachedData = CoreLocalization.tr("Localizable", "ERROR.NO_CACHED_DATA", fallback: "No cached data for offline mode") + /// Reload + public static let reload = CoreLocalization.tr("Localizable", "ERROR.RELOAD", fallback: "Reload") /// Slow or no internet connection public static let slowOrNoInternetConnection = CoreLocalization.tr("Localizable", "ERROR.SLOW_OR_NO_INTERNET_CONNECTION", fallback: "Slow or no internet connection") /// Something went wrong @@ -97,6 +129,14 @@ public enum CoreLocalization { public static let tryAgainBtn = CoreLocalization.tr("Localizable", "VIEW.SNACKBAR.TRY_AGAIN_BTN", fallback: "Try Again") } } + public enum Webview { + public enum Alert { + /// Cancel + public static let cancel = CoreLocalization.tr("Localizable", "WEBVIEW.ALERT.CANCEL", fallback: "Cancel") + /// Ok + public static let ok = CoreLocalization.tr("Localizable", "WEBVIEW.ALERT.OK", fallback: "Ok") + } + } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/Core/Core/Theme.swift b/Core/Core/Theme.swift index 57bc2bcd7..56db63501 100644 --- a/Core/Core/Theme.swift +++ b/Core/Core/Theme.swift @@ -38,6 +38,7 @@ public struct Theme { public static let cardImageRadius = 10.0 public static let textInputShape = RoundedRectangle(cornerRadius: 8) public static let buttonShape = RoundedCorners(tl: 8, tr: 8, bl: 8, br: 8) + public static let unitButtonShape = RoundedCorners(tl: 21, tr: 21, bl: 21, br: 21) public static let roundedScreenBackgroundShape = RoundedCorners( tl: Theme.Shapes.screenBackgroundRadius, tr: Theme.Shapes.screenBackgroundRadius, @@ -65,6 +66,7 @@ public extension Theme.Fonts { guard let url = Bundle(for: __.self).url(forResource: "SF-Pro", withExtension: "ttf") else { return } CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil) } + // swiftlint:enable type_name } extension View { diff --git a/Core/Core/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index 7a51d0bf8..e4dd061a2 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -27,8 +27,10 @@ public struct AlertView: View { private var alertTitle: String private var alertMessage: String + private var nextSectionName: String? private var onCloseTapped: (() -> Void) = {} private var okTapped: (() -> Void) = {} + private var nextSectionTapped: (() -> Void) = {} private let type: AlertViewType public init( @@ -49,15 +51,19 @@ public struct AlertView: View { public init( alertTitle: String, alertMessage: String, + nextSectionName: String? = nil, mainAction: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, - okTapped: @escaping () -> Void + okTapped: @escaping () -> Void, + nextSectionTapped: @escaping () -> Void ) { self.alertTitle = alertTitle self.alertMessage = alertMessage self.onCloseTapped = onCloseTapped + self.nextSectionName = nextSectionName self.okTapped = okTapped + self.nextSectionTapped = nextSectionTapped type = .action(mainAction, image) } @@ -99,6 +105,7 @@ public struct AlertView: View { .font(Theme.Fonts.bodyMedium) .multilineTextAlignment(.center) .padding(.horizontal, 40) + .frame(maxWidth: 250) } HStack { switch type { @@ -109,8 +116,29 @@ public struct AlertView: View { .frame(maxWidth: 135) .saturation(0) case let .action(action, _): - StyledButton(action, action: { okTapped() }) - .frame(maxWidth: 160) + VStack(spacing: 20) { + if nextSectionName != nil { + UnitButtonView(type: .nextSection, action: { nextSectionTapped() }) + .frame(maxWidth: 215) + } + UnitButtonView(type: .custom(action), + bgColor: .clear, + action: { okTapped() }) + .frame(maxWidth: 215) + + if let nextSectionName { + Group { + Text(CoreLocalization.Courseware.nextSectionDescriptionFirst) + + Text(nextSectionName) + + Text(CoreLocalization.Courseware.nextSectionDescriptionLast) + }.frame(maxWidth: 215) + .padding(.horizontal, 40) + .multilineTextAlignment(.center) + .font(Theme.Fonts.labelSmall) + .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + } + + } case .logOut: Button(action: { okTapped() @@ -133,7 +161,12 @@ public struct AlertView: View { ) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) + .stroke(style: .init( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 1 + )) .foregroundColor(.clear) ) .frame(maxWidth: 215) @@ -157,7 +190,12 @@ public struct AlertView: View { ) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) + .stroke(style: .init( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 1 + )) .foregroundColor(.clear) ) .frame(maxWidth: 215) @@ -180,7 +218,12 @@ public struct AlertView: View { ) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) + .stroke(style: .init( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 1 + )) .foregroundColor(CoreAssets.textPrimary.swiftUIColor) ) .frame(maxWidth: 215) @@ -218,14 +261,17 @@ public struct AlertView: View { struct AlertView_Previews: PreviewProvider { static var previews: some View { AlertView( - alertTitle: "Warning!", + alertTitle: "Warning", alertMessage: "Something goes wrong. Do you want to exterminate your phone, right now", - positiveAction: "Accept", + nextSectionName: "Ahmad tea is a power", + mainAction: "Back to outline", + image: CoreAssets.goodWork.swiftUIImage, onCloseTapped: {}, okTapped: {}, - type: .logOut + nextSectionTapped: {} ) .previewLayout(.sizeThatFits) .background(Color.gray) } } +//swiftlint:enable all diff --git a/Core/Core/View/Base/CourseButton.swift b/Core/Core/View/Base/CourseButton.swift index d91b570d4..a7473e36e 100644 --- a/Core/Core/View/Base/CourseButton.swift +++ b/Core/Core/View/Base/CourseButton.swift @@ -26,7 +26,8 @@ public struct CourseButton: View { public var body: some View { HStack { if isCompleted { - Image(systemName: "checkmark.circle.fill") + CoreAssets.finished.swiftUIImage + .renderingMode(.template) .foregroundColor(.accentColor) } else { image @@ -58,6 +59,11 @@ public struct CourseButton: View { struct CourseButton_Previews: PreviewProvider { static var previews: some View { - CourseButton(isCompleted: true, image: CoreAssets.pen.swiftUIImage, displayName: "Lets see whats happen", index: 0) + CourseButton( + isCompleted: true, + image: CoreAssets.pen.swiftUIImage, + displayName: "Lets see whats happen", + index: 0 + ) } } diff --git a/Core/Core/View/Base/CourseCellView.swift b/Core/Core/View/Base/CourseCellView.swift index 1388d1489..cc5e83022 100644 --- a/Core/Core/View/Base/CourseCellView.swift +++ b/Core/Core/View/Base/CourseCellView.swift @@ -33,7 +33,7 @@ public struct CourseCellView: View { self.courseStart = model.courseStart?.dateToString(style: .startDDMonthYear) ?? "" self.courseEnd = model.courseEnd?.dateToString(style: .endedMonthDay) ?? "" self.courseOrg = model.org - self.index = Double(index)+1 + self.index = Double(index) + 1 self.cellsCount = cellsCount } @@ -144,3 +144,4 @@ struct CourseCellView_Previews: PreviewProvider { } } +// swiftlint:enable all diff --git a/Core/Core/View/Base/HTMLFormattedText.swift b/Core/Core/View/Base/HTMLFormattedText.swift index d29f9e8b0..6276b9f2d 100644 --- a/Core/Core/View/Base/HTMLFormattedText.swift +++ b/Core/Core/View/Base/HTMLFormattedText.swift @@ -70,11 +70,14 @@ public struct HTMLFormattedText: UIViewRepresentable { private func convertHTML(text: String) -> NSAttributedString? { guard let data = text.data(using: .utf8) else { return nil } - if let attributedString = try? NSAttributedString(data: data, - options: [ - .documentType: NSAttributedString.DocumentType.html, - .characterEncoding: String.Encoding.utf8.rawValue - ], documentAttributes: nil) { + if let attributedString = try? NSAttributedString( + data: data, + options: [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ], + documentAttributes: nil + ) { return attributedString } else { return nil diff --git a/Core/Core/View/Base/PickerMenu.swift b/Core/Core/View/Base/PickerMenu.swift index ab2377f89..ea0a78ce5 100644 --- a/Core/Core/View/Base/PickerMenu.swift +++ b/Core/Core/View/Base/PickerMenu.swift @@ -32,11 +32,13 @@ public struct PickerMenu: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } private var selected: ((PickerItem) -> Void) = { _ in } - public init(items: [PickerItem], - titleText: String, - router: BaseRouter, - selectedItem: PickerItem? = nil, - selected: @escaping (PickerItem) -> Void) { + public init( + items: [PickerItem], + titleText: String, + router: BaseRouter, + selectedItem: PickerItem? = nil, + selected: @escaping (PickerItem) -> Void + ) { self.items = items self.titleText = titleText self.router = router diff --git a/Core/Core/View/Base/StyledButton.swift b/Core/Core/View/Base/StyledButton.swift index 4e1a0b872..65d223447 100644 --- a/Core/Core/View/Base/StyledButton.swift +++ b/Core/Core/View/Base/StyledButton.swift @@ -43,7 +43,7 @@ public struct StyledButton: View { .frame(maxWidth: .infinity) .padding(.horizontal, 16) } - .frame(maxWidth: idiom == .pad ? 260: .infinity, minHeight: isTransparent ? 36 : 48) + .frame(maxWidth: idiom == .pad ? 260: .infinity, minHeight: isTransparent ? 36 : 42) .background( Theme.Shapes.buttonShape .fill(isTransparent ? .clear : buttonColor) diff --git a/Core/Core/View/Base/TextWithUrls.swift b/Core/Core/View/Base/TextWithUrls.swift index 70bc6934b..e516a22f8 100644 --- a/Core/Core/View/Base/TextWithUrls.swift +++ b/Core/Core/View/Base/TextWithUrls.swift @@ -62,12 +62,15 @@ public struct TextWithUrls: View { var text = Text("") attributedString.enumerateAttributes(in: stringRange, options: []) { attrs, range, _ in let valueOfString: String = attributedString.attributedSubstring(from: range).string - text = text + Text(.init((attrs[.underlineStyle] != nil ? getMarkupText(url: valueOfString): valueOfString))) + text = text + Text(.init((attrs[.underlineStyle] != nil + ? getMarkupText(url: valueOfString) + : valueOfString))) } return text } } +// swiftlint:enable shorthand_operator struct TextWithUrls_Previews: PreviewProvider { static var previews: some View { diff --git a/Core/Core/View/Base/UnitButtonView.swift b/Core/Core/View/Base/UnitButtonView.swift new file mode 100644 index 000000000..52b3900c9 --- /dev/null +++ b/Core/Core/View/Base/UnitButtonView.swift @@ -0,0 +1,209 @@ +// +// UnitButtonView.swift +// Course +// +// Created by  Stepanok Ivan on 14.02.2023. +// + +import SwiftUI + +public enum UnitButtonType: Equatable { + case first + case next + case nextBig + case previous + case last + case finish + case reload + case continueLesson + case nextSection + case custom(String) + + func stringValue() -> String { + switch self { + case .first: + return CoreLocalization.Courseware.next + case .next, .nextBig: + return CoreLocalization.Courseware.next + case .previous: + return CoreLocalization.Courseware.previous + case .last: + return CoreLocalization.Courseware.finish + case .finish: + return CoreLocalization.Courseware.finish + case .reload: + return CoreLocalization.Error.reload + case .continueLesson: + return CoreLocalization.Courseware.continue + case .nextSection: + return CoreLocalization.Courseware.nextSection + case let .custom(text): + return text + } + } +} + +public struct UnitButtonView: View { + + private let action: () -> Void + private let type: UnitButtonType + private let bgColor: Color? + + public init(type: UnitButtonType, bgColor: Color? = nil, action: @escaping () -> Void) { + self.type = type + self.bgColor = bgColor + self.action = action + } + + public var body: some View { + HStack { + Button(action: action) { + VStack { + switch type { + case .first: + HStack { + Text(type.stringValue()) + .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .font(Theme.Fonts.labelLarge) + CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) + .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .rotationEffect(Angle.degrees(-90)) + }.padding(.horizontal, 16) + case .next, .nextBig: + HStack { + Text(type.stringValue()) + .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .padding(.leading, 20) + .font(Theme.Fonts.labelLarge) + if type != .nextBig { + Spacer() + } + CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) + .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .rotationEffect(Angle.degrees(-90)) + .padding(.trailing, 20) + } + case .previous: + HStack { + Text(type.stringValue()) + .foregroundColor(CoreAssets.accentColor.swiftUIColor) + .font(Theme.Fonts.labelLarge) + .padding(.leading, 20) + CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) + .rotationEffect(Angle.degrees(90)) + .padding(.trailing, 20) + .foregroundColor(CoreAssets.accentColor.swiftUIColor) + + } + case .last: + HStack { + Text(type.stringValue()) + .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .padding(.leading, 16) + .font(Theme.Fonts.labelLarge) + Spacer() + CoreAssets.check.swiftUIImage.renderingMode(.template) + .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .padding(.trailing, 16) + } + case .finish: + HStack { + Text(type.stringValue()) + .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .font(Theme.Fonts.labelLarge) + CoreAssets.check.swiftUIImage.renderingMode(.template) + .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + }.padding(.horizontal, 16) + case .reload, .custom: + VStack(alignment: .center) { + Text(type.stringValue()) + .foregroundColor(bgColor == nil ? .white : CoreAssets.accentColor.swiftUIColor) + .font(Theme.Fonts.labelLarge) + }.padding(.horizontal, 16) + case .continueLesson, .nextSection: + HStack { + Text(type.stringValue()) + .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .padding(.leading, 20) + .font(Theme.Fonts.labelLarge) + CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) + .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) + .rotationEffect(Angle.degrees(180)) + .padding(.trailing, 20) + } + } + } + .frame(maxWidth: .infinity, minHeight: 42) + .background( + VStack { + switch self.type { + case .first, .next, .nextBig, .previous, .last: + Theme.Shapes.buttonShape + .fill(type == .previous + ? CoreAssets.background.swiftUIColor + : CoreAssets.accentColor.swiftUIColor) + .shadow(color: Color.black.opacity(0.25), radius: 21, y: 4) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(style: .init( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 1) + ) + .foregroundColor(CoreAssets.accentColor.swiftUIColor) + ) + + case .continueLesson, .nextSection, .reload, .finish, .custom: + Theme.Shapes.buttonShape + .fill(bgColor ?? CoreAssets.accentColor.swiftUIColor) + + .shadow(color: (type == .first + || type == .next + || type == .previous + || type == .last + || type == .finish + || type == .reload) ? Color.black.opacity(0.25) : .clear, + radius: 21, y: 4) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(style: .init( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 1 + )) + .foregroundColor(CoreAssets.accentColor.swiftUIColor) + ) + } + } + ) + + } + .fixedSize(horizontal: (type == .first + || type == .next + || type == .previous + || type == .last + || type == .finish + || type == .reload) + , vertical: false) + } + + } +} + +struct UnitButtonView_Previews: PreviewProvider { + static var previews: some View { + VStack { + UnitButtonView(type: .first, action: {}) + UnitButtonView(type: .previous, action: {}) + UnitButtonView(type: .next, action: {}) + UnitButtonView(type: .last, action: {}) + UnitButtonView(type: .finish, action: {}) + UnitButtonView(type: .reload, action: {}) + UnitButtonView(type: .custom("Custom text"), action: {}) + UnitButtonView(type: .continueLesson, action: {}) + UnitButtonView(type: .nextSection, action: {}) + }.padding() + } +} diff --git a/Core/Core/View/Base/WebBrowser.swift b/Core/Core/View/Base/WebBrowser.swift index 04ae19b99..6d89e4528 100644 --- a/Core/Core/View/Base/WebBrowser.swift +++ b/Core/Core/View/Base/WebBrowser.swift @@ -6,6 +6,7 @@ // import SwiftUI +import WebKit public struct WebBrowser: View { @@ -24,17 +25,23 @@ public struct WebBrowser: View { // MARK: - Page name VStack(alignment: .center) { - NavigationBar(title: pageTitle, - leftButtonAction: { presentationMode.wrappedValue.dismiss() }) + NavigationBar( + title: pageTitle, + leftButtonAction: { presentationMode.wrappedValue.dismiss() } + ) // MARK: - Page Body VStack { ZStack(alignment: .top) { NavigationView { - WebView(viewModel: .init(url: url), isLoading: $isShowProgress, refreshCookies: {}) - .navigationBarTitle(Text("")) // Needed for hide navBar on ios 14, 15 - .navigationBarHidden(true) - .ignoresSafeArea() + WebView( + viewModel: .init(url: url, baseURL: ""), + isLoading: $isShowProgress, + refreshCookies: {} + ) + .navigationBarTitle(Text("")) // Needed for hide navBar on ios 14, 15 + .navigationBarHidden(true) + .ignoresSafeArea() } } } diff --git a/Core/Core/View/Base/WebUnitView.swift b/Core/Core/View/Base/WebUnitView.swift index 12f6e162b..e1108b3dc 100644 --- a/Core/Core/View/Base/WebUnitView.swift +++ b/Core/Core/View/Base/WebUnitView.swift @@ -6,7 +6,7 @@ // import SwiftUI -import Introspect +import SwiftUIIntrospect public struct WebUnitView: View { @@ -17,64 +17,64 @@ public struct WebUnitView: View { public init(url: String, viewModel: WebUnitViewModel) { self.viewModel = viewModel self.url = url - Task { - await viewModel.updateCookies() - } } @ViewBuilder public var body: some View { - ZStack(alignment: .center) { - GeometryReader { reader in - ScrollView { - if viewModel.cookiesReady { - WebView(viewModel: .init(url: url), isLoading: $isWebViewLoading, refreshCookies: { - await viewModel.updateCookies(force: true) - }) - .introspectScrollView(customize: { scrollView in - scrollView.isScrollEnabled = false - scrollView.alwaysBounceVertical = false - scrollView.alwaysBounceHorizontal = false - scrollView.bounces = false - }) - .frame(width: reader.size.width, height: reader.size.height) + // MARK: - Error Alert + if viewModel.showError { + VStack(spacing: 28) { + Image(systemName: "nosign") + .resizable() + .scaledToFit() + .frame(width: 64) + .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + Text(viewModel.errorMessage ?? "") + .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + Button(action: { + Task { + await viewModel.updateCookies(force: true) } - } - if viewModel.updatingCookies || isWebViewLoading { - VStack { - ProgressBar(size: 40, lineWidth: 8) + }, label: { + Text(CoreLocalization.View.Snackbar.tryAgainBtn) + .frame(maxWidth: .infinity, minHeight: 48) + .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) + ) + }) + .frame(width: 100) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ZStack(alignment: .center) { + GeometryReader { reader in + ScrollView { + if viewModel.cookiesReady { + WebView( + viewModel: .init(url: url, baseURL: viewModel.config.baseURL.absoluteString), + isLoading: $isWebViewLoading, refreshCookies: { + await viewModel.updateCookies(force: true) + }) + .introspect(.scrollView, on: .iOS(.v14, .v15, .v16, .v17), customize: { scrollView in + scrollView.isScrollEnabled = false + }) + .frame(width: reader.size.width, height: reader.size.height) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - // MARK: - Error Alert - if viewModel.showError { - VStack(spacing: 28) { - Image(systemName: "nosign") - .resizable() - .scaledToFit() - .frame(width: 64) - .foregroundColor(.black) - Text(viewModel.errorMessage ?? "") - .foregroundColor(.black) - .multilineTextAlignment(.center) - .padding(.horizontal, 20) - Button(action: { - Task { - await viewModel.updateCookies(force: true) + if viewModel.updatingCookies || isWebViewLoading { + VStack { + ProgressBar(size: 40, lineWidth: 8) } - }, label: { - Text(CoreLocalization.View.Snackbar.tryAgainBtn) - .frame(maxWidth: .infinity, minHeight: 48) - .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) - ) - }) - .frame(width: 100) - }.frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + }.onFirstAppear { + Task { + await viewModel.updateCookies() + } } } } diff --git a/Core/Core/View/Base/WebUnitViewModel.swift b/Core/Core/View/Base/WebUnitViewModel.swift index 9b06a1daa..caa010a93 100644 --- a/Core/Core/View/Base/WebUnitViewModel.swift +++ b/Core/Core/View/Base/WebUnitViewModel.swift @@ -9,7 +9,10 @@ import Foundation import SwiftUI public class WebUnitViewModel: ObservableObject { + let authInteractor: AuthInteractorProtocol + let config: Config + @Published var updatingCookies: Bool = false @Published var cookiesReady: Bool = false @Published var showError: Bool = false @@ -23,17 +26,20 @@ public class WebUnitViewModel: ObservableObject { } } - public init(authInteractor: AuthInteractorProtocol) { + public init(authInteractor: AuthInteractorProtocol, config: Config) { self.authInteractor = authInteractor + self.config = config } @MainActor func updateCookies(force: Bool = false) async { + guard !updatingCookies else { return } do { updatingCookies = true try await authInteractor.getCookies(force: force) cookiesReady = true updatingCookies = false + errorMessage = nil } catch { if error.isInternetError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection diff --git a/Core/Core/View/Base/WebView.swift b/Core/Core/View/Base/WebView.swift index 14fb8da4e..463a9a315 100644 --- a/Core/Core/View/Base/WebView.swift +++ b/Core/Core/View/Base/WebView.swift @@ -12,10 +12,13 @@ import SwiftUI public struct WebView: UIViewRepresentable { public class ViewModel: ObservableObject { + @Published var url: String + let baseURL: String - public init(url: String) { + public init(url: String, baseURL: String) { self.url = url + self.baseURL = baseURL } } @@ -29,7 +32,7 @@ public struct WebView: UIViewRepresentable { self.refreshCookies = refreshCookies } - public class Coordinator: NSObject, WKNavigationDelegate { + public class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { var parent: WebView init(_ parent: WebView) { @@ -42,12 +45,62 @@ public struct WebView: UIViewRepresentable { } } - public func webView(_ webView: WKWebView, - decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { - guard let statusCode = (navigationResponse.response as? HTTPURLResponse)?.statusCode else { + public func webView( + _ webView: WKWebView, + runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (Bool) -> Void + ) { + + let alertController = UIAlertController(title: nil, message: message, preferredStyle: .actionSheet) + + alertController.addAction(UIAlertAction( + title: CoreLocalization.Webview.Alert.ok, + style: .default, + handler: { _ in + completionHandler(true) + })) + + alertController.addAction(UIAlertAction( + title: CoreLocalization.Webview.Alert.cancel, + style: .cancel, + handler: { _ in + completionHandler(false) + })) + + UIApplication.topViewController()?.present(alertController, animated: true, completion: nil) + } + + public func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction + ) async -> WKNavigationActionPolicy { + guard let url = navigationAction.request.url else { return .cancel } - if (401...404).contains(statusCode) { + + let baseURL = await parent.viewModel.baseURL + if !baseURL.isEmpty, !url.absoluteString.starts(with: baseURL) { + await MainActor.run { + UIApplication.shared.open(url, options: [:]) + } + return .cancel + } + + return .allow + } + + public func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse + ) async -> WKNavigationResponsePolicy { + guard let response = (navigationResponse.response as? HTTPURLResponse), + let url = response.url else { + return .cancel + } + let baseURL = await parent.viewModel.baseURL + + if (401...404).contains(response.statusCode) || url.absoluteString.hasPrefix(baseURL + "/login") { await parent.refreshCookies() DispatchQueue.main.async { if let url = webView.url { @@ -65,34 +118,35 @@ public struct WebView: UIViewRepresentable { } public func makeUIView(context: UIViewRepresentableContext) -> WKWebView { - let webview = WKWebView() - webview.navigationDelegate = context.coordinator + let webViewConfig = WKWebViewConfiguration() + + let webView = WKWebView(frame: .zero, configuration: webViewConfig) + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator - webview.scrollView.bounces = false - webview.scrollView.alwaysBounceHorizontal = false - webview.scrollView.showsHorizontalScrollIndicator = false - webview.scrollView.isScrollEnabled = true - webview.configuration.suppressesIncrementalRendering = true - webview.isOpaque = false - webview.backgroundColor = .clear - webview.scrollView.backgroundColor = UIColor.clear - webview.scrollView.alwaysBounceVertical = false + webView.scrollView.bounces = false + webView.scrollView.alwaysBounceHorizontal = false + webView.scrollView.showsHorizontalScrollIndicator = false + webView.scrollView.isScrollEnabled = true + webView.configuration.suppressesIncrementalRendering = true + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .white + webView.scrollView.alwaysBounceVertical = false + webView.scrollView.layer.cornerRadius = 24 + webView.scrollView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 200, right: 0) - return webview + return webView } public func updateUIView(_ webview: WKWebView, context: UIViewRepresentableContext) { if let url = URL(string: viewModel.url) { - let cookies = HTTPCookieStorage.shared.cookies ?? [] - for (cookie) in cookies { - webview.configuration.websiteDataStore.httpCookieStore - .setCookie(cookie) - } - let request = URLRequest(url: url) if webview.url?.absoluteString != url.absoluteString { DispatchQueue.main.async { isLoading = true } + let request = URLRequest(url: url) webview.load(request) } } diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 6fda66932..f16f781dc 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -21,6 +21,24 @@ "ERROR.UNKNOWN_ERROR" = "Something went wrong"; "ERROR.WIFI" = "You can only download files over Wi-Fi. You can change this in the settings."; +"COURSEWARE.COURSE_CONTENT" = "Course content"; +"COURSEWARE.COURSE_UNITS" = "Course units"; +"COURSEWARE.NEXT" = "Next"; +"COURSEWARE.PREVIOUS" = "Prev"; +"COURSEWARE.FINISH" = "Finish"; +"COURSEWARE.GOOD_WORK" = "Good Work!"; +"COURSEWARE.BACK_TO_OUTLINE" = "Back to outline"; +"COURSEWARE.SECTION" = "Section “"; +"COURSEWARE.IS_FINISHED" = "“ is finished."; +"COURSEWARE.CONTINUE" = "Continue"; +"COURSEWARE.CONTINUE_WITH" = "Continue with:"; +"COURSEWARE.NEXT_SECTION" = "Next section"; + +"COURSEWARE.NEXT_SECTION_DESCRIPTION_FIRST" = "To proceed with “"; +"COURSEWARE.NEXT_SECTION_DESCRIPTION_LAST" = "” press “Next section”."; + +"ERROR.RELOAD" = "Reload"; + "DATE.ENDED" = "Ended"; "DATE.START" = "Start"; "DATE.STARTED" = "Started"; @@ -47,3 +65,6 @@ "PICKER.SEARCH" = "Search"; "PICKER.ACCEPT" = "Accept"; + +"WEBVIEW.ALERT.OK" = "Ok"; +"WEBVIEW.ALERT.CANCEL" = "Cancel"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index 939524031..e06937311 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -21,6 +21,24 @@ "ERROR.UNKNOWN_ERROR" = "Щось пішло не так"; "ERROR.WIFI" = "Завантажувати файли можна лише через Wi-Fi. Ви можете змінити це в налаштуваннях."; +"COURSEWARE.COURSE_CONTENT" = "Зміст курсу"; +"COURSEWARE.COURSE_UNITS" = "Модулі"; +"COURSEWARE.NEXT" = "Далі"; +"COURSEWARE.PREVIOUS" = "Назад"; +"COURSEWARE.FINISH" = "Завершити"; +"COURSEWARE.GOOD_WORK" = "Гарна робота!"; +"COURSEWARE.BACK_TO_OUTLINE" = "Повернутись до модуля"; +"COURSEWARE.SECTION" = "Секція “"; +"COURSEWARE.IS_FINISHED" = "“ завершена."; +"COURSEWARE.CONTINUE" = "Продовжити"; +"COURSEWARE.CONTINUE_WITH" = "Продовжити далі:"; +"COURSEWARE.NEXT_SECTION" = "Наступний розділ"; + +"COURSEWARE.NEXT_SECTION_DESCRIPTION_FIRST" = "Щоб перейти до “"; +"COURSEWARE.NEXT_SECTION_DESCRIPTION_LAST" = "” натисніть “Наступний розділ”."; + +"ERROR.RELOAD" = "Перезавантажити"; + "DATE.ENDED" = "Кінець"; "DATE.START" = "Початок"; "DATE.STARTED" = "Почався"; @@ -30,6 +48,7 @@ "ALERT.CANCEL" = "СКАСУВАТИ"; "ALERT.LOGOUT" = "Вийти"; "ALERT.LEAVE" = "Покинути"; +"ALERT.KEEP_EDITING" = "Залишитись"; "NO_INTERNET.OFFLINE" = "Офлайн режим"; "NO_INTERNET.DISMISS" = "Сховати"; @@ -46,3 +65,6 @@ "PICKER.SEARCH" = "Знайти"; "PICKER.ACCEPT" = "Прийняти"; + +"WEBVIEW.ALERT.OK" = "Так"; +"WEBVIEW.ALERT.CANCEL" = "Скасувати"; diff --git a/Course/Course.xcodeproj.xcworkspace/contents.xcworkspacedata b/Course/Course.xcodeproj.xcworkspace/contents.xcworkspacedata index b74ad64b7..6e6981f01 100644 --- a/Course/Course.xcodeproj.xcworkspace/contents.xcworkspacedata +++ b/Course/Course.xcodeproj.xcworkspace/contents.xcworkspacedata @@ -17,7 +17,7 @@ location = "group:../Discovery/Discovery.xcodeproj"> + location = "group:../OpenEdX.xcodeproj"> diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 7c1c46947..9baaaedb5 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -16,15 +16,22 @@ 022C64E029ADEA9B000F532B /* Data_UpdatesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64DF29ADEA9B000F532B /* Data_UpdatesResponse.swift */; }; 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64E129ADEB83000F532B /* CourseUpdate.swift */; }; 022EA8CB297AD63B0014A8F7 /* CourseContainerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022EA8CA297AD63B0014A8F7 /* CourseContainerViewModelTests.swift */; }; + 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022F8E152A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift */; }; + 022F8E182A1E2642008EFAB9 /* EncodedVideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022F8E172A1E2642008EFAB9 /* EncodedVideoPlayerViewModel.swift */; }; 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0231124C28EDA804002588FB /* CourseUnitView.swift */; }; 0231124F28EDA811002588FB /* CourseUnitViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0231124E28EDA811002588FB /* CourseUnitViewModel.swift */; }; 023812E7297AC8EB0087098F /* CourseDetailsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023812E6297AC8EB0087098F /* CourseDetailsViewModelTests.swift */; }; 023812E8297AC8EB0087098F /* Course.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0289F8EE28E1C3510064F8F3 /* Course.framework */; platformFilter = ios; }; 023812F3297AC9ED0087098F /* CourseMock.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023812F2297AC9EC0087098F /* CourseMock.generated.swift */; }; - 0248C92529C0901200DC8402 /* CourseBlocksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0248C92429C0901200DC8402 /* CourseBlocksViewModel.swift */; }; + 02454CA02A2618E70043052A /* YouTubeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454C9F2A2618E70043052A /* YouTubeView.swift */; }; + 02454CA22A26190A0043052A /* EncodedVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454CA12A26190A0043052A /* EncodedVideoView.swift */; }; + 02454CA42A26193F0043052A /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454CA32A26193F0043052A /* WebView.swift */; }; + 02454CA62A26196C0043052A /* UnknownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454CA52A26196C0043052A /* UnknownView.swift */; }; + 02454CA82A2619890043052A /* DiscussionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454CA72A2619890043052A /* DiscussionView.swift */; }; + 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454CA92A2619B40043052A /* LessonProgressView.swift */; }; 0248C92729C097EB00DC8402 /* CourseVerticalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0248C92629C097EB00DC8402 /* CourseVerticalViewModel.swift */; }; - 02512FEE298EAD770024D438 /* CourseBlocksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02512FED298EAD770024D438 /* CourseBlocksView.swift */; }; 0262148F29AE17C4008BD75A /* HandoutsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0262148E29AE17C4008BD75A /* HandoutsViewModelTests.swift */; }; + 02635AC72A24F181008062F2 /* ContinueWithView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02635AC62A24F181008062F2 /* ContinueWithView.swift */; }; 0265B4B728E2141D00E6EAFD /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0265B4B628E2141D00E6EAFD /* Strings.swift */; }; 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027020FB28E7362100F54332 /* Data_CourseOutlineResponse.swift */; }; 0270210328E736E700F54332 /* CourseOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0270210128E736E700F54332 /* CourseOutlineView.swift */; }; @@ -32,7 +39,6 @@ 0276D75D29DDA3F80004CDF8 /* ResumeBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276D75C29DDA3F80004CDF8 /* ResumeBlock.swift */; }; 0289F90228E1C3E10064F8F3 /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 0289F90128E1C3E00064F8F3 /* swiftgen.yml */; }; 0295B1D9297E6DF8003B0C65 /* CourseUnitViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295B1D8297E6DF8003B0C65 /* CourseUnitViewModelTests.swift */; }; - 0295C887299BBDE300ABE571 /* UnitButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C886299BBDE300ABE571 /* UnitButtonView.swift */; }; 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C888299BBE8200ABE571 /* CourseNavigationView.swift */; }; 02A8076829474831007F53AB /* CourseVerticalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A8076729474831007F53AB /* CourseVerticalView.swift */; }; 02B6B3B228E1C49400232911 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 02B6B3B428E1C49400232911 /* Localizable.strings */; }; @@ -47,6 +53,7 @@ 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0144E28F46474002E513D /* CourseContainerView.swift */; }; 02F0145728F4A2FF002E513D /* CourseContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */; }; 02F066E829DC71750073E13B /* SubtittlesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F066E729DC71750073E13B /* SubtittlesView.swift */; }; + 02F175372A4DAFD20019CD70 /* CourseAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175362A4DAFD20019CD70 /* CourseAnalytics.swift */; }; 02F3BFDD29252E900051930C /* CourseRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFDC29252E900051930C /* CourseRouter.swift */; }; 02F78AEB29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F78AEA29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift */; }; 02F98A8128F8224200DE94C0 /* Discussion.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F98A8028F8224200DE94C0 /* Discussion.framework */; }; @@ -79,15 +86,22 @@ 022C64DF29ADEA9B000F532B /* Data_UpdatesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_UpdatesResponse.swift; sourceTree = ""; }; 022C64E129ADEB83000F532B /* CourseUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseUpdate.swift; sourceTree = ""; }; 022EA8CA297AD63B0014A8F7 /* CourseContainerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerViewModelTests.swift; sourceTree = ""; }; + 022F8E152A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoPlayerViewModel.swift; sourceTree = ""; }; + 022F8E172A1E2642008EFAB9 /* EncodedVideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncodedVideoPlayerViewModel.swift; sourceTree = ""; }; 0231124C28EDA804002588FB /* CourseUnitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseUnitView.swift; sourceTree = ""; }; 0231124E28EDA811002588FB /* CourseUnitViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseUnitViewModel.swift; sourceTree = ""; }; 023812E4297AC8EA0087098F /* CourseTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CourseTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 023812E6297AC8EB0087098F /* CourseDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDetailsViewModelTests.swift; sourceTree = ""; }; 023812F2297AC9EC0087098F /* CourseMock.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseMock.generated.swift; sourceTree = ""; }; - 0248C92429C0901200DC8402 /* CourseBlocksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseBlocksViewModel.swift; sourceTree = ""; }; + 02454C9F2A2618E70043052A /* YouTubeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeView.swift; sourceTree = ""; }; + 02454CA12A26190A0043052A /* EncodedVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncodedVideoView.swift; sourceTree = ""; }; + 02454CA32A26193F0043052A /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; + 02454CA52A26196C0043052A /* UnknownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnknownView.swift; sourceTree = ""; }; + 02454CA72A2619890043052A /* DiscussionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionView.swift; sourceTree = ""; }; + 02454CA92A2619B40043052A /* LessonProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LessonProgressView.swift; sourceTree = ""; }; 0248C92629C097EB00DC8402 /* CourseVerticalViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseVerticalViewModel.swift; sourceTree = ""; }; - 02512FED298EAD770024D438 /* CourseBlocksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseBlocksView.swift; sourceTree = ""; }; 0262148E29AE17C4008BD75A /* HandoutsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandoutsViewModelTests.swift; sourceTree = ""; }; + 02635AC62A24F181008062F2 /* ContinueWithView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWithView.swift; sourceTree = ""; }; 0265B4B628E2141D00E6EAFD /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; 027020FB28E7362100F54332 /* Data_CourseOutlineResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseOutlineResponse.swift; sourceTree = ""; }; 0270210128E736E700F54332 /* CourseOutlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseOutlineView.swift; sourceTree = ""; }; @@ -96,7 +110,6 @@ 0289F8EE28E1C3510064F8F3 /* Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0289F90128E1C3E00064F8F3 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; 0295B1D8297E6DF8003B0C65 /* CourseUnitViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseUnitViewModelTests.swift; sourceTree = ""; }; - 0295C886299BBDE300ABE571 /* UnitButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitButtonView.swift; sourceTree = ""; }; 0295C888299BBE8200ABE571 /* CourseNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseNavigationView.swift; sourceTree = ""; }; 02A8076729474831007F53AB /* CourseVerticalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseVerticalView.swift; sourceTree = ""; }; 02B6B3B328E1C49400232911 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; @@ -112,6 +125,7 @@ 02F0144E28F46474002E513D /* CourseContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerView.swift; sourceTree = ""; }; 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerViewModel.swift; sourceTree = ""; }; 02F066E729DC71750073E13B /* SubtittlesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtittlesView.swift; sourceTree = ""; }; + 02F175362A4DAFD20019CD70 /* CourseAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseAnalytics.swift; sourceTree = ""; }; 02F3BFDC29252E900051930C /* CourseRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRouter.swift; sourceTree = ""; }; 02F78AEA29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = VideoPlayerViewModelTests.swift; path = CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift; sourceTree = SOURCE_ROOT; }; 02F98A8028F8224200DE94C0 /* Discussion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discussion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -187,6 +201,19 @@ path = CourseTests; sourceTree = ""; }; + 02454C9E2A2618D40043052A /* Subviews */ = { + isa = PBXGroup; + children = ( + 02454C9F2A2618E70043052A /* YouTubeView.swift */, + 02454CA12A26190A0043052A /* EncodedVideoView.swift */, + 02454CA32A26193F0043052A /* WebView.swift */, + 02454CA52A26196C0043052A /* UnknownView.swift */, + 02454CA72A2619890043052A /* DiscussionView.swift */, + 02454CA92A2619B40043052A /* LessonProgressView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; 0289F8E428E1C3510064F8F3 = { isa = PBXGroup; children = ( @@ -299,6 +326,7 @@ 070019A928F6F59D00D5FC78 /* Unit */, 070019AA28F6F79E00D5FC78 /* Video */, 02F3BFDC29252E900051930C /* CourseRouter.swift */, + 02F175362A4DAFD20019CD70 /* CourseAnalytics.swift */, ); path = Presentation; sourceTree = ""; @@ -325,11 +353,10 @@ 070019A728F6F2D600D5FC78 /* Outline */ = { isa = PBXGroup; children = ( + 02635AC62A24F181008062F2 /* ContinueWithView.swift */, 0270210128E736E700F54332 /* CourseOutlineView.swift */, 02A8076729474831007F53AB /* CourseVerticalView.swift */, 0248C92629C097EB00DC8402 /* CourseVerticalViewModel.swift */, - 02512FED298EAD770024D438 /* CourseBlocksView.swift */, - 0248C92429C0901200DC8402 /* CourseBlocksViewModel.swift */, ); path = Outline; sourceTree = ""; @@ -347,10 +374,10 @@ 070019A928F6F59D00D5FC78 /* Unit */ = { isa = PBXGroup; children = ( + 02454C9E2A2618D40043052A /* Subviews */, 0231124C28EDA804002588FB /* CourseUnitView.swift */, 0231124E28EDA811002588FB /* CourseUnitViewModel.swift */, 0295C888299BBE8200ABE571 /* CourseNavigationView.swift */, - 0295C886299BBDE300ABE571 /* UnitButtonView.swift */, ); path = Unit; sourceTree = ""; @@ -360,7 +387,9 @@ children = ( 02F066E729DC71750073E13B /* SubtittlesView.swift */, 0766DFCB299AA7A600EBEF6A /* YouTubeVideoPlayer.swift */, + 022F8E152A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift */, 0766DFCD299AB26D00EBEF6A /* EncodedVideoPlayer.swift */, + 022F8E172A1E2642008EFAB9 /* EncodedVideoPlayerViewModel.swift */, 0766DFCF299AB29000EBEF6A /* PlayerViewController.swift */, 02FFAD0C29E4347300140E46 /* VideoPlayerViewModel.swift */, ); @@ -645,40 +674,47 @@ buildActionMask = 2147483647; files = ( 02FFAD0D29E4347300140E46 /* VideoPlayerViewModel.swift in Sources */, + 02454CA42A26193F0043052A /* WebView.swift in Sources */, 022C64DA29ACEC50000F532B /* HandoutsViewModel.swift in Sources */, + 02635AC72A24F181008062F2 /* ContinueWithView.swift in Sources */, 022C64DE29AD167A000F532B /* HandoutsUpdatesDetailView.swift in Sources */, 0270210328E736E700F54332 /* CourseOutlineView.swift in Sources */, 022C64E029ADEA9B000F532B /* Data_UpdatesResponse.swift in Sources */, + 02454CA02A2618E70043052A /* YouTubeView.swift in Sources */, + 02454CA22A26190A0043052A /* EncodedVideoView.swift in Sources */, 02B6B3BC28E1D14F00232911 /* CourseRepository.swift in Sources */, 02280F60294B50030032823A /* CoursePersistence.swift in Sources */, + 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */, 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */, 0766DFCE299AB26D00EBEF6A /* EncodedVideoPlayer.swift in Sources */, 0276D75B29DDA3890004CDF8 /* Data_ResumeBlock.swift in Sources */, 0276D75D29DDA3F80004CDF8 /* ResumeBlock.swift in Sources */, 02F3BFDD29252E900051930C /* CourseRouter.swift in Sources */, + 022F8E182A1E2642008EFAB9 /* EncodedVideoPlayerViewModel.swift in Sources */, 0248C92729C097EB00DC8402 /* CourseVerticalViewModel.swift in Sources */, - 0295C887299BBDE300ABE571 /* UnitButtonView.swift in Sources */, 02F0145728F4A2FF002E513D /* CourseContainerViewModel.swift in Sources */, 02B6B3B728E1D11E00232911 /* CourseInteractor.swift in Sources */, 073512E229C0E400005CFA41 /* BaseCourseViewModel.swift in Sources */, - 02512FEE298EAD770024D438 /* CourseBlocksView.swift in Sources */, 0231124F28EDA811002588FB /* CourseUnitViewModel.swift in Sources */, 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */, 02A8076829474831007F53AB /* CourseVerticalView.swift in Sources */, - 0248C92529C0901200DC8402 /* CourseBlocksViewModel.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, 02E685C028E4B629000AE015 /* CourseDetailsViewModel.swift in Sources */, 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */, 02F066E829DC71750073E13B /* SubtittlesView.swift in Sources */, 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */, + 02454CA62A26196C0043052A /* UnknownView.swift in Sources */, 0766DFD0299AB29000EBEF6A /* PlayerViewController.swift in Sources */, 022C64DC29ACFDEE000F532B /* Data_HandoutsResponse.swift in Sources */, 022C64D829ACEC48000F532B /* HandoutsView.swift in Sources */, + 02454CA82A2619890043052A /* DiscussionView.swift in Sources */, 0265B4B728E2141D00E6EAFD /* Strings.swift in Sources */, 02B6B3C128E1DBA100232911 /* Data_CourseDetailsResponse.swift in Sources */, 0766DFCC299AA7A600EBEF6A /* YouTubeVideoPlayer.swift in Sources */, + 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02E685BE28E4B60A000AE015 /* CourseDetailsView.swift in Sources */, + 02F175372A4DAFD20019CD70 /* CourseAnalytics.swift in Sources */, 02B6B3BE28E1D15C00232911 /* CourseDetailsEndpoint.swift in Sources */, 02B6B3C328E1DCD100232911 /* CourseDetails.swift in Sources */, ); @@ -714,12 +750,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.CourseTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -735,12 +771,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.CourseTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -756,12 +792,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.CourseTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -777,12 +813,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.CourseTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -798,12 +834,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.CourseTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -819,12 +855,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.CourseTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -978,7 +1014,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CourseDetails; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseDetails; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1013,7 +1049,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CourseDetails; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseDetails; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1111,7 +1147,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CourseDetails; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseDetails; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1210,7 +1246,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CourseDetails; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseDetails; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1303,7 +1339,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CourseDetails; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseDetails; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1395,7 +1431,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CourseDetails; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseDetails; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1493,7 +1529,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CourseDetails; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseDetails; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1512,12 +1548,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.CourseTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1607,7 +1643,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.CourseDetails; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseDetails; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1625,12 +1661,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.CourseTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index e7cbcd9e7..55f97a869 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -18,7 +18,7 @@ public protocol CourseRepositoryProtocol { func getHandouts(courseID: String) async throws -> String? func getUpdates(courseID: String) async throws -> [CourseUpdate] func resumeBlock(courseID: String) async throws -> ResumeBlock - func getSubtitles(url: String) async throws -> String + func getSubtitles(url: String, selectedLanguage: String) async throws -> String } public class CourseRepository: CourseRepositoryProtocol { @@ -98,13 +98,16 @@ public class CourseRepository: CourseRepositoryProtocol { .mapResponse(DataLayer.ResumeBlock.self).domain } - public func getSubtitles(url: String) async throws -> String { - if let subtitlesOffline = persistence.loadSubtitles(url: url) { + public func getSubtitles(url: String, selectedLanguage: String) async throws -> String { + if let subtitlesOffline = persistence.loadSubtitles(url: url + selectedLanguage) { return subtitlesOffline } else { - let result = try await api.requestData(CourseDetailsEndpoint.getSubtitles(url: url)) + let result = try await api.requestData(CourseDetailsEndpoint.getSubtitles( + url: url, + selectedLanguage: selectedLanguage + )) let subtitles = String(data: result, encoding: .utf8) ?? "" - persistence.saveSubtitles(url: url, subtitlesString: subtitles) + persistence.saveSubtitles(url: url + selectedLanguage, subtitlesString: subtitles) return subtitles } } @@ -119,7 +122,8 @@ public class CourseRepository: CourseRepositoryProtocol { childs.append(chapter) } - return CourseStructure(id: course.id, + return CourseStructure(courseID: structure.id, + id: course.id, graded: course.graded, completion: course.completion ?? 0, viewYouTubeUrl: course.userViewData?.encodedVideo?.youTube?.url ?? "", @@ -241,7 +245,7 @@ class CourseRepositoryMock: CourseRepositoryProtocol { let decoder = JSONDecoder() let jsonData = Data(courseStructureJson.utf8) let courseBlocks = try decoder.decode(DataLayer.CourseStructure.self, from: jsonData) - return parseCourseStructure(courseBlocks: courseBlocks) + return parseCourseStructure(structure: courseBlocks) } public func getCourseDetails(courseID: String) async throws -> CourseDetails { @@ -263,10 +267,12 @@ class CourseRepositoryMock: CourseRepositoryProtocol { public func getCourseBlocks(courseID: String) async throws -> CourseStructure { do { - let decoder = JSONDecoder() - let jsonData = Data(courseStructureJson.utf8) - let courseBlocks = try decoder.decode(DataLayer.CourseStructure.self, from: jsonData) - return parseCourseStructure(courseBlocks: courseBlocks) +// let decoder = JSONDecoder() +// let jsonData = Data(courseStructureJson.utf8) + let courseBlocks = try courseStructureJson.data(using: .utf8)!.mapResponse(DataLayer.CourseStructure.self) + +// let courseBlocks = try decoder.decode(DataLayer.CourseStructure.self, from: jsonData) + return parseCourseStructure(structure: courseBlocks) } catch { throw error } @@ -280,7 +286,7 @@ class CourseRepositoryMock: CourseRepositoryProtocol { } - public func getSubtitles(url: String) async throws -> String { + public func getSubtitles(url: String, selectedLanguage: String) async throws -> String { return """ 0 00:00:00,350 --> 00:00:05,230 @@ -303,8 +309,8 @@ And there are various ways of describing it-- call it oral poetry or """ } - private func parseCourseStructure(courseBlocks: DataLayer.CourseStructure) -> CourseStructure { - let blocks = Array(courseBlocks.dict.values) + private func parseCourseStructure(structure: DataLayer.CourseStructure) -> CourseStructure { + let blocks = Array(structure.dict.values) let course = blocks.first(where: {$0.type == BlockType.course.rawValue })! let descendants = course.descendants ?? [] var childs: [CourseChapter] = [] @@ -313,7 +319,8 @@ And there are various ways of describing it-- call it oral poetry or childs.append(chapter) } - return CourseStructure(id: course.id, + return CourseStructure(courseID: structure.id, + id: course.id, graded: course.graded, completion: course.completion ?? 0, viewYouTubeUrl: course.userViewData?.encodedVideo?.youTube?.url ?? "", @@ -321,8 +328,8 @@ And there are various ways of describing it-- call it oral poetry or displayName: course.displayName, topicID: course.userViewData?.topicID, childs: childs, - media: courseBlocks.media, - certificate: courseBlocks.certificate?.domain) + media: structure.media, + certificate: structure.certificate?.domain) } private func parseChapters(id: String, blocks: [DataLayer.CourseBlock]) -> CourseChapter { @@ -375,11 +382,12 @@ And there are various ways of describing it-- call it oral poetry or private func parseBlock(id: String, blocks: [DataLayer.CourseBlock]) -> CourseBlock { let block = blocks.first(where: {$0.id == id })! let subtitles = block.userViewData?.transcripts?.map { -// let url = $0.value + let url = $0.value // .replacingOccurrences(of: config.baseURL.absoluteString, with: "") -// .replacingOccurrences(of: "?lang=en", with: "") - SubtitleUrl(language: $0.key, url: $0.value) +// .replacingOccurrences(of: "?lang=\($0.key)", with: "") + return SubtitleUrl(language: $0.key, url: url) } + return CourseBlock(blockId: block.blockId, id: block.id, topicId: block.userViewData?.topicID, @@ -393,443 +401,627 @@ And there are various ways of describing it-- call it oral poetry or youTubeUrl: block.userViewData?.encodedVideo?.youTube?.url) } - private let courseStructureJson: String = "{\n" + - " \"root\": \"block-v1:RG+MC01+2022+type@course+block@course\",\n" + - " \"blocks\": {\n" + - " \"block-v1:RG+MC01+2022+type@html+block@8718fdf95d584d198a3b17c0d2611139\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@html+block@8718fdf95d584d198a3b17c0d2611139\",\n" + - " \"block_id\": \"8718fdf95d584d198a3b17c0d2611139\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@html+block@8718fdf95d584d198a3b17c0d2611139\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@html+block@8718fdf95d584d198a3b17c0d2611139?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@html+block@8718fdf95d584d198a3b17c0d2611139\",\n" + - " \"type\": \"html\",\n" + - " \"display_name\": \"Text\",\n" + - " \"graded\": false,\n" + - " \"student_view_data\": {\n" + - " \"enabled\": false,\n" + - " \"message\": \"To enable, set FEATURES[\\\"ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA\\\"]\"\n" + - " },\n" + - " \"student_view_multi_device\": true,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [],\n" + - " \"completion\": 1.0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@video+block@d1bb8c9e6ed44b708ea54cacf67b650a\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@video+block@d1bb8c9e6ed44b708ea54cacf67b650a\",\n" + - " \"block_id\": \"d1bb8c9e6ed44b708ea54cacf67b650a\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@video+block@d1bb8c9e6ed44b708ea54cacf67b650a\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@video+block@d1bb8c9e6ed44b708ea54cacf67b650a?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@video+block@d1bb8c9e6ed44b708ea54cacf67b650a\",\n" + - " \"type\": \"video\",\n" + - " \"display_name\": \"Video\",\n" + - " \"graded\": false,\n" + - " \"student_view_data\": {\n" + - " \"only_on_web\": false,\n" + - " \"duration\": null,\n" + - " \"transcripts\": {\n" + - " \"en\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/xblock/block-v1:RG+MC01+2022+type@video+block@d1bb8c9e6ed44b708ea54cacf67b650a/handler_noauth/transcript/download?lang=en\"\n" + - " },\n" + - " \"encoded_videos\": {\n" + - " \"youtube\": {\n" + - " \"url\": \"https://www.youtube.com/watch?v=3_yD_cEKoCk\",\n" + - " \"file_size\": 0\n" + - " }\n" + - " },\n" + - " \"all_sources\": []\n" + - " },\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 1\n" + - " },\n" + - " \"descendants\": [],\n" + - " \"completion\": 0.0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@vertical+block@8ccbceb2abec4028a9cc8b1fecf5e7d8\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@vertical+block@8ccbceb2abec4028a9cc8b1fecf5e7d8\",\n" + - " \"block_id\": \"8ccbceb2abec4028a9cc8b1fecf5e7d8\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@vertical+block@8ccbceb2abec4028a9cc8b1fecf5e7d8\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@vertical+block@8ccbceb2abec4028a9cc8b1fecf5e7d8?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@vertical+block@8ccbceb2abec4028a9cc8b1fecf5e7d8\",\n" + - " \"type\": \"vertical\",\n" + - " \"display_name\": \"Welcome!\",\n" + - " \"graded\": false,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 1\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@html+block@8718fdf95d584d198a3b17c0d2611139\",\n" + - " \"block-v1:RG+MC01+2022+type@video+block@d1bb8c9e6ed44b708ea54cacf67b650a\"\n" + - " ],\n" + - " \"completion\": 0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@html+block@5735347ae4be44d5b184728661d79bb4\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@html+block@5735347ae4be44d5b184728661d79bb4\",\n" + - " \"block_id\": \"5735347ae4be44d5b184728661d79bb4\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@html+block@5735347ae4be44d5b184728661d79bb4\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@html+block@5735347ae4be44d5b184728661d79bb4?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@html+block@5735347ae4be44d5b184728661d79bb4\",\n" + - " \"type\": \"html\",\n" + - " \"display_name\": \"Text\",\n" + - " \"graded\": false,\n" + - " \"student_view_data\": {\n" + - " \"enabled\": false,\n" + - " \"message\": \"To enable, set FEATURES[\\\"ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA\\\"]\"\n" + - " },\n" + - " \"student_view_multi_device\": true,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [],\n" + - " \"completion\": 1.0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@discussion+block@0b26805b246c44148a2c02dfbffa2b27\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@discussion+block@0b26805b246c44148a2c02dfbffa2b27\",\n" + - " \"block_id\": \"0b26805b246c44148a2c02dfbffa2b27\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@discussion+block@0b26805b246c44148a2c02dfbffa2b27\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@discussion+block@0b26805b246c44148a2c02dfbffa2b27?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@discussion+block@0b26805b246c44148a2c02dfbffa2b27\",\n" + - " \"type\": \"discussion\",\n" + - " \"display_name\": \"Discussion\",\n" + - " \"graded\": false,\n" + - " \"student_view_data\": {\n" + - " \"topic_id\": \"035315aac3f889b472c8f051d8fd0abaa99682de\"\n" + - " },\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": []\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@vertical+block@890277efe17a42a185c68b8ba8fc5a98\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@vertical+block@890277efe17a42a185c68b8ba8fc5a98\",\n" + - " \"block_id\": \"890277efe17a42a185c68b8ba8fc5a98\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@vertical+block@890277efe17a42a185c68b8ba8fc5a98\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@vertical+block@890277efe17a42a185c68b8ba8fc5a98?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@vertical+block@890277efe17a42a185c68b8ba8fc5a98\",\n" + - " \"type\": \"vertical\",\n" + - " \"display_name\": \"General Info\",\n" + - " \"graded\": false,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@html+block@5735347ae4be44d5b184728661d79bb4\",\n" + - " \"block-v1:RG+MC01+2022+type@discussion+block@0b26805b246c44148a2c02dfbffa2b27\"\n" + - " ],\n" + - " \"completion\": 1\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@sequential+block@45b174bf007b4d86a3a265d996565883\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@sequential+block@45b174bf007b4d86a3a265d996565883\",\n" + - " \"block_id\": \"45b174bf007b4d86a3a265d996565883\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@sequential+block@45b174bf007b4d86a3a265d996565883\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@sequential+block@45b174bf007b4d86a3a265d996565883?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@sequential+block@45b174bf007b4d86a3a265d996565883\",\n" + - " \"type\": \"sequential\",\n" + - " \"display_name\": \"Course Intro\",\n" + - " \"graded\": false,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 1\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@vertical+block@8ccbceb2abec4028a9cc8b1fecf5e7d8\",\n" + - " \"block-v1:RG+MC01+2022+type@vertical+block@890277efe17a42a185c68b8ba8fc5a98\"\n" + - " ],\n" + - " \"completion\": 0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@chapter+block@7cb5739b6ead4fc39b126bbe56cdb9c7\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@chapter+block@7cb5739b6ead4fc39b126bbe56cdb9c7\",\n" + - " \"block_id\": \"7cb5739b6ead4fc39b126bbe56cdb9c7\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@chapter+block@7cb5739b6ead4fc39b126bbe56cdb9c7\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@chapter+block@7cb5739b6ead4fc39b126bbe56cdb9c7?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@chapter+block@7cb5739b6ead4fc39b126bbe56cdb9c7\",\n" + - " \"type\": \"chapter\",\n" + - " \"display_name\": \"Info Section\",\n" + - " \"graded\": false,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 1\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@sequential+block@45b174bf007b4d86a3a265d996565883\"\n" + - " ],\n" + - " \"completion\": 0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@problem+block@376ec419a01449fd86c2d11c8054d0be\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@problem+block@376ec419a01449fd86c2d11c8054d0be\",\n" + - " \"block_id\": \"376ec419a01449fd86c2d11c8054d0be\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@problem+block@376ec419a01449fd86c2d11c8054d0be\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@problem+block@376ec419a01449fd86c2d11c8054d0be?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@problem+block@376ec419a01449fd86c2d11c8054d0be\",\n" + - " \"type\": \"problem\",\n" + - " \"display_name\": \"Checkboxes\",\n" + - " \"graded\": true,\n" + - " \"student_view_multi_device\": true,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [],\n" + - " \"completion\": 1.0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@problem+block@ebc2d20fad364992b13fff49fc53d7cf\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@problem+block@ebc2d20fad364992b13fff49fc53d7cf\",\n" + - " \"block_id\": \"ebc2d20fad364992b13fff49fc53d7cf\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@problem+block@ebc2d20fad364992b13fff49fc53d7cf\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@problem+block@ebc2d20fad364992b13fff49fc53d7cf?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@problem+block@ebc2d20fad364992b13fff49fc53d7cf\",\n" + - " \"type\": \"problem\",\n" + - " \"display_name\": \"Dropdown\",\n" + - " \"graded\": true,\n" + - " \"student_view_multi_device\": true,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [],\n" + - " \"completion\": 1.0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@problem+block@6b822c82f2ca4b049ee380a1cf65396b\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@problem+block@6b822c82f2ca4b049ee380a1cf65396b\",\n" + - " \"block_id\": \"6b822c82f2ca4b049ee380a1cf65396b\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@problem+block@6b822c82f2ca4b049ee380a1cf65396b\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@problem+block@6b822c82f2ca4b049ee380a1cf65396b?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@problem+block@6b822c82f2ca4b049ee380a1cf65396b\",\n" + - " \"type\": \"problem\",\n" + - " \"display_name\": \"Numerical Input with Hints and Feedback\",\n" + - " \"graded\": true,\n" + - " \"student_view_multi_device\": true,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [],\n" + - " \"completion\": 1.0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@problem+block@009da5f764a04078855d322e205c5863\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@problem+block@009da5f764a04078855d322e205c5863\",\n" + - " \"block_id\": \"009da5f764a04078855d322e205c5863\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@problem+block@009da5f764a04078855d322e205c5863\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@problem+block@009da5f764a04078855d322e205c5863?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@problem+block@009da5f764a04078855d322e205c5863\",\n" + - " \"type\": \"problem\",\n" + - " \"display_name\": \"Multiple Choice\",\n" + - " \"graded\": true,\n" + - " \"student_view_multi_device\": true,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [],\n" + - " \"completion\": 1.0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@vertical+block@e34d9616cbaa45d1a6986a687c49f5c4\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@vertical+block@e34d9616cbaa45d1a6986a687c49f5c4\",\n" + - " \"block_id\": \"e34d9616cbaa45d1a6986a687c49f5c4\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@vertical+block@e34d9616cbaa45d1a6986a687c49f5c4\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@vertical+block@e34d9616cbaa45d1a6986a687c49f5c4?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@vertical+block@e34d9616cbaa45d1a6986a687c49f5c4\",\n" + - " \"type\": \"vertical\",\n" + - " \"display_name\": \"Common Problems\",\n" + - " \"graded\": true,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@problem+block@376ec419a01449fd86c2d11c8054d0be\",\n" + - " \"block-v1:RG+MC01+2022+type@problem+block@ebc2d20fad364992b13fff49fc53d7cf\",\n" + - " \"block-v1:RG+MC01+2022+type@problem+block@6b822c82f2ca4b049ee380a1cf65396b\",\n" + - " \"block-v1:RG+MC01+2022+type@problem+block@009da5f764a04078855d322e205c5863\"\n" + - " ],\n" + - " \"completion\": 1\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@sequential+block@ac7862e8c3c9481bbe657a82795def56\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@sequential+block@ac7862e8c3c9481bbe657a82795def56\",\n" + - " \"block_id\": \"ac7862e8c3c9481bbe657a82795def56\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@sequential+block@ac7862e8c3c9481bbe657a82795def56\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@sequential+block@ac7862e8c3c9481bbe657a82795def56?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@sequential+block@ac7862e8c3c9481bbe657a82795def56\",\n" + - " \"type\": \"sequential\",\n" + - " \"display_name\": \"Test\",\n" + - " \"graded\": true,\n" + - " \"format\": \"Final Exam\",\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@vertical+block@e34d9616cbaa45d1a6986a687c49f5c4\"\n" + - " ],\n" + - " \"completion\": 1\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@problem+block@9355144723fc4270a1081547fd8bdd3d\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@problem+block@9355144723fc4270a1081547fd8bdd3d\",\n" + - " \"block_id\": \"9355144723fc4270a1081547fd8bdd3d\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@problem+block@9355144723fc4270a1081547fd8bdd3d\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@problem+block@9355144723fc4270a1081547fd8bdd3d?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@problem+block@9355144723fc4270a1081547fd8bdd3d\",\n" + - " \"type\": \"problem\",\n" + - " \"display_name\": \"Image Mapped Input\",\n" + - " \"graded\": false,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [],\n" + - " \"completion\": 0.0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@vertical+block@50d42e9c9d91451fb50693e01b9e4340\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@vertical+block@50d42e9c9d91451fb50693e01b9e4340\",\n" + - " \"block_id\": \"50d42e9c9d91451fb50693e01b9e4340\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@vertical+block@50d42e9c9d91451fb50693e01b9e4340\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@vertical+block@50d42e9c9d91451fb50693e01b9e4340?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@vertical+block@50d42e9c9d91451fb50693e01b9e4340\",\n" + - " \"type\": \"vertical\",\n" + - " \"display_name\": \"Advanced Problems\",\n" + - " \"graded\": false,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@problem+block@9355144723fc4270a1081547fd8bdd3d\"\n" + - " ],\n" + - " \"completion\": 0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@sequential+block@5cdb10d7d0e9498faba55450173e23be\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@sequential+block@5cdb10d7d0e9498faba55450173e23be\",\n" + - " \"block_id\": \"5cdb10d7d0e9498faba55450173e23be\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@sequential+block@5cdb10d7d0e9498faba55450173e23be\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@sequential+block@5cdb10d7d0e9498faba55450173e23be?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@sequential+block@5cdb10d7d0e9498faba55450173e23be\",\n" + - " \"type\": \"sequential\",\n" + - " \"display_name\": \"X-blocks not supported in app\",\n" + - " \"graded\": false,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@vertical+block@50d42e9c9d91451fb50693e01b9e4340\"\n" + - " ],\n" + - " \"completion\": 0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@chapter+block@8f208b5d63234ce483f7d6702c46238a\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@chapter+block@8f208b5d63234ce483f7d6702c46238a\",\n" + - " \"block_id\": \"8f208b5d63234ce483f7d6702c46238a\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@chapter+block@8f208b5d63234ce483f7d6702c46238a\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@chapter+block@8f208b5d63234ce483f7d6702c46238a?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@chapter+block@8f208b5d63234ce483f7d6702c46238a\",\n" + - " \"type\": \"chapter\",\n" + - " \"display_name\": \"Problems\",\n" + - " \"graded\": false,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@sequential+block@ac7862e8c3c9481bbe657a82795def56\",\n" + - " \"block-v1:RG+MC01+2022+type@sequential+block@5cdb10d7d0e9498faba55450173e23be\"\n" + - " ],\n" + - " \"completion\": 0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@html+block@4b0ea9edbf59484fb5ecc1f8f29f73c2\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@html+block@4b0ea9edbf59484fb5ecc1f8f29f73c2\",\n" + - " \"block_id\": \"4b0ea9edbf59484fb5ecc1f8f29f73c2\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@html+block@4b0ea9edbf59484fb5ecc1f8f29f73c2\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@html+block@4b0ea9edbf59484fb5ecc1f8f29f73c2?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@html+block@4b0ea9edbf59484fb5ecc1f8f29f73c2\",\n" + - " \"type\": \"html\",\n" + - " \"display_name\": \"Text\",\n" + - " \"graded\": false,\n" + - " \"student_view_data\": {\n" + - " \"enabled\": false,\n" + - " \"message\": \"To enable, set FEATURES[\\\"ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA\\\"]\"\n" + - " },\n" + - " \"student_view_multi_device\": true,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [],\n" + - " \"completion\": 1.0\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@vertical+block@b912f9ba42ac43c492bfb423e15b0da1\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@vertical+block@b912f9ba42ac43c492bfb423e15b0da1\",\n" + - " \"block_id\": \"b912f9ba42ac43c492bfb423e15b0da1\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@vertical+block@b912f9ba42ac43c492bfb423e15b0da1\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@vertical+block@b912f9ba42ac43c492bfb423e15b0da1?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@vertical+block@b912f9ba42ac43c492bfb423e15b0da1\",\n" + - " \"type\": \"vertical\",\n" + - " \"display_name\": \"Thank you\",\n" + - " \"graded\": false,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@html+block@4b0ea9edbf59484fb5ecc1f8f29f73c2\"\n" + - " ],\n" + - " \"completion\": 1\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@sequential+block@a6e2101867234019b60607a9b9bf64f9\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@sequential+block@a6e2101867234019b60607a9b9bf64f9\",\n" + - " \"block_id\": \"a6e2101867234019b60607a9b9bf64f9\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@sequential+block@a6e2101867234019b60607a9b9bf64f9\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@sequential+block@a6e2101867234019b60607a9b9bf64f9?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@sequential+block@a6e2101867234019b60607a9b9bf64f9\",\n" + - " \"type\": \"sequential\",\n" + - " \"display_name\": \"Thank you note\",\n" + - " \"graded\": false,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@vertical+block@b912f9ba42ac43c492bfb423e15b0da1\"\n" + - " ],\n" + - " \"completion\": 1\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@chapter+block@29f4043d199e46ef95d437da3be1d222\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@chapter+block@29f4043d199e46ef95d437da3be1d222\",\n" + - " \"block_id\": \"29f4043d199e46ef95d437da3be1d222\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@chapter+block@29f4043d199e46ef95d437da3be1d222\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@chapter+block@29f4043d199e46ef95d437da3be1d222?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@chapter+block@29f4043d199e46ef95d437da3be1d222\",\n" + - " \"type\": \"chapter\",\n" + - " \"display_name\": \"Fin\",\n" + - " \"graded\": false,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 0\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@sequential+block@a6e2101867234019b60607a9b9bf64f9\"\n" + - " ],\n" + - " \"completion\": 1\n" + - " },\n" + - " \"block-v1:RG+MC01+2022+type@course+block@course\": {\n" + - " \"id\": \"block-v1:RG+MC01+2022+type@course+block@course\",\n" + - " \"block_id\": \"course\",\n" + - " \"lms_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@course+block@course\",\n" + - " \"legacy_web_url\": \"https://lms-client-demo-maple.raccoongang.com/courses/course-v1:RG+MC01+2022/jump_to/block-v1:RG+MC01+2022+type@course+block@course?experience=legacy\",\n" + - " \"student_view_url\": \"https://lms-client-demo-maple.raccoongang.com/xblock/block-v1:RG+MC01+2022+type@course+block@course\",\n" + - " \"type\": \"course\",\n" + - " \"display_name\": \"Mobile Course Demo\",\n" + - " \"graded\": false,\n" + - " \"student_view_multi_device\": false,\n" + - " \"block_counts\": {\n" + - " \"video\": 1\n" + - " },\n" + - " \"descendants\": [\n" + - " \"block-v1:RG+MC01+2022+type@chapter+block@7cb5739b6ead4fc39b126bbe56cdb9c7\",\n" + - " \"block-v1:RG+MC01+2022+type@chapter+block@8f208b5d63234ce483f7d6702c46238a\",\n" + - " \"block-v1:RG+MC01+2022+type@chapter+block@29f4043d199e46ef95d437da3be1d222\"\n" + - " ],\n" + - " \"completion\": 0\n" + - " }\n" + - " }\n" + - "}" + private let courseStructureJson: String = """ + {"root": "block-v1:QA+comparison+2022+type@course+block@course", + "blocks": { + "block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78": { + "id": "block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78", + "block_id": "be1704c576284ba39753c6f0ea4a4c78", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78", + "type": "comparison", + "display_name": "Comparison", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296": { + "id": "block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296", + "block_id": "93acc543871e4c73bc20a72a64e93296", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296", + "type": "problem", + "display_name": "Dropdown with Hints and Feedback", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b": { + "id": "block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b", + "block_id": "06c17035106e48328ebcd042babcf47b", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b", + "type": "comparison", + "display_name": "Comparison", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58": { + "id": "block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58", + "block_id": "c19e41b61db14efe9c45f1354332ae58", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58", + "type": "problem", + "display_name": "Text Input with Hints and Feedback", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb": { + "id": "block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb", + "block_id": "0d96732f577b4ff68799faf8235d1bfb", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb", + "type": "problem", + "display_name": "Numerical Input with Hints and Feedback", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96": { + "id": "block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96", + "block_id": "dd2e22fdf0724bd88c8b2e6b68dedd96", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96", + "type": "problem", + "display_name": "Blank Common Problem", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd": { + "id": "block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd", + "block_id": "d1e091aa305741c5bedfafed0d269efd", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd", + "type": "problem", + "display_name": "Blank Common Problem", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7": { + "id": "block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7", + "block_id": "23e10dea806345b19b77997b4fc0eea7", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7", + "type": "comparison", + "display_name": "Comparison", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904": { + "id": "block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904", + "block_id": "29e7eddbe8964770896e4036748c9904", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904", + "type": "vertical", + "display_name": "Unit", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78", + "block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296", + "block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b", + "block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58", + "block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb", + "block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96", + "block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd", + "block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6": { + "id": "block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6", + "block_id": "f468bb5c6e8641179e523c7fcec4e6d6", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6", + "type": "sequential", + "display_name": "Subsection", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234": { + "id": "block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234", + "block_id": "eaf91d8fc70547339402043ba1a1c234", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234", + "type": "problem", + "display_name": "Dropdown with Hints and Feedback", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751": { + "id": "block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751", + "block_id": "fac531c3f1f3400cb8e3b97eb2c3d751", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751", + "type": "comparison", + "display_name": "Comparison", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de": { + "id": "block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de", + "block_id": "74a1074024fe401ea305534f2241e5de", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de", + "type": "html", + "display_name": "Raw HTML", + "graded": false, + "student_view_data": { + "last_modified": "2023-05-04T19:08:07Z", + "html_data": "https://s3.eu-central-1.amazonaws.com/vso-dev-edx-sorage/htmlxblock/QA/comparison/html/74a1074024fe401ea305534f2241e5de/content_html.zip", + "size": 576, + "index_page": "index.html", + "icon_class": "other" + }, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca": { + "id": "block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca", + "block_id": "e5b2e105f4f947c5b76fb12c35da1eca", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca", + "type": "vertical", + "display_name": "Unit", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234", + "block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751", + "block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2": { + "id": "block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2", + "block_id": "d37cb0c5c2d24ddaacf3494760a055f2", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2", + "type": "sequential", + "display_name": "Another one subsection", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846": { + "id": "block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846", + "block_id": "abecaefe203c4c93b441d16cea3b7846", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846", + "type": "chapter", + "display_name": "Section", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6", + "block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de": { + "id": "block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de", + "block_id": "a0c3ac29daab425f92a34b34eb2af9de", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de", + "type": "pdf", + "display_name": "PDF title", + "graded": false, + "student_view_data": { + "last_modified": "2023-04-26T08:43:45Z", + "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", + }, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9": { + "id": "block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9", + "block_id": "bcd1b0f3015b4d3696b12f65a5d682f9", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9", + "type": "pdf", + "display_name": "PDF", + "graded": false, + "student_view_data": { + "last_modified": "2023-04-26T08:43:45Z", + "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", + }, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59": { + "id": "block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59", + "block_id": "67d805daade34bd4b6ace607e6d48f59", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59", + "type": "pdf", + "display_name": "PDF", + "graded": false, + "student_view_data": { + "last_modified": "2023-04-26T08:43:45Z", + "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", + }, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974": { + "id": "block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974", + "block_id": "828606a51f4e44198e92f86a45be7974", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974", + "type": "pdf", + "display_name": "PDF", + "graded": false, + "student_view_data": { + "last_modified": "2023-04-26T08:43:45Z", + "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", + }, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee": { + "id": "block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee", + "block_id": "8646c3bc2184467b86e5ef01ecd452ee", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee", + "type": "pdf", + "display_name": "PDF", + "graded": false, + "student_view_data": { + "last_modified": "2023-04-26T08:43:45Z", + "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", + }, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1": { + "id": "block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1", + "block_id": "e2faa0e62223489e91a41700865c5fc1", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1", + "type": "vertical", + "display_name": "Unit", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de", + "block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9", + "block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59", + "block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974", + "block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52": { + "id": "block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52", + "block_id": "0c5e89fa6d7a4fac8f7b26f2ca0bbe52", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52", + "type": "problem", + "display_name": "Checkboxes with Hints and Feedback", + "graded": false, + "student_view_multi_device": true, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4": { + "id": "block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4", + "block_id": "8ba437d8b20d416d91a2d362b0c940a4", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4", + "type": "vertical", + "display_name": "Unit", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d": { + "id": "block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d", + "block_id": "021f70794f7349998e190b060260b70d", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d", + "type": "pdf", + "display_name": "PDF", + "graded": false, + "student_view_data": { + "last_modified": "2023-04-26T08:43:45Z", + "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", + }, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ], + "completion": 0.0 + }, + "block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1": { + "id": "block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1", + "block_id": "2c344115d3554ac58c140ec86e591aa1", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1", + "type": "vertical", + "display_name": "Unit", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe": { + "id": "block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe", + "block_id": "6c9c6ba663b54c0eb9cbdcd0c6b4bebe", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe", + "type": "sequential", + "display_name": "Subsection", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1", + "block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4", + "block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7": { + "id": "block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7", + "block_id": "d5a4f1f2f5314288aae400c270fb03f7", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7", + "type": "chapter", + "display_name": "PDF", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe" + ], + "completion": 0 + }, + "block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf": { + "id": "block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf", + "block_id": "7ab45affb80f4846a60648ec6aff9fbf", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf", + "type": "chapter", + "display_name": "Section", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + + ] + }, + "block-v1:QA+comparison+2022+type@course+block@course": { + "id": "block-v1:QA+comparison+2022+type@course+block@course", + "block_id": "course", + "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@course+block@course", + "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@course+block@course?experience=legacy", + "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@course+block@course", + "type": "course", + "display_name": "Comparison xblock test coursre", + "graded": false, + "student_view_multi_device": false, + "block_counts": { + "video": 0 + }, + "descendants": [ + "block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846", + "block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7", + "block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf" + ], + "completion": 0 + } + }, + "id": "course-v1:QA+comparison+2022", + "name": "Comparison xblock test coursre", + "number": "comparison", + "org": "QA", + "start": "2022-01-01T00:00:00Z", + "start_display": "01 january 2022 р.", + "start_type": "timestamp", + "end": null, + "courseware_access": { + "has_access": true, + "error_code": null, + "developer_message": null, + "user_message": null, + "additional_context_user_message": null, + "user_fragment": null + }, + "media": { + "image": { + "raw": "/asset-v1:QA+comparison+2022+type@asset+block@images_course_image.jpg", + "small": "/asset-v1:QA+comparison+2022+type@asset+block@images_course_image.jpg", + "large": "/asset-v1:QA+comparison+2022+type@asset+block@images_course_image.jpg" + } + }, + "certificate": { + + }, + "is_self_paced": false + } + """ } #endif diff --git a/Course/Course/Data/Model/Data_ResumeBlock.swift b/Course/Course/Data/Model/Data_ResumeBlock.swift index 506defef3..c49167a1b 100644 --- a/Course/Course/Data/Model/Data_ResumeBlock.swift +++ b/Course/Course/Data/Model/Data_ResumeBlock.swift @@ -30,6 +30,6 @@ public extension DataLayer { public extension DataLayer.ResumeBlock { var domain: ResumeBlock { - ResumeBlock(blockID: lastVisitedModulePath.first ?? "") + ResumeBlock(blockID: lastVisitedBlockID) } } diff --git a/Course/Course/Data/Network/CourseDetailsEndpoint.swift b/Course/Course/Data/Network/CourseDetailsEndpoint.swift index 3207fc4ff..57bb2459f 100644 --- a/Course/Course/Data/Network/CourseDetailsEndpoint.swift +++ b/Course/Course/Data/Network/CourseDetailsEndpoint.swift @@ -18,7 +18,7 @@ enum CourseDetailsEndpoint: EndPointType { case getHandouts(courseID: String) case getUpdates(courseID: String) case resumeBlock(userName: String, courseID: String) - case getSubtitles(url: String) + case getSubtitles(url: String, selectedLanguage: String) var path: String { switch self { @@ -38,7 +38,7 @@ enum CourseDetailsEndpoint: EndPointType { return "/api/mobile/v1/course_info/\(courseID)/updates" case let .resumeBlock(userName, courseID): return "/api/mobile/v1/users/\(userName)/course_status_info/\(courseID)" - case .getSubtitles(url: let url): + case let .getSubtitles(url, _): return url } } @@ -111,10 +111,10 @@ enum CourseDetailsEndpoint: EndPointType { return .requestParameters(encoding: JSONEncoding.default) case .resumeBlock: return .requestParameters(encoding: JSONEncoding.default) - case .getSubtitles: - let languageCode = Locale.current.languageCode ?? "en" + case let .getSubtitles(_, subtitleLanguage): +// let languageCode = Locale.current.languageCode ?? "en" let params: [String: Any] = [ - "lang": languageCode + "lang": subtitleLanguage ] return .requestParameters(parameters: params, encoding: URLEncoding.queryString) } diff --git a/Course/Course/Data/Persistence/CoursePersistence.swift b/Course/Course/Data/Persistence/CoursePersistence.swift index 2d5531b00..1a9731e12 100644 --- a/Course/Course/Data/Persistence/CoursePersistence.swift +++ b/Course/Course/Data/Persistence/CoursePersistence.swift @@ -156,14 +156,19 @@ public class CoursePersistence: CoursePersistenceProtocol { result[block.id] = block } ?? [:] - return DataLayer.CourseStructure(rootItem: structure.rootItem ?? "", - dict: dictionary, - id: structure.id ?? "", - media: DataLayer.CourseMedia(image: - DataLayer.Image(raw: structure.mediaRaw ?? "", - small: structure.mediaSmall ?? "", - large: structure.mediaLarge ?? "")), - certificate: DataLayer.Certificate(url: structure.certificate)) + return DataLayer.CourseStructure( + rootItem: structure.rootItem ?? "", + dict: dictionary, + id: structure.id ?? "", + media: DataLayer.CourseMedia( + image: DataLayer.Image( + raw: structure.mediaRaw ?? "", + small: structure.mediaSmall ?? "", + large: structure.mediaLarge ?? "" + ) + ), + certificate: DataLayer.Certificate(url: structure.certificate) + ) } public func saveCourseStructure(structure: DataLayer.CourseStructure) { diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index 87eda8281..3bcfdd574 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -20,7 +20,7 @@ public protocol CourseInteractorProtocol { func getHandouts(courseID: String) async throws -> String? func getUpdates(courseID: String) async throws -> [CourseUpdate] func resumeBlock(courseID: String) async throws -> ResumeBlock - func getSubtitles(url: String) async throws -> [Subtitle] + func getSubtitles(url: String, selectedLanguage: String) async throws -> [Subtitle] } public class CourseInteractor: CourseInteractorProtocol { @@ -48,6 +48,7 @@ public class CourseInteractor: CourseInteractorProtocol { } } return CourseStructure( + courseID: course.courseID, id: course.id, graded: course.graded, completion: course.completion, @@ -89,8 +90,8 @@ public class CourseInteractor: CourseInteractorProtocol { return try await repository.resumeBlock(courseID: courseID) } - public func getSubtitles(url: String) async throws -> [Subtitle] { - let result = try await repository.getSubtitles(url: url) + public func getSubtitles(url: String, selectedLanguage: String) async throws -> [Subtitle] { + let result = try await repository.getSubtitles(url: url, selectedLanguage: selectedLanguage) return parseSubtitles(from: result) } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 78dd8ca7f..69a9755f7 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -18,7 +18,7 @@ public struct CourseContainerView: View { private var courseID: String private var title: String - public enum CourseTab { + enum CourseTab { case course case videos case discussion @@ -39,67 +39,77 @@ public struct CourseContainerView: View { } public var body: some View { - if let courseStart = viewModel.courseStart { - if courseStart > Date() { - CourseOutlineView( - viewModel: viewModel, - title: title, - courseID: courseID, - isVideo: false - ) - } else { - TabView(selection: $selection) { + ZStack { + if let courseStart = viewModel.courseStart { + if courseStart > Date() { CourseOutlineView( - viewModel: self.viewModel, + viewModel: viewModel, title: title, courseID: courseID, isVideo: false ) - .tabItem { - CoreAssets.bookCircle.swiftUIImage.renderingMode(.template) - Text(CourseLocalization.CourseContainer.course) + } else { + TabView(selection: $selection) { + CourseOutlineView( + viewModel: self.viewModel, + title: title, + courseID: courseID, + isVideo: false + ) + .tabItem { + CoreAssets.bookCircle.swiftUIImage.renderingMode(.template) + Text(CourseLocalization.CourseContainer.course) + } + .tag(CourseTab.course) + .hideNavigationBar() + + CourseOutlineView( + viewModel: self.viewModel, + title: title, + courseID: courseID, + isVideo: true + ) + .tabItem { + CoreAssets.videoCircle.swiftUIImage.renderingMode(.template) + Text(CourseLocalization.CourseContainer.videos) + } + .tag(CourseTab.videos) + .hideNavigationBar() + + DiscussionTopicsView(courseID: courseID, + viewModel: Container.shared.resolve(DiscussionTopicsViewModel.self, + argument: title)!, + router: Container.shared.resolve(DiscussionRouter.self)!) + .tabItem { + CoreAssets.bubbleLeftCircle.swiftUIImage.renderingMode(.template) + Text(CourseLocalization.CourseContainer.discussion) + } + .tag(CourseTab.discussion) + .hideNavigationBar() + + HandoutsView(courseID: courseID, + viewModel: Container.shared.resolve(HandoutsViewModel.self, argument: courseID)!) + .tabItem { + CoreAssets.docCircle.swiftUIImage.renderingMode(.template) + Text(CourseLocalization.CourseContainer.handouts) + } + .tag(CourseTab.handounds) + .hideNavigationBar() } - .tag(CourseTab.course) - .hideNavigationBar() - - CourseOutlineView( - viewModel: self.viewModel, - title: title, - courseID: courseID, - isVideo: true - ) - .tabItem { - CoreAssets.videoCircle.swiftUIImage.renderingMode(.template) - Text(CourseLocalization.CourseContainer.videos) + .onFirstAppear { + Task { + await viewModel.tryToRefreshCookies() + } } - .tag(CourseTab.videos) - .hideNavigationBar() - - DiscussionTopicsView(courseID: courseID, - viewModel: Container.shared.resolve(DiscussionTopicsViewModel.self)!, - router: Container.shared.resolve(DiscussionRouter.self)!) - .tabItem { - CoreAssets.bubbleLeftCircle.swiftUIImage.renderingMode(.template) - Text(CourseLocalization.CourseContainer.discussion) - } - .tag(CourseTab.discussion) - .hideNavigationBar() - - HandoutsView(courseID: courseID, - viewModel: Container.shared.resolve(HandoutsViewModel.self, argument: courseID)!) - .tabItem { - CoreAssets.docCircle.swiftUIImage.renderingMode(.template) - Text(CourseLocalization.CourseContainer.handouts) - } - .tag(CourseTab.handounds) - .hideNavigationBar() - } - .navigationBarHidden(true) - .introspectViewController { vc in - vc.navigationController?.setNavigationBarHidden(true, animated: false) } } - } + }.onChange(of: selection, perform: { selection in + viewModel.trackSelectedTab( + selection: selection, + courseId: courseID, + courseName: title + ) + }) } } @@ -109,7 +119,9 @@ struct CourseScreensView_Previews: PreviewProvider { CourseContainerView( viewModel: CourseContainerViewModel( interactor: CourseInteractor.mock, + authInteractor: AuthInteractor.mock, router: CourseRouterMock(), + analytics: CourseAnalyticsMock(), config: ConfigMock(), connectivity: Connectivity(), manager: DownloadManagerMock(), diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index ca3150bdb..4f948bfa9 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -17,7 +17,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { @Published private(set) var isShowProgress = false @Published var showError: Bool = false @Published var downloadState: [String: DownloadViewState] = [:] - @Published var returnCourseSequential: CourseSequential? + @Published var continueWith: ContinueWith? var errorMessage: String? { didSet { @@ -27,30 +27,37 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } - public let interactor: CourseInteractorProtocol - public let router: CourseRouter - public let config: Config - public let connectivity: ConnectivityProtocol + private let interactor: CourseInteractorProtocol + private let authInteractor: AuthInteractorProtocol + let router: CourseRouter + let analytics: CourseAnalytics + let config: Config + let connectivity: ConnectivityProtocol - public let isActive: Bool? - public let courseStart: Date? - public let courseEnd: Date? - public let enrollmentStart: Date? - public let enrollmentEnd: Date? + let isActive: Bool? + let courseStart: Date? + let courseEnd: Date? + let enrollmentStart: Date? + let enrollmentEnd: Date? - public init(interactor: CourseInteractorProtocol, - router: CourseRouter, - config: Config, - connectivity: ConnectivityProtocol, - manager: DownloadManagerProtocol, - isActive: Bool?, - courseStart: Date?, - courseEnd: Date?, - enrollmentStart: Date?, - enrollmentEnd: Date? + public init( + interactor: CourseInteractorProtocol, + authInteractor: AuthInteractorProtocol, + router: CourseRouter, + analytics: CourseAnalytics, + config: Config, + connectivity: ConnectivityProtocol, + manager: DownloadManagerProtocol, + isActive: Bool?, + courseStart: Date?, + courseEnd: Date?, + enrollmentStart: Date?, + enrollmentEnd: Date? ) { self.interactor = interactor + self.authInteractor = authInteractor self.router = router + self.analytics = analytics self.config = config self.connectivity = connectivity self.isActive = isActive @@ -72,7 +79,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } @MainActor - public func getCourseBlocks(courseID: String, withProgress: Bool = true) async { + func getCourseBlocks(courseID: String, withProgress: Bool = true) async { if let courseStart { if courseStart < Date() { isShowProgress = withProgress @@ -81,10 +88,10 @@ public class CourseContainerViewModel: BaseCourseViewModel { courseStructure = try await interactor.getCourseBlocks(courseID: courseID) isShowProgress = false if let courseStructure { - let returnCourseSequential = try await getResumeBlock(courseID: courseID, - courseStructure: courseStructure) + let continueWith = try await getResumeBlock(courseID: courseID, + courseStructure: courseStructure) withAnimation { - self.returnCourseSequential = returnCourseSequential + self.continueWith = continueWith } } } else { @@ -107,19 +114,24 @@ public class CourseContainerViewModel: BaseCourseViewModel { } @MainActor - private func getResumeBlock(courseID: String, courseStructure: CourseStructure) async throws -> CourseSequential? { + func tryToRefreshCookies() async { + try? await authInteractor.getCookies(force: false) + } + + @MainActor + private func getResumeBlock(courseID: String, courseStructure: CourseStructure) async throws -> ContinueWith? { let result = try await interactor.resumeBlock(courseID: courseID) - return findCourseSequential(blockID: result.blockID, - courseStructure: courseStructure) + return findContinueVertical( + blockID: result.blockID, + courseStructure: courseStructure + ) } func onDownloadViewTap(chapter: CourseChapter, blockId: String, state: DownloadViewState) { let blocks = chapter.childs - .filter { $0.isDownloadable } + .first(where: { $0.id == blockId })?.childs .flatMap { $0.childs } - .filter { $0.isDownloadable } - .flatMap { $0.childs } - .filter { $0.isDownloadable } + .filter { $0.isDownloadable } ?? [] do { switch state { @@ -140,6 +152,23 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } + func trackSelectedTab( + selection: CourseContainerView.CourseTab, + courseId: String, + courseName: String + ) { + switch selection { + case .course: + analytics.courseOutlineCourseTabClicked(courseId: courseId, courseName: courseName) + case .videos: + analytics.courseOutlineVideosTabClicked(courseId: courseId, courseName: courseName) + case .discussion: + analytics.courseOutlineDiscussionTabClicked(courseId: courseId, courseName: courseName) + case .handounds: + analytics.courseOutlineHandoutsTabClicked(courseId: courseId, courseName: courseName) + } + } + @MainActor private func setDownloadsStates() { guard let courseStructure else { return } @@ -176,10 +205,21 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } - private func findCourseSequential(blockID: String, courseStructure: CourseStructure) -> CourseSequential? { - for chapter in courseStructure.childs { - if let sequential = chapter.childs.first(where: { $0.id == blockID }) { - return sequential + private func findContinueVertical(blockID: String, courseStructure: CourseStructure) -> ContinueWith? { + for chapterIndex in courseStructure.childs.indices { + let chapter = courseStructure.childs[chapterIndex] + for sequentialIndex in chapter.childs.indices { + let sequential = chapter.childs[sequentialIndex] + for verticalIndex in sequential.childs.indices { + let vertical = sequential.childs[verticalIndex] + for block in vertical.childs where block.id == blockID { + return ContinueWith( + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex, + verticalIndex: verticalIndex + ) + } + } } } return nil diff --git a/Course/Course/Presentation/CourseAnalytics.swift b/Course/Course/Presentation/CourseAnalytics.swift new file mode 100644 index 000000000..914774946 --- /dev/null +++ b/Course/Course/Presentation/CourseAnalytics.swift @@ -0,0 +1,47 @@ +// +// CourseAnalytics.swift +// Course +// +// Created by  Stepanok Ivan on 29.06.2023. +// + +import Foundation + +//sourcery: AutoMockable +public protocol CourseAnalytics { + func courseEnrollClicked(courseId: String, courseName: String) + func courseEnrollSuccess(courseId: String, courseName: String) + func viewCourseClicked(courseId: String, courseName: String) + func resumeCourseTapped(courseId: String, courseName: String, blockId: String) + func sequentialClicked(courseId: String, courseName: String, blockId: String, blockName: String) + func verticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) + func nextBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) + func prevBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) + func finishVerticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) + func finishVerticalNextSectionClicked(courseId: String, courseName: String, blockId: String, blockName: String) + func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) + func courseOutlineCourseTabClicked(courseId: String, courseName: String) + func courseOutlineVideosTabClicked(courseId: String, courseName: String) + func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) + func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) +} + +#if DEBUG +class CourseAnalyticsMock: CourseAnalytics { + public func courseEnrollClicked(courseId: String, courseName: String) {} + public func courseEnrollSuccess(courseId: String, courseName: String) {} + public func viewCourseClicked(courseId: String, courseName: String) {} + public func resumeCourseTapped(courseId: String, courseName: String, blockId: String) {} + public func sequentialClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} + public func verticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} + public func nextBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} + public func prevBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} + public func finishVerticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} + public func finishVerticalNextSectionClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} + public func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) {} + public func courseOutlineCourseTabClicked(courseId: String, courseName: String) {} + public func courseOutlineVideosTabClicked(courseId: String, courseName: String) {} + public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) {} + public func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) {} +} +#endif diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index 83e1181ba..b6e2832f3 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -10,32 +10,56 @@ import Core public protocol CourseRouter: BaseRouter { - func showCourseScreens(courseID: String, - isActive: Bool?, - courseStart: Date?, - courseEnd: Date?, - enrollmentStart: Date?, - enrollmentEnd: Date?, - title: String) + func showCourseScreens( + courseID: String, + isActive: Bool?, + courseStart: Date?, + courseEnd: Date?, + enrollmentStart: Date?, + enrollmentEnd: Date?, + title: String + ) - func showCourseUnit(blockId: String, - courseID: String, - sectionName: String, - blocks: [CourseBlock]) + func showCourseUnit( + courseName: String, + id: String, + blockId: String, + courseID: String, + sectionName: String, + verticalIndex: Int, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) - func showCourseVerticalView(title: String, - verticals: [CourseVertical]) + func replaceCourseUnit( + id: String, + courseName: String, + blockId: String, + courseID: String, + sectionName: String, + verticalIndex: Int, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) - func showCourseBlocksView(title: String, - blocks: [CourseBlock]) + func showCourseVerticalView( + id: String, + courseID: String, + courseName: String, + title: String, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) - func showCourseVerticalAndBlocksView(verticals: (String, [CourseVertical]), - blocks: (String, [CourseBlock])) - - func showHandoutsUpdatesView(handouts: String?, - announcements: [CourseUpdate]?, - router: Course.CourseRouter, - cssInjector: CSSInjector) + func showHandoutsUpdatesView( + handouts: String?, + announcements: [CourseUpdate]?, + router: Course.CourseRouter, + cssInjector: CSSInjector + ) } // Mark - For testing and SwiftUI preview @@ -44,32 +68,56 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { public override init() {} - public func showCourseScreens(courseID: String, - isActive: Bool?, - courseStart: Date?, - courseEnd: Date?, - enrollmentStart: Date?, - enrollmentEnd: Date?, - title: String) {} - - public func showCourseUnit(blockId: String, - courseID: String, - sectionName: String, - blocks: [CourseBlock]) {} + public func showCourseScreens( + courseID: String, + isActive: Bool?, + courseStart: Date?, + courseEnd: Date?, + enrollmentStart: Date?, + enrollmentEnd: Date?, + title: String + ) {} - public func showCourseVerticalView(title: String, - verticals: [CourseVertical]) {} + public func showCourseUnit( + courseName: String, + id: String, + blockId: String, + courseID: String, + sectionName: String, + verticalIndex: Int, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) {} - public func showCourseBlocksView(title: String, - blocks: [CourseBlock]) {} + public func replaceCourseUnit( + id: String, + courseName: String, + blockId: String, + courseID: String, + sectionName: String, + verticalIndex: Int, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) {} - public func showCourseVerticalAndBlocksView(verticals: (String, [CourseVertical]), - blocks: (String, [CourseBlock])) {} + public func showCourseVerticalView( + id: String, + courseID: String, + courseName: String, + title: String, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) {} - public func showHandoutsUpdatesView(handouts: String?, - announcements: [CourseUpdate]?, - router: Course.CourseRouter, - cssInjector: CSSInjector) {} + public func showHandoutsUpdatesView( + handouts: String?, + announcements: [CourseUpdate]?, + router: Course.CourseRouter, + cssInjector: CSSInjector + ) {} } #endif diff --git a/Course/Course/Presentation/Details/CourseDetailsView.swift b/Course/Course/Presentation/Details/CourseDetailsView.swift index 77457a601..b7556292e 100644 --- a/Course/Course/Presentation/Details/CourseDetailsView.swift +++ b/Course/Course/Presentation/Details/CourseDetailsView.swift @@ -65,7 +65,7 @@ public struct CourseDetailsView: View { // MARK: - iPad if idiom == .pad && viewModel.isHorisontal { - HStack { + HStack(alignment: .top) { VStack(alignment: .leading) { // MARK: - Title and description @@ -82,6 +82,7 @@ public struct CourseDetailsView: View { CourseBannerView( courseDetails: courseDetails, proxy: proxy, + isHorisontal: viewModel.isHorisontal, onPlayButtonTap: { [weak viewModel] in viewModel?.showCourseVideo() } @@ -95,28 +96,29 @@ public struct CourseDetailsView: View { } } else { // MARK: - iPhone - VStack { + VStack(alignment: .leading) { // MARK: - Course Banner CourseBannerView( courseDetails: courseDetails, proxy: proxy, + isHorisontal: viewModel.isHorisontal, onPlayButtonTap: { [weak viewModel] in viewModel?.showCourseVideo() }) }.aspectRatio(CGSize(width: 16, height: 8.5), contentMode: .fill) - .frame(maxHeight: 250) +// .frame(maxHeight: 250) .cornerRadius(12) .padding(.horizontal, 6) .padding(.top, 7) .fixedSize(horizontal: false, vertical: true) + // MARK: - Title and description + CourseTitleView(courseDetails: courseDetails) + // MARK: - Course state button CourseStateView(title: title, courseDetails: courseDetails, viewModel: viewModel) - - // MARK: - Title and description - CourseTitleView(courseDetails: courseDetails) } // MARK: - HTML Embed @@ -202,7 +204,6 @@ private struct CourseStateView: View { } }) .padding(16) - .frame(maxWidth: .infinity) case .enrollClose: Text(CourseLocalization.Details.enrollmentDateIsOver) .multilineTextAlignment(.center) @@ -211,6 +212,8 @@ private struct CourseStateView: View { .padding(.vertical, 24) case .alreadyEnrolled: StyledButton(CourseLocalization.Details.viewCourse, action: { + viewModel.viewCourseClicked(courseId: courseDetails.courseID, + courseName: courseDetails.courseTitle) viewModel.router.showCourseScreens( courseID: courseDetails.courseID, isActive: nil, @@ -262,6 +265,7 @@ private struct CourseTitleView: View { private struct CourseBannerView: View { @State private var animate = false + private var isHorisontal: Bool private let courseDetails: CourseDetails private let idiom: UIUserInterfaceIdiom private let proxy: GeometryProxy @@ -269,8 +273,10 @@ private struct CourseBannerView: View { init(courseDetails: CourseDetails, proxy: GeometryProxy, + isHorisontal: Bool, onPlayButtonTap: @escaping () -> Void) { self.courseDetails = courseDetails + self.isHorisontal = isHorisontal self.idiom = UIDevice.current.userInterfaceIdiom self.proxy = proxy self.onPlayButtonTap = onPlayButtonTap @@ -278,19 +284,36 @@ private struct CourseBannerView: View { var body: some View { ZStack(alignment: .center) { - KFImage(URL(string: courseDetails.courseBannerURL)) - .onFailureImage(CoreAssets.noCourseImage.image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: idiom == .pad ? 312 : proxy.size.width - 12) - .opacity(animate ? 1 : 0) - .onAppear { - withAnimation(.linear(duration: 0.5)) { - animate = true + if !isHorisontal { + KFImage(URL(string: courseDetails.courseBannerURL)) + .onFailureImage(CoreAssets.noCourseImage.image) + .resizable() + .aspectRatio(16/9, contentMode: .fill) + .frame(width: idiom == .pad ? nil : proxy.size.width - 12) + .opacity(animate ? 1 : 0) + .onAppear { + withAnimation(.linear(duration: 0.5)) { + animate = true + } + } + if courseDetails.courseVideoURL != nil { + PlayButton(action: onPlayButtonTap) + } + } else { + KFImage(URL(string: courseDetails.courseBannerURL)) + .onFailureImage(CoreAssets.noCourseImage.image) + .resizable() + .aspectRatio(16/9, contentMode: .fill) + .frame(width: idiom == .pad ? 312 : proxy.size.width - 12) + .opacity(animate ? 1 : 0) + .onAppear { + withAnimation(.linear(duration: 0.5)) { + animate = true + } } + if courseDetails.courseVideoURL != nil { + PlayButton(action: onPlayButtonTap) } - if courseDetails.courseVideoURL != nil { - PlayButton(action: onPlayButtonTap) } } } @@ -303,6 +326,7 @@ struct CourseDetailsView_Previews: PreviewProvider { let vm = CourseDetailsViewModel( interactor: CourseInteractor.mock, router: CourseRouterMock(), + analytics: CourseAnalyticsMock(), config: ConfigMock(), cssInjector: CSSInjectorMock(), connectivity: Connectivity() diff --git a/Course/Course/Presentation/Details/CourseDetailsViewModel.swift b/Course/Course/Presentation/Details/CourseDetailsViewModel.swift index 6f3e7c8d2..6b1e6d747 100644 --- a/Course/Course/Presentation/Details/CourseDetailsViewModel.swift +++ b/Course/Course/Presentation/Details/CourseDetailsViewModel.swift @@ -30,18 +30,23 @@ public class CourseDetailsViewModel: ObservableObject { } private let interactor: CourseInteractorProtocol + private let analytics: CourseAnalytics let router: CourseRouter let config: Config let cssInjector: CSSInjector - public let connectivity: ConnectivityProtocol + let connectivity: ConnectivityProtocol - public init(interactor: CourseInteractorProtocol, - router: CourseRouter, - config: Config, - cssInjector: CSSInjector, - connectivity: ConnectivityProtocol) { + public init( + interactor: CourseInteractorProtocol, + router: CourseRouter, + analytics: CourseAnalytics, + config: Config, + cssInjector: CSSInjector, + connectivity: ConnectivityProtocol + ) { self.interactor = interactor self.router = router + self.analytics = analytics self.config = config self.cssInjector = cssInjector self.connectivity = connectivity @@ -56,7 +61,7 @@ public class CourseDetailsViewModel: ObservableObject { if let isEnrolled = courseDetails?.isEnrolled { self.courseDetails?.isEnrolled = isEnrolled } - + isShowProgress = false } else { courseDetails = try await interactor.getCourseDetailsOffline(courseID: courseID) @@ -98,11 +103,17 @@ public class CourseDetailsViewModel: ObservableObject { guard let url = URL(string: httpsURL) else { return } UIApplication.shared.open(url) } - + + func viewCourseClicked(courseId: String, courseName: String) { + analytics.viewCourseClicked(courseId: courseId, courseName: courseName) + } + @MainActor func enrollToCourse(id: String) async { do { + analytics.courseEnrollClicked(courseId: id, courseName: courseDetails?.courseTitle ?? "") _ = try await interactor.enrollToCourse(courseID: id) + analytics.courseEnrollSuccess(courseId: id, courseName: courseDetails?.courseTitle ?? "") courseDetails?.isEnrolled = true NotificationCenter.default.post(name: .onCourseEnrolled, object: id) } catch let error { diff --git a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift index 179e6b55f..d16f4d8e5 100644 --- a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift @@ -20,7 +20,12 @@ public struct HandoutsUpdatesDetailView: View { private let title: String @State private var height: [Int: CGFloat] = [:] - public init(handouts: String?, announcements: [CourseUpdate]?, router: CourseRouter, cssInjector: CSSInjector) { + public init( + handouts: String?, + announcements: [CourseUpdate]?, + router: CourseRouter, + cssInjector: CSSInjector + ) { if handouts != nil { self.title = CourseLocalization.HandoutsCellHandouts.title } else { @@ -66,19 +71,23 @@ public struct HandoutsUpdatesDetailView: View { GeometryReader { reader in // MARK: - Page name VStack(alignment: .center) { - NavigationBar(title: title, - leftButtonAction: { router.back() }) + NavigationBar( + title: title, + leftButtonAction: { router.back() } + ) // 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) + 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 { @@ -89,15 +98,19 @@ public struct HandoutsUpdatesDetailView: View { 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]) + 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() } @@ -124,6 +137,7 @@ public struct HandoutsUpdatesDetailView: View { } #if DEBUG +// swiftlint:disable all struct HandoutsUpdatesDetailView_Previews: PreviewProvider { static var previews: some View { @@ -135,13 +149,24 @@ Hi! Welcome to the demonstration course. We built this to help you become more f Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollitia animi, id est laborum. """ - HandoutsUpdatesDetailView(handouts: nil, - announcements: [CourseUpdate(id: 1, date: "1 march", - content: handouts, status: "done"), - CourseUpdate(id: 2, date: "3 april", - content: loremIpsumHtml, status: "nice")], - router: CourseRouterMock(), - cssInjector: CSSInjectorMock()) + HandoutsUpdatesDetailView( + handouts: nil, + announcements: [ + CourseUpdate( + id: 1, + date: "1 march", + content: handouts, + status: "done" + ), + CourseUpdate( + id: 2, + date: "3 april", + content: loremIpsumHtml, + status: "nice")], + router: CourseRouterMock(), + cssInjector: CSSInjectorMock() + ) } } +// swiftlint:enable all #endif diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index e876b9dd9..bbc0752e9 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -12,10 +12,13 @@ struct HandoutsView: View { private let courseID: String - @ObservedObject private var viewModel: HandoutsViewModel + @ObservedObject + private var viewModel: HandoutsViewModel - public init(courseID: String, - viewModel: HandoutsViewModel) { + public init( + courseID: String, + viewModel: HandoutsViewModel + ) { self.courseID = courseID self.viewModel = viewModel } @@ -60,13 +63,15 @@ struct HandoutsView: View { } // MARK: - Offline mode SnackBar - OfflineSnackBarView(connectivity: viewModel.connectivity, - reloadAction: { - Task { - await viewModel.getHandouts(courseID: courseID) - await viewModel.getUpdates(courseID: courseID) + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: { + Task { + await viewModel.getHandouts(courseID: courseID) + await viewModel.getUpdates(courseID: courseID) + } } - }) + ) // MARK: - Error Alert if viewModel.showError { diff --git a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift index 2759406cb..2055e4adb 100644 --- a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift +++ b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift @@ -25,15 +25,17 @@ public class HandoutsViewModel: ObservableObject { } private let interactor: CourseInteractorProtocol - public let cssInjector: CSSInjector - public let router: CourseRouter - public let connectivity: ConnectivityProtocol + let cssInjector: CSSInjector + let router: CourseRouter + let connectivity: ConnectivityProtocol - public init(interactor: CourseInteractorProtocol, - router: CourseRouter, - cssInjector: CSSInjector, - connectivity: ConnectivityProtocol, - courseID: String) { + public init( + interactor: CourseInteractorProtocol, + router: CourseRouter, + cssInjector: CSSInjector, + connectivity: ConnectivityProtocol, + courseID: String + ) { self.interactor = interactor self.router = router self.cssInjector = cssInjector diff --git a/Course/Course/Presentation/Outline/ContinueWithView.swift b/Course/Course/Presentation/Outline/ContinueWithView.swift new file mode 100644 index 000000000..998ddeca2 --- /dev/null +++ b/Course/Course/Presentation/Outline/ContinueWithView.swift @@ -0,0 +1,153 @@ +// +// ContinueWithView.swift +// Course +// +// Created by  Stepanok Ivan on 29.05.2023. +// + +import SwiftUI +import Core + +struct ContinueWith { + let chapterIndex: Int + let sequentialIndex: Int + let verticalIndex: Int +} + +struct ContinueWithView: View { + let data: ContinueWith + let courseStructure: CourseStructure + let router: CourseRouter + let analytics: CourseAnalytics + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + init(data: ContinueWith, courseStructure: CourseStructure, router: CourseRouter, analytics: CourseAnalytics) { + self.data = data + self.courseStructure = courseStructure + self.router = router + self.analytics = analytics + } + + var body: some View { + VStack(alignment: .leading) { + let chapter = courseStructure.childs[data.chapterIndex] + if let vertical = chapter.childs[data.sequentialIndex].childs.first { + if idiom == .pad { + HStack(alignment: .top) { + VStack(alignment: .leading) { + ContinueTitle(vertical: vertical) + }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) + Spacer() + UnitButtonView(type: .continueLesson, action: { + analytics.resumeCourseTapped(courseId: courseStructure.courseID, + courseName: courseStructure.displayName, + blockId: chapter.childs[data.sequentialIndex] + .childs[data.verticalIndex].blockId) + router.showCourseVerticalView(id: courseStructure.id, + courseID: courseStructure.courseID, + courseName: courseStructure.displayName, + title: chapter.childs[data.sequentialIndex].displayName, + chapters: courseStructure.childs, + chapterIndex: data.chapterIndex, + sequentialIndex: data.sequentialIndex) + }).frame(width: 200) + } .padding(.horizontal, 24) + .padding(.top, 32) + } else { + VStack(alignment: .leading) { + ContinueTitle(vertical: vertical) + .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + } + UnitButtonView(type: .continueLesson, action: { + analytics.resumeCourseTapped(courseId: courseStructure.courseID, + courseName: courseStructure.displayName, + blockId: chapter.childs[data.sequentialIndex] + .childs[data.verticalIndex].blockId) + router.showCourseVerticalView(id: courseStructure.id, + courseID: courseStructure.courseID, + courseName: courseStructure.displayName, + title: chapter.childs[data.sequentialIndex].displayName, + chapters: courseStructure.childs, + chapterIndex: data.chapterIndex, + sequentialIndex: data.sequentialIndex) + }) + } + + } + } .padding(.horizontal, 24) + .padding(.top, 32) + } +} + +private struct ContinueTitle: View { + + let vertical: CourseVertical + + var body: some View { + Text(CoreLocalization.Courseware.continueWith) + .font(Theme.Fonts.labelMedium) + .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + HStack { + vertical.type.image + Text(vertical.displayName) + .multilineTextAlignment(.leading) + .font(Theme.Fonts.titleMedium) + .multilineTextAlignment(.leading) + } + } + +} + +#if DEBUG +struct ContinueWithView_Previews: PreviewProvider { + static var previews: some View { + + let childs = [ + CourseChapter( + blockId: "123", + id: "123", + displayName: "Continue lesson", + type: .chapter, + childs: [ + CourseSequential( + blockId: "1", + id: "1", + displayName: "Name", + type: .sequential, + completion: 0, + childs: [ + CourseVertical( + blockId: "1", + id: "1", + displayName: "Vertical", + type: .vertical, + completion: 0, + childs: [ + CourseBlock( + blockId: "2", id: "2", + graded: true, + completion: 0, + type: .html, + displayName: "Continue lesson", + studentUrl: "") + ])])]) + ] + + ContinueWithView(data: ContinueWith(chapterIndex: 0, sequentialIndex: 0, verticalIndex: 0), + courseStructure: CourseStructure(courseID: "v1-course", + id: "123", + graded: true, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "Namaste", + childs: childs, + media: DataLayer.CourseMedia.init(image: + .init(raw: "", small: "", large: "")), + certificate: nil), + router: CourseRouterMock(), + analytics: CourseAnalyticsMock()) + } +} +#endif diff --git a/Course/Course/Presentation/Outline/CourseBlocksView.swift b/Course/Course/Presentation/Outline/CourseBlocksView.swift deleted file mode 100644 index 0e5175fcc..000000000 --- a/Course/Course/Presentation/Outline/CourseBlocksView.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// CourseBlocksView.swift -// Course -// -// Created by  Stepanok Ivan on 04.02.2023. -// - -import SwiftUI - -import Core -import Kingfisher - -public struct CourseBlocksView: View { - - private var title: String - @ObservedObject - private var viewModel: CourseBlocksViewModel - - public init(title: String, - viewModel: CourseBlocksViewModel) { - self.title = title - self.viewModel = viewModel - } - - public var body: some View { - ZStack(alignment: .top) { - - // MARK: - Page name - VStack(alignment: .center) { - NavigationBar(title: title, - leftButtonAction: { viewModel.router.back() }) - - // MARK: - Page Body - ScrollView { - VStack(alignment: .leading) { - // MARK: - Lessons list - ForEach(viewModel.blocks, id: \.id) { block in - let index = viewModel.blocks.firstIndex(where: { $0.id == block.id }) - Button(action: { - viewModel.router.showCourseUnit(blockId: block.id, - courseID: block.blockId, - sectionName: title, - blocks: viewModel.blocks) - }, label: { - HStack { - Group { - if block.completion == 1 { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.accentColor) - } else { - block.type.image - } - Text(block.displayName) - .multilineTextAlignment(.leading) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) - Spacer() - if let state = viewModel.downloadState[block.id] { - switch state { - case .available: - DownloadAvailableView() - .onTapGesture { - viewModel.onDownloadViewTap(blockId: block.id, state: state) - } - .onForeground { - viewModel.onForeground() - } - case .downloading: - DownloadProgressView() - .onTapGesture { - viewModel.onDownloadViewTap(blockId: block.id, state: state) - } - .onBackground { - viewModel.onBackground() - } - case .finished: - DownloadFinishedView() - .onTapGesture { - viewModel.onDownloadViewTap(blockId: block.id, state: state) - } - } - } - Image(systemName: "chevron.right") - .padding(.vertical, 8) - } - }).padding(.horizontal, 36) - .padding(.vertical, 14) - if index != viewModel.blocks.count - 1 { - Divider() - .frame(height: 1) - .overlay(CoreAssets.cardViewStroke.swiftUIColor) - .padding(.horizontal, 24) - } - } - } - Spacer(minLength: 84) - }.frameLimit() - .onRightSwipeGesture { - viewModel.router.back() - } - } - - // MARK: - Offline mode SnackBar - OfflineSnackBarView(connectivity: viewModel.connectivity, - reloadAction: { }) - - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) - } - .padding(.bottom, viewModel.connectivity.isInternetAvaliable - ? 0 : OfflineSnackBarView.height) - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil - } - } - } - - } - .background( - CoreAssets.background.swiftUIColor - .ignoresSafeArea() - ) - } -} - -#if DEBUG -struct CourseBlocksView_Previews: PreviewProvider { - static var previews: some View { - let blocks: [CourseBlock] = [ - CourseBlock( - blockId: "block_1", - id: "1", - topicId: nil, - graded: true, - completion: 0, - type: .html, - displayName: "HTML Block", - studentUrl: "", - videoUrl: nil, - youTubeUrl: nil - ), - CourseBlock( - blockId: "block_2", - id: "2", - topicId: nil, - graded: true, - completion: 0, - type: .problem, - displayName: "Problem Block", - studentUrl: "", - videoUrl: nil, - youTubeUrl: nil - ), - CourseBlock( - blockId: "block_3", - id: "3", - topicId: nil, - graded: true, - completion: 1, - type: .problem, - displayName: "Completed Problem Block", - studentUrl: "", - videoUrl: nil, - youTubeUrl: nil - ), - CourseBlock( - blockId: "block_4", - id: "4", - topicId: nil, - graded: true, - completion: 0, - type: .video, - displayName: "Video Block", - studentUrl: "", - videoUrl: "some_data", - youTubeUrl: nil - ) - ] - - let viewModel = CourseBlocksViewModel(blocks: blocks, - manager: DownloadManagerMock(), - router: CourseRouterMock(), - connectivity: Connectivity()) - - return Group { - CourseBlocksView( - title: "Course title", - viewModel: viewModel - ) - .preferredColorScheme(.light) - .previewDisplayName("CourseBlocksView Light") - - CourseBlocksView( - title: "Course title", - viewModel: viewModel - ) - .preferredColorScheme(.dark) - .previewDisplayName("CourseBlocksView Dark") - } - - } -} -#endif diff --git a/Course/Course/Presentation/Outline/CourseBlocksViewModel.swift b/Course/Course/Presentation/Outline/CourseBlocksViewModel.swift deleted file mode 100644 index 9dba81c5a..000000000 --- a/Course/Course/Presentation/Outline/CourseBlocksViewModel.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// CourseBlocksViewModel.swift -// Course -// -// Created by  Stepanok Ivan on 14.03.2023. -// - -import SwiftUI -import Core -import Combine - -public class CourseBlocksViewModel: BaseCourseViewModel { - let router: CourseRouter - let connectivity: ConnectivityProtocol - @Published var blocks: [CourseBlock] - @Published var downloadState: [String: DownloadViewState] = [:] - @Published var showError: Bool = false - - var errorMessage: String? { - didSet { - withAnimation { - showError = errorMessage != nil - } - } - } - - public init(blocks: [CourseBlock], - manager: DownloadManagerProtocol, - router: CourseRouter, - connectivity: ConnectivityProtocol) { - self.blocks = blocks - self.router = router - self.connectivity = connectivity - - super.init(manager: manager) - - manager.publisher() - .sink(receiveValue: { [weak self] _ in - guard let self else { return } - DispatchQueue.main.async { - self.setDownloadsStates() - } - }) - .store(in: &cancellables) - - setDownloadsStates() - } - - func onDownloadViewTap(blockId: String, state: DownloadViewState) { - if let block = blocks.first(where: { $0.id == blockId }) { - do { - switch state { - case .available: - try manager.addToDownloadQueue(blocks: [block]) - downloadState[block.id] = .downloading - case .downloading: - try manager.cancelDownloading(blocks: [block]) - downloadState[block.id] = .available - case .finished: - manager.deleteFile(blocks: [block]) - downloadState[block.id] = .available - } - } catch let error { - if error is NoWiFiError { - errorMessage = CoreLocalization.Error.wifi - } - } - } - } - - private func setDownloadsStates() { - let downloads = manager.getAllDownloads() - var states: [String: DownloadViewState] = [:] - for block in blocks where block.isDownloadable { - if let download = downloads.first(where: { $0.id == block.id }) { - switch download.state { - case .waiting, .inProgress: - states[download.id] = .downloading - case .paused: - states[download.id] = .available - case .finished: - states[download.id] = .finished - } - } else { - states[block.id] = .available - } - } - downloadState = states - } -} diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 3a26579b6..b0f06ef29 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -17,6 +17,7 @@ public struct CourseOutlineView: View { private let isVideo: Bool @State private var openCertificateView: Bool = false + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } public init( viewModel: CourseContainerViewModel, @@ -37,7 +38,7 @@ public struct CourseOutlineView: View { GeometryReader { proxy in VStack(alignment: .center) { NavigationBar(title: title, - leftButtonAction: { viewModel.router.back() }) + leftButtonAction: { viewModel.router.back() }) // MARK: - Page Body RefreshableScrollViewCompat(action: { @@ -89,17 +90,25 @@ public struct CourseOutlineView: View { .fixedSize(horizontal: false, vertical: true) if !isVideo { - if let sequential = viewModel.returnCourseSequential { - ContinueWithView(sequential: sequential, viewModel: viewModel) + if let continueWith = viewModel.continueWith, + let courseStructure = viewModel.courseStructure { + ContinueWithView( + data: continueWith, + courseStructure: courseStructure, + router: viewModel.router, + analytics: viewModel.analytics + ) } } - if let courseStructure = isVideo ? viewModel.courseVideosStructure : viewModel.courseStructure { + if let courseStructure = isVideo + ? viewModel.courseVideosStructure + : viewModel.courseStructure { // MARK: - Sections list let chapters = courseStructure.childs ForEach(chapters, id: \.id) { chapter in - let index = chapters.firstIndex(where: {$0.id == chapter.id }) + let chapterIndex = chapters.firstIndex(where: { $0.id == chapter.id }) Text(chapter.displayName) .font(Theme.Fonts.titleMedium) .multilineTextAlignment(.leading) @@ -107,16 +116,38 @@ public struct CourseOutlineView: View { .padding(.horizontal, 24) .padding(.top, 40) ForEach(chapter.childs, id: \.id) { child in + let sequentialIndex = chapter.childs.firstIndex(where: { $0.id == child.id }) VStack(alignment: .leading) { Button(action: { - viewModel.router.showCourseVerticalView(title: child.displayName, - verticals: child.childs) + if let chapterIndex, let sequentialIndex { + viewModel.analytics + .sequentialClicked(courseId: courseID, + courseName: self.title, + blockId: child.blockId, + blockName: child.displayName) + viewModel.router.showCourseVerticalView( + id: courseID, + courseID: courseStructure.courseID, + courseName: viewModel.courseStructure?.displayName ?? "", + title: child.displayName, + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + } }, label: { Group { child.type.image Text(child.displayName) .font(Theme.Fonts.titleMedium) .multilineTextAlignment(.leading) + .lineLimit(1) + .frame( + maxWidth: idiom == .pad + ? proxy.size.width * 0.5 + : proxy.size.width * 0.6, + alignment: .leading + ) }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) Spacer() if let state = viewModel.downloadState[child.id] { @@ -160,7 +191,7 @@ public struct CourseOutlineView: View { .foregroundColor(CoreAssets.accentColor.swiftUIColor) }).padding(.horizontal, 36) .padding(.vertical, 20) - if index != chapters.count - 1 { + if chapterIndex != chapters.count - 1 { Divider() .frame(height: 1) .overlay(CoreAssets.cardViewStroke.swiftUIColor) @@ -185,10 +216,12 @@ public struct CourseOutlineView: View { } // MARK: - Offline mode SnackBar - OfflineSnackBarView(connectivity: viewModel.connectivity, - reloadAction: { - await viewModel.getCourseBlocks(courseID: courseID, withProgress: isIOS14) - }) + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: { + await viewModel.getCourseBlocks(courseID: courseID, withProgress: isIOS14) + } + ) // MARK: - Error Alert if viewModel.showError { @@ -222,51 +255,21 @@ public struct CourseOutlineView: View { } } -struct ContinueWithView: View { - let sequential: CourseSequential - let viewModel: CourseContainerViewModel - - var body: some View { - VStack(alignment: .leading) { - if let vertical = sequential.childs.first { - Text(CourseLocalization.Courseware.continueWith) - .font(Theme.Fonts.labelMedium) - .foregroundColor(CoreAssets.textSecondary.swiftUIColor) - HStack { - vertical.type.image - Text(vertical.displayName) - .multilineTextAlignment(.leading) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) - UnitButtonView(type: .continueLesson, action: { -// viewModel.router.showCourseBlocksView(title: vertical.displayName, -// blocks: vertical.childs) -// viewModel.router.showCourseVerticalView(title: sequential.displayName, -// verticals: sequential.childs) - viewModel.router.showCourseVerticalAndBlocksView(verticals: (sequential.displayName, sequential.childs), - blocks: (vertical.displayName, vertical.childs)) - }) - } - } - .padding(.horizontal, 24) - .padding(.top, 32) - } -} - #if DEBUG struct CourseOutlineView_Previews: PreviewProvider { static var previews: some View { let viewModel = CourseContainerViewModel( interactor: CourseInteractor.mock, + authInteractor: AuthInteractor.mock, router: CourseRouterMock(), + analytics: CourseAnalyticsMock(), config: ConfigMock(), connectivity: Connectivity(), manager: DownloadManagerMock(), - isActive: nil, + isActive: true, courseStart: Date(), courseEnd: nil, - enrollmentStart: nil, + enrollmentStart: Date(), enrollmentEnd: nil ) Task { diff --git a/Course/Course/Presentation/Outline/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVerticalView.swift index be44690b6..cf72ea53a 100644 --- a/Course/Course/Presentation/Outline/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVerticalView.swift @@ -13,14 +13,24 @@ import Kingfisher public struct CourseVerticalView: View { private var title: String + private var courseName: String + private var courseID: String + private let id: String @ObservedObject private var viewModel: CourseVerticalViewModel + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } public init( title: String, + courseName: String, + courseID: String, + id: String, viewModel: CourseVerticalViewModel ) { self.title = title + self.courseName = courseName + self.courseID = courseID + self.id = id self.viewModel = viewModel } @@ -28,82 +38,112 @@ public struct CourseVerticalView: View { ZStack(alignment: .top) { VStack(alignment: .center) { NavigationBar(title: title, - leftButtonAction: { viewModel.router.back() }) + leftButtonAction: { viewModel.router.back() }) // MARK: - Page Body - ScrollView { - VStack(alignment: .leading) { - // MARK: - Lessons list - ForEach(viewModel.verticals, id: \.id) { vertical in - let index = viewModel.verticals.firstIndex(where: {$0.id == vertical.id}) - Button(action: { - viewModel.router.showCourseBlocksView( - title: vertical.displayName, - blocks: vertical.childs - ) - }, label: { - HStack { - Group { - if vertical.completion == 1 { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.accentColor) - } else { - vertical.type.image + 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.analytics.verticalClicked(courseId: courseID, + courseName: courseName, + blockId: vertical.blockId, + blockName: vertical.displayName) + viewModel.router.showCourseUnit(courseName: courseName, + id: id, + blockId: block.id, + courseID: courseID, + sectionName: block.displayName, + verticalIndex: index, + chapters: viewModel.chapters, + chapterIndex: viewModel.chapterIndex, + sequentialIndex: viewModel.sequentialIndex) } - Text(vertical.displayName) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) - Spacer() - if let state = viewModel.downloadState[vertical.id] { - switch state { - case .available: - DownloadAvailableView() - .onTapGesture { - viewModel.onDownloadViewTap(blockId: vertical.id, state: state) + }, label: { + HStack { + Group { + if vertical.completion == 1 { + CoreAssets.finished.swiftUIImage + .renderingMode(.template) + .foregroundColor(.accentColor) + } else { + vertical.type.image } - .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) + 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(CoreAssets.textPrimary.swiftUIColor) + 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(CoreAssets.cardViewStroke.swiftUIColor) + .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(CoreAssets.cardViewStroke.swiftUIColor) - .padding(.horizontal, 24) } } - } - Spacer(minLength: 84) - }.frameLimit() - .onRightSwipeGesture { - viewModel.router.back() - } + Spacer(minLength: 84) + }.frameLimit() + .onRightSwipeGesture { + viewModel.router.back() + } + } } // MARK: - Offline mode SnackBar OfflineSnackBarView(connectivity: viewModel.connectivity, - reloadAction: { }) + reloadAction: { }) // MARK: - Error Alert if viewModel.showError { @@ -131,47 +171,49 @@ public struct CourseVerticalView: View { #if DEBUG struct CourseVerticalView_Previews: PreviewProvider { static var previews: some View { - - let verticals: [CourseVertical] = [ - CourseVertical( - blockId: "block_1", + let chapters = [ + CourseChapter( + blockId: "1", id: "1", - displayName: "Some vertical", - type: .vertical, - completion: 0, - childs: [] - ), - CourseVertical( - blockId: "block_2", - id: "2", - displayName: "Comleted vertical", - type: .vertical, - completion: 1, - childs: [] - ), - CourseVertical( - blockId: "block_3", - id: "3", - displayName: "Another vertical", - type: .vertical, - completion: 0, - childs: [] - ) + displayName: "Chapter 1", + type: .chapter, + childs: [ + CourseSequential( + blockId: "3", + id: "3", + displayName: "Sequential", + type: .sequential, + completion: 1, + childs: [ + CourseVertical( + blockId: "4", + id: "4", + displayName: "Vertical", + type: .vertical, + completion: 0, + childs: []) + ]) + ]) ] - let viewModel = CourseVerticalViewModel(verticals: verticals, - manager: DownloadManagerMock(), - router: CourseRouterMock(), - connectivity: Connectivity()) + let viewModel = CourseVerticalViewModel( + chapters: chapters, + chapterIndex: 0, + sequentialIndex: 0, + manager: DownloadManagerMock(), + router: CourseRouterMock(), + analytics: CourseAnalyticsMock(), + connectivity: Connectivity() + ) return Group { - CourseVerticalView(title: "Course title", viewModel: viewModel) - .preferredColorScheme(.light) - .previewDisplayName("CourseVerticalView Light") + CourseVerticalView(title: "Course title", courseName: "CourseName", courseID: "1", id: "1", viewModel: viewModel) + .preferredColorScheme(.light) + .previewDisplayName("CourseVerticalView Light") - CourseVerticalView(title: "Course title", viewModel: viewModel) - .preferredColorScheme(.dark) - .previewDisplayName("CourseVerticalView Dark") + CourseVerticalView(title: "Course title", courseName: "CourseName", courseID: "1", id: "1", viewModel: viewModel) + .preferredColorScheme(.dark) + .previewDisplayName("CourseVerticalView Dark") } } diff --git a/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift b/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift index 54fc1ca01..793fb7519 100644 --- a/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift +++ b/Course/Course/Presentation/Outline/CourseVerticalViewModel.swift @@ -11,11 +11,15 @@ import Combine public class CourseVerticalViewModel: BaseCourseViewModel { let router: CourseRouter + let analytics: CourseAnalytics let connectivity: ConnectivityProtocol @Published var verticals: [CourseVertical] @Published var downloadState: [String: DownloadViewState] = [:] @Published var showError: Bool = false - + let chapters: [CourseChapter] + let chapterIndex: Int + let sequentialIndex: Int + var errorMessage: String? { didSet { withAnimation { @@ -24,14 +28,22 @@ public class CourseVerticalViewModel: BaseCourseViewModel { } } - public init(verticals: [CourseVertical], - manager: DownloadManagerProtocol, - router: CourseRouter, - connectivity: ConnectivityProtocol) { - self.verticals = verticals + public init( + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int, + manager: DownloadManagerProtocol, + router: CourseRouter, + analytics: CourseAnalytics, + connectivity: ConnectivityProtocol + ) { + self.chapters = chapters + self.chapterIndex = chapterIndex + self.sequentialIndex = sequentialIndex self.router = router + self.analytics = analytics self.connectivity = connectivity - + self.verticals = chapters[chapterIndex].childs[sequentialIndex].childs super.init(manager: manager) manager.publisher() @@ -68,7 +80,7 @@ public class CourseVerticalViewModel: BaseCourseViewModel { } } } - + private func setDownloadsStates() { let downloads = manager.getAllDownloads() var states: [String: DownloadViewState] = [:] diff --git a/Course/Course/Presentation/Unit/CourseNavigationView.swift b/Course/Course/Presentation/Unit/CourseNavigationView.swift index 106a05173..97db77c0d 100644 --- a/Course/Course/Presentation/Unit/CourseNavigationView.swift +++ b/Course/Course/Presentation/Unit/CourseNavigationView.swift @@ -7,78 +7,173 @@ import SwiftUI import Core +import Combine struct CourseNavigationView: View { - @ObservedObject private var viewModel: CourseUnitViewModel + @ObservedObject + private var viewModel: CourseUnitViewModel private let sectionName: String - - init(sectionName: String, viewModel: CourseUnitViewModel) { + private let playerStateSubject: CurrentValueSubject + + init( + sectionName: String, + viewModel: CourseUnitViewModel, + playerStateSubject: CurrentValueSubject + ) { self.viewModel = viewModel self.sectionName = sectionName - + self.playerStateSubject = playerStateSubject } var body: some View { - HStack(alignment: .top, spacing: 24) { - if viewModel.selectedLesson() == viewModel.blocks.first - && viewModel.blocks.count != 1 { - UnitButtonView(type: .first, action: { + HStack(alignment: .top, spacing: 7) { + if viewModel.selectedLesson() == viewModel.verticals[viewModel.verticalIndex].childs.first + && viewModel.verticals[viewModel.verticalIndex].childs.count != 1 { + UnitButtonView(type: .nextBig, action: { + playerStateSubject.send(VideoPlayerState.pause) viewModel.select(move: .next) - self.viewModel.createLessonType() - self.viewModel.killPlayer.toggle() - }) + }).frame(width: 215) } else { - - if viewModel.previousLesson != "" { - UnitButtonView(type: .previous, action: { - viewModel.select(move: .previous) - self.viewModel.createLessonType() - self.viewModel.killPlayer.toggle() - }) - } - if viewModel.nextLesson != "" { - UnitButtonView(type: .next, action: { - viewModel.select(move: .next) - self.viewModel.createLessonType() - self.viewModel.killPlayer.toggle() - }) - } - if viewModel.selectedLesson() == viewModel.blocks.last { - UnitButtonView(type: viewModel.blocks.count == 1 ? .finish : .last, action: { + if viewModel.selectedLesson() == viewModel.verticals[viewModel.verticalIndex].childs.last { + if viewModel.selectedLesson() != viewModel.verticals[viewModel.verticalIndex].childs.first { + UnitButtonView(type: .previous, action: { + playerStateSubject.send(VideoPlayerState.pause) + viewModel.select(move: .previous) + }) + } + UnitButtonView(type: .last, action: { + let sequentials = viewModel.chapters[viewModel.chapterIndex].childs + let verticals = viewModel + .chapters[viewModel.chapterIndex] + .childs[viewModel.sequentialIndex] + .childs + let chapters = viewModel.chapters + let currentVertical = viewModel.verticals[viewModel.verticalIndex] + viewModel.router.presentAlert( alertTitle: CourseLocalization.Courseware.goodWork, alertMessage: (CourseLocalization.Courseware.section - + " " + sectionName + " " + CourseLocalization.Courseware.isFinished), + + currentVertical.displayName + CourseLocalization.Courseware.isFinished), + nextSectionName: { + if viewModel.verticals.count > viewModel.verticalIndex + 1 { + return viewModel.verticals[viewModel.verticalIndex + 1].displayName + } else if sequentials.count > viewModel.sequentialIndex + 1 { + return sequentials[viewModel.sequentialIndex + 1].childs.first?.displayName + } else if chapters.count > viewModel.chapterIndex + 1 { + return chapters[viewModel.chapterIndex + 1].childs.first?.childs.first?.displayName + } else { + return nil + } + }(), action: CourseLocalization.Courseware.backToOutline, image: CoreAssets.goodWork.swiftUIImage, - onCloseTapped: {}, + onCloseTapped: { viewModel.router.dismiss(animated: false) }, okTapped: { + playerStateSubject.send(VideoPlayerState.pause) + playerStateSubject.send(VideoPlayerState.kill) + viewModel.analytics + .finishVerticalBackToOutlineClicked(courseId: viewModel.courseID, + courseName: viewModel.courseName) viewModel.router.dismiss(animated: false) - viewModel.router.removeLastView(controllers: 2) + viewModel.router.back(animated: true) + }, + nextSectionTapped: { + playerStateSubject.send(VideoPlayerState.pause) + playerStateSubject.send(VideoPlayerState.kill) + viewModel.router.dismiss(animated: false) + + let chapterIndex: Int + let sequentialIndex: Int + let verticalIndex: Int + + // Switch to the next Vertical + if verticals.count - 1 > viewModel.verticalIndex { + chapterIndex = viewModel.chapterIndex + sequentialIndex = viewModel.sequentialIndex + verticalIndex = viewModel.verticalIndex + 1 + // Switch to the next Sequential + } else if sequentials.count - 1 > viewModel.sequentialIndex { + chapterIndex = viewModel.chapterIndex + sequentialIndex = viewModel.sequentialIndex + 1 + verticalIndex = 0 + } else { + // Switch to the next Chapter + chapterIndex = viewModel.chapterIndex + 1 + sequentialIndex = 0 + verticalIndex = 0 + } + + viewModel.analytics + .finishVerticalNextSectionClicked( + courseId: viewModel.courseID, + courseName: viewModel.courseName, + blockId: viewModel.selectedLesson().blockId, + blockName: viewModel.selectedLesson().displayName + ) + + viewModel.router.replaceCourseUnit( + id: viewModel.id, + courseName: viewModel.courseName, + blockId: viewModel.lessonID, + courseID: viewModel.courseID, + sectionName: viewModel.selectedLesson().displayName, + verticalIndex: verticalIndex, + chapters: viewModel.chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex) } ) + viewModel.analytics.finishVerticalClicked( + courseId: viewModel.courseID, + courseName: viewModel.courseName, + blockId: viewModel.selectedLesson().blockId, + blockName: viewModel.selectedLesson().displayName + ) + }) + } else { + if viewModel.selectedLesson() != viewModel.verticals[viewModel.verticalIndex].childs.first { + UnitButtonView(type: .previous, action: { + playerStateSubject.send(VideoPlayerState.pause) + viewModel.select(move: .previous) + }) + } + + UnitButtonView(type: .next, action: { + playerStateSubject.send(VideoPlayerState.pause) + viewModel.select(move: .next) }) } } }.frame(minWidth: 0, maxWidth: .infinity) .padding(.horizontal, 24) - } } #if DEBUG struct CourseNavigationView_Previews: PreviewProvider { static var previews: some View { - let viewModel = CourseUnitViewModel(lessonID: "1", - courseID: "1", - blocks: [], - interactor: CourseInteractor.mock, - router: CourseRouterMock(), - connectivity: Connectivity(), - manager: DownloadManagerMock()) + let viewModel = CourseUnitViewModel( + lessonID: "1", + courseID: "1", + id: "1", + courseName: "Name", + chapters: [], + chapterIndex: 1, + sequentialIndex: 1, + verticalIndex: 1, + interactor: CourseInteractor.mock, + router: CourseRouterMock(), + analytics: CourseAnalyticsMock(), + connectivity: Connectivity(), + manager: DownloadManagerMock() + ) - CourseNavigationView(sectionName: "Name", viewModel: viewModel) + CourseNavigationView( + sectionName: "Name", + viewModel: viewModel, + playerStateSubject: CurrentValueSubject(nil) + ) } } #endif diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 0dbf62682..d8b06640e 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -10,6 +10,7 @@ import SwiftUI import Core import Discussion import Swinject +import Combine public struct CourseUnitView: View { @@ -22,225 +23,315 @@ public struct CourseUnitView: View { } } } + @State var offsetView: CGFloat = 0 + @State var showDiscussion: Bool = false + private let sectionName: String + public let playerStateSubject = CurrentValueSubject(nil) public init(viewModel: CourseUnitViewModel, sectionName: String) { self.viewModel = viewModel self.sectionName = sectionName viewModel.loadIndex() - viewModel.createLessonType() viewModel.nextTitles() } public var body: some View { ZStack(alignment: .top) { - - // MARK: - Page name - VStack(alignment: .center) { - NavigationBar(title: "", - leftButtonAction: { - viewModel.router.back() - self.viewModel.killPlayer.toggle() - }) - - // MARK: - Page Body - VStack { - ZStack(alignment: .top) { - VStack(alignment: .leading) { - if viewModel.connectivity.isInternetAvaliable - || viewModel.lessonType != .video(videoUrl: "", blockID: "") { - switch viewModel.lessonType { - case let .youtube(url, blockID): - VStack(alignment: .leading) { - Text(viewModel.selectedLesson().displayName) - .font(Theme.Fonts.titleLarge) - .padding(.horizontal, 24) - YouTubeVideoPlayer(url: url, - blockID: blockID, - courseID: viewModel.courseID, - languages: viewModel.languages()) - Spacer() - - }.background(CoreAssets.background.swiftUIColor) - case let .video(encodedUrl, blockID): - Text(viewModel.selectedLesson().displayName) - .font(Theme.Fonts.titleLarge) - .padding(.horizontal, 24) - EncodedVideoPlayer( - url: viewModel.urlForVideoFileOrFallback(blockId: blockID, url: encodedUrl), - blockID: blockID, - courseID: viewModel.courseID, - languages: viewModel.languages(), - killPlayer: $viewModel.killPlayer - ) - Spacer() - case .web(let url): - VStack { - WebUnitView(url: url, viewModel: Container.shared.resolve(WebUnitViewModel.self)!) - }.background(Color.white) - .contrast(1.08) - .padding(.horizontal, -12) - .roundedBackground(strokeColor: .clear, maxIpadWidth: .infinity) - - case .unknown(let url): - Spacer() + // MARK: - Page Body + ZStack(alignment: .bottom) { + GeometryReader { reader in + VStack(spacing: 0) { + 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 VStack(spacing: 0) { - CoreAssets.notAvaliable.swiftUIImage - Text(CourseLocalization.NotAvaliable.title) - .font(Theme.Fonts.titleLarge) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.top, 40) - Text(CourseLocalization.NotAvaliable.description) - .font(Theme.Fonts.bodyLarge) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.top, 12) - StyledButton(CourseLocalization.NotAvaliable.button, action: { - if let url = URL(string: url) { - UIApplication.shared.open(url) + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { + 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) + + // 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) + // MARK: Web + case .web(let url): + WebView(url: url, viewModel: viewModel) + // MARK: Unknown + case .unknown(let url): + UnknownView(url: url, viewModel: viewModel) + Spacer() + // MARK: Discussion + case let .discussion(blockID, blockKey, title): + VStack { + if showDiscussion { + DiscussionView( + id: viewModel.id, + blockID: blockID, + blockKey: blockKey, + title: title, + viewModel: viewModel + ) + Spacer(minLength: 100) + } else { + DiscussionView( + id: viewModel.id, + blockID: blockID, + blockKey: blockKey, + title: title, + viewModel: viewModel + ).drawingGroup() + Spacer(minLength: 100) + } + }.frameLimit() } - }).frame(width: 215).padding(.top, 40) - }.padding(24) - Spacer() - case let .discussion(blockID): - let id = "course-v1:" - + (viewModel.lessonID.find(from: "block-v1:", to: "+type").first ?? "") - PostsView(courseID: id, - topics: Topics(coursewareTopics: [], - nonCoursewareTopics: []), - title: "", type: .courseTopics(topicID: blockID), - viewModel: Container.shared.resolve(PostsViewModel.self)!, - router: Container.shared.resolve(DiscussionRouter.self)!, - showTopMenu: false) - .onAppear { - Task { - await viewModel.blockCompletionRequest(blockID: blockID) + } else { + EmptyView() } } - default: - VStack {} + .frame(height: reader.size.height) + .id(index) } - } else { - VStack(spacing: 28) { - Image(systemName: "wifi").resizable() - .scaledToFit() - .frame(width: 100) - Text(CourseLocalization.Error.noInternet) - .multilineTextAlignment(.center) - .padding(.horizontal, 20) - UnitButtonView(type: .reload, action: { - self.viewModel.createLessonType() - self.viewModel.killPlayer.toggle() - }).frame(width: 100) - }.frame(maxWidth: .infinity, maxHeight: .infinity) - } - - // MARK: - Alert - if showAlert { - ZStack(alignment: .bottomLeading) { - Spacer() - HStack(spacing: 6) { - CoreAssets.rotateDevice.swiftUIImage.renderingMode(.template) - .onAppear { - alertMessage = CourseLocalization.Alert.rotateDevice - } - Text(alertMessage ?? "") - }.shadowCardStyle(bgColor: CoreAssets.accentColor.swiftUIColor, - textColor: .white) - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - alertMessage = nil - showAlert = false + } + .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 } } } - } - - // MARK: - Course Navigation - CourseNavigationView( - sectionName: sectionName, - viewModel: viewModel - ).padding(.vertical, 12) - .frameLimit(sizePortrait: 420) - .background( - CoreAssets.background.swiftUIColor - .ignoresSafeArea() - .shadow(color: CoreAssets.shadowColor.swiftUIColor, radius: 4, y: -2) - ) - }.frame(maxWidth: .infinity) + + }) + } 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) - .onRightSwipeGesture { - viewModel.router.back() - self.viewModel.killPlayer.toggle() + .clipped() + + // MARK: Progress Dots + if viewModel.verticals[viewModel.verticalIndex].childs.count > 1 { + LessonProgressView(viewModel: viewModel) + } + } + // MARK: - Alert + if showAlert { + ZStack(alignment: .bottomLeading) { + Spacer() + HStack(spacing: 6) { + CoreAssets.rotateDevice.swiftUIImage.renderingMode(.template) + .onAppear { + alertMessage = CourseLocalization.Alert.rotateDevice + } + Text(alertMessage ?? "") + }.shadowCardStyle(bgColor: CoreAssets.accentColor.swiftUIColor, + textColor: .white) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + alertMessage = nil + showAlert = false + } } - + } } - } - .background( - CoreAssets.background.swiftUIColor - .ignoresSafeArea() - ) + + // MARK: - Course Navigation + VStack { + NavigationBar( + title: "", + leftButtonAction: { + viewModel.router.back() + playerStateSubject.send(VideoPlayerState.kill) + }).padding(.top, 50) + Spacer() + CourseNavigationView( + sectionName: sectionName, + viewModel: viewModel, + playerStateSubject: playerStateSubject + ).padding(.bottom, 30) + .frameLimit(sizePortrait: 420) + }.frame(maxWidth: .infinity) + .onRightSwipeGesture { + playerStateSubject.send(VideoPlayerState.kill) + viewModel.router.back() + } + } + }.ignoresSafeArea() + .background( + CoreAssets.background.swiftUIColor + .ignoresSafeArea() + ) } } #if DEBUG //swiftlint:disable all -struct LessonView_Previews: PreviewProvider { +struct CourseUnitView_Previews: PreviewProvider { static var previews: some View { let blocks = [ - CourseBlock(blockId: "1", - id: "1", - topicId: "1", - graded: false, - completion: 0, - type: .vertical, - displayName: "Lesson 1", - studentUrl: "1", - videoUrl: nil, - youTubeUrl: nil), - CourseBlock(blockId: "2", - id: "2", - topicId: "2", - graded: false, + CourseBlock( + blockId: "1", + id: "1", + topicId: "1", + graded: false, + completion: 0, + type: .video, + displayName: "Lesson 1", + studentUrl: "", + videoUrl: nil, + youTubeUrl: nil + ), + CourseBlock( + blockId: "2", + id: "2", + topicId: "2", + graded: false, + completion: 0, + type: .video, + displayName: "Lesson 2", + studentUrl: "2", + videoUrl: nil, + youTubeUrl: nil + ), + CourseBlock( + blockId: "3", + id: "3", + topicId: "3", + graded: false, + completion: 0, + type: .unknown, + displayName: "Lesson 3", + studentUrl: "3", + videoUrl: nil, + youTubeUrl: nil + ), + CourseBlock( + blockId: "4", + id: "4", + topicId: "4", + graded: false, + completion: 0, + type: .unknown, + displayName: "4", + studentUrl: "4", + videoUrl: nil, + youTubeUrl: nil + ), + ] + + let chapters = [ + CourseChapter( + blockId: "0", + id: "0", + displayName: "0", + type: .chapter, + childs: [ + CourseSequential( + blockId: "5", + id: "5", + displayName: "5", + type: .sequential, completion: 0, - type: .chapter, - displayName: "Lesson 2", - studentUrl: "2", - videoUrl: nil, - youTubeUrl: nil), - CourseBlock(blockId: "3", + childs: [ + CourseVertical( + blockId: "6", id: "6", + displayName: "6", + type: .vertical, + completion: 0, + childs: blocks + ) + ] + ) + + ]), + CourseChapter( + blockId: "2", + id: "2", + displayName: "2", + type: .chapter, + childs: [ + CourseSequential( + blockId: "3", id: "3", - topicId: "3", - graded: false, - completion: 0, - type: .vertical, - displayName: "Lesson 3", - studentUrl: "3", - videoUrl: nil, - youTubeUrl: nil), - CourseBlock(blockId: "4", - id: "4", - topicId: "4", - graded: false, + displayName: "3", + type: .sequential, completion: 0, - type: .vertical, - displayName: "4", - studentUrl: "4", - videoUrl: nil, - youTubeUrl: nil), + childs: [ + CourseVertical( + blockId: "4", id: "4", + displayName: "4", + type: .vertical, + completion: 0, + childs: blocks + ) + ] + ) + + ]) ] return CourseUnitView(viewModel: CourseUnitViewModel( - lessonID: "", courseID: "", blocks: blocks, + lessonID: "", + courseID: "", + id: "1", + courseName: "courseName", + chapters: chapters, + chapterIndex: 0, + sequentialIndex: 0, + verticalIndex: 0, interactor: CourseInteractor.mock, router: CourseRouterMock(), + analytics: CourseAnalyticsMock(), connectivity: Connectivity(), manager: DownloadManagerMock() ), sectionName: "") } } +//swiftlint:enable all #endif diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index 9c4cf1b01..cca727927 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -5,7 +5,7 @@ // Created by  Stepanok Ivan on 05.10.2022. // -import Foundation +import SwiftUI import Core public enum LessonType: Equatable { @@ -13,7 +13,7 @@ public enum LessonType: Equatable { case youtube(viewYouTubeUrl: String, blockID: String) case video(videoUrl: String, blockID: String) case unknown(String) - case discussion(String) + case discussion(String, String, String) static func from(_ block: CourseBlock) -> Self { switch block.type { @@ -22,7 +22,7 @@ public enum LessonType: Equatable { case .html: return .web(block.studentUrl) case .discussion: - return .discussion(block.topicId ?? "") + return .discussion(block.topicId ?? "", block.id, block.displayName) case .video: if block.youTubeUrl != nil, let encodedVideo = block.videoUrl { return .video(videoUrl: encodedVideo, blockID: block.id) @@ -42,12 +42,18 @@ public enum LessonType: Equatable { public class CourseUnitViewModel: ObservableObject { - public var blocks: [CourseBlock] + enum LessonAction { + case next + case previous + } + + var verticals: [CourseVertical] + var verticalIndex: Int + var courseName: String + @Published var index: Int = 0 - @Published var previousLesson: String = "" - @Published var nextLesson: String = "" - @Published var lessonType: LessonType? - @Published var killPlayer = false + var previousLesson: String = "" + var nextLesson: String = "" @Published var showError: Bool = false var errorMessage: String? { didSet { @@ -55,76 +61,91 @@ public class CourseUnitViewModel: ObservableObject { } } - public var lessonID: String - public var courseID: String - + var lessonID: String + var courseID: String + var id: String + private let interactor: CourseInteractorProtocol - public let router: CourseRouter - public let connectivity: ConnectivityProtocol + let router: CourseRouter + let analytics: CourseAnalytics + let connectivity: ConnectivityProtocol private let manager: DownloadManagerProtocol private var subtitlesDownloaded: Bool = false + let chapters: [CourseChapter] + let chapterIndex: Int + let sequentialIndex: Int func loadIndex() { index = selectLesson() } - public init(lessonID: String, - courseID: String, - blocks: [CourseBlock], - interactor: CourseInteractorProtocol, - router: CourseRouter, - connectivity: ConnectivityProtocol, - manager: DownloadManagerProtocol + public init( + lessonID: String, + courseID: String, + id: String, + courseName: String, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int, + verticalIndex: Int, + interactor: CourseInteractorProtocol, + router: CourseRouter, + analytics: CourseAnalytics, + connectivity: ConnectivityProtocol, + manager: DownloadManagerProtocol ) { self.lessonID = lessonID self.courseID = courseID - self.blocks = blocks + self.id = id + self.courseName = courseName + self.chapters = chapters + self.chapterIndex = chapterIndex + self.sequentialIndex = sequentialIndex + self.verticalIndex = verticalIndex + self.verticals = chapters[chapterIndex].childs[sequentialIndex].childs self.interactor = interactor self.router = router + self.analytics = analytics self.connectivity = connectivity self.manager = manager } - public func languages() -> [SubtitleUrl] { - return blocks.first(where: { $0.id == lessonID })?.subtitles ?? [] - } - private func selectLesson() -> Int { - guard blocks.count > 0 else { return 0 } - let index = blocks.firstIndex(where: { $0.id == lessonID }) ?? 0 + guard verticals[verticalIndex].childs.count > 0 else { return 0 } + let index = verticals[verticalIndex].childs.firstIndex(where: { $0.id == lessonID }) ?? 0 nextTitles() return index } func selectedLesson() -> CourseBlock { - return blocks[index] - } - - func createLessonType() { - self.lessonType = LessonType.from(blocks[index]) - } - - enum LessonAction { - case next - case previous + return verticals[verticalIndex].childs[index] } func select(move: LessonAction) { switch move { case .next: - if index != blocks.count - 1 { index += 1 } + if index != verticals[verticalIndex].childs.count - 1 { index += 1 } + let nextBlock = verticals[verticalIndex].childs[index] nextTitles() + analytics.nextBlockClicked(courseId: courseID, + courseName: courseName, + blockId: nextBlock.blockId, + blockName: nextBlock.displayName) case .previous: if index != 0 { index -= 1 } nextTitles() + let prevBlock = verticals[verticalIndex].childs[index] + analytics.prevBlockClicked(courseId: courseID, + courseName: courseName, + blockId: prevBlock.blockId, + blockName: prevBlock.displayName) } } @MainActor func blockCompletionRequest(blockID: String) async { - let fullBlockID = "block-v1:\(courseID.dropFirst(10))+type@discussion+block@\(blockID)" do { - try await interactor.blockCompletionRequest(courseID: courseID, blockID: fullBlockID) + try await interactor.blockCompletionRequest(courseID: self.id, blockID: blockID) } catch let error { if error.isInternetError || error is NoCachedDataError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection @@ -136,20 +157,18 @@ public class CourseUnitViewModel: ObservableObject { func nextTitles() { if index != 0 { - previousLesson = blocks[index - 1].displayName + previousLesson = verticals[verticalIndex].childs[index - 1].displayName } else { previousLesson = "" } - if index != blocks.count - 1 { - nextLesson = blocks[index + 1].displayName + if index != verticals[verticalIndex].childs.count - 1 { + nextLesson = verticals[verticalIndex].childs[index + 1].displayName } else { nextLesson = "" } } - public func urlForVideoFileOrFallback(blockId: String, url: String) -> URL? { - guard let block = blocks.first(where: { $0.id == blockId }) else { return nil } - + func urlForVideoFileOrFallback(blockId: String, url: String) -> URL? { if let fileURL = manager.fileUrl(for: blockId) { return fileURL } else { diff --git a/Course/Course/Presentation/Unit/Subviews/DiscussionView.swift b/Course/Course/Presentation/Unit/Subviews/DiscussionView.swift new file mode 100644 index 000000000..ed46e69b8 --- /dev/null +++ b/Course/Course/Presentation/Unit/Subviews/DiscussionView.swift @@ -0,0 +1,37 @@ +// +// DiscussionView.swift +// Course +// +// Created by  Stepanok Ivan on 30.05.2023. +// + +import SwiftUI +import Core +import Discussion +import Swinject + +struct DiscussionView: View { + let id: String + let blockID: String + let blockKey: String + let title: String + let viewModel: CourseUnitViewModel + + var body: some View { + PostsView( + courseID: id, + currentBlockID: blockID, + topics: Topics(coursewareTopics: [], nonCoursewareTopics: []), + title: title, + type: .courseTopics(topicID: blockID), + viewModel: Container.shared.resolve(PostsViewModel.self)!, + router: Container.shared.resolve(DiscussionRouter.self)!, + showTopMenu: false + ) + .onAppear { + Task { + await viewModel.blockCompletionRequest(blockID: blockKey) + } + } + } +} diff --git a/Course/Course/Presentation/Unit/Subviews/EncodedVideoView.swift b/Course/Course/Presentation/Unit/Subviews/EncodedVideoView.swift new file mode 100644 index 000000000..1bdc629fa --- /dev/null +++ b/Course/Course/Presentation/Unit/Subviews/EncodedVideoView.swift @@ -0,0 +1,41 @@ +// +// EncodedVideoView.swift +// Course +// +// Created by  Stepanok Ivan on 30.05.2023. +// + +import SwiftUI +import Core +import Combine +import Swinject + +struct EncodedVideoView: View { + + let name: String + let url: URL? + let courseID: String + let blockID: String + let playerStateSubject: CurrentValueSubject + let languages: [SubtitleUrl] + let isOnScreen: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(name) + .font(Theme.Fonts.titleLarge) + .padding(.horizontal, 24) + + let vm = Container.shared.resolve( + EncodedVideoPlayerViewModel.self, + arguments: url, + blockID, + courseID, + languages, + playerStateSubject + )! + EncodedVideoPlayer(viewModel: vm, isOnScreen: isOnScreen) + Spacer(minLength: 100) + } + } +} diff --git a/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift b/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift new file mode 100644 index 000000000..37dcb67d3 --- /dev/null +++ b/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift @@ -0,0 +1,42 @@ +// +// LessonProgressView.swift +// Course +// +// Created by  Stepanok Ivan on 30.05.2023. +// + +import SwiftUI +import Core + +struct LessonProgressView: View { + @ObservedObject var viewModel: CourseUnitViewModel + + init(viewModel: CourseUnitViewModel) { + self.viewModel = viewModel + } + + var body: some View { + HStack { + Spacer() + VStack { + Spacer() + let childs = viewModel.verticals[viewModel.verticalIndex].childs + ForEach(Array(childs.enumerated()), id: \.offset) { index, _ in + let selected = viewModel.verticals[viewModel.verticalIndex].childs[index] + Circle() + .frame( + width: selected == viewModel.selectedLesson() ? 5 : 3, + height: selected == viewModel.selectedLesson() ? 5 : 3 + ) + .foregroundColor( + selected == viewModel.selectedLesson() + ? .accentColor + : CoreAssets.textSecondary.swiftUIColor + ) + } + Spacer() + } + .padding(.trailing, 6) + } + } +} diff --git a/Course/Course/Presentation/Unit/Subviews/UnknownView.swift b/Course/Course/Presentation/Unit/Subviews/UnknownView.swift new file mode 100644 index 000000000..4f25de9da --- /dev/null +++ b/Course/Course/Presentation/Unit/Subviews/UnknownView.swift @@ -0,0 +1,38 @@ +// +// UnknownView.swift +// Course +// +// Created by  Stepanok Ivan on 30.05.2023. +// + +import SwiftUI +import Core + +struct UnknownView: View { + let url: String + let viewModel: CourseUnitViewModel + + var body: some View { + VStack(spacing: 0) { + CoreAssets.notAvaliable.swiftUIImage + Text(CourseLocalization.NotAvaliable.title) + .font(Theme.Fonts.titleLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 40) + Text(CourseLocalization.NotAvaliable.description) + .font(Theme.Fonts.bodyLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 12) + StyledButton(CourseLocalization.NotAvaliable.button, action: { + if let url = URL(string: url) { + UIApplication.shared.open(url) + } + }) + .frame(width: 215) + .padding(.top, 40) + } + .padding(24) + } +} diff --git a/Course/Course/Presentation/Unit/Subviews/WebView.swift b/Course/Course/Presentation/Unit/Subviews/WebView.swift new file mode 100644 index 000000000..9cdc59269 --- /dev/null +++ b/Course/Course/Presentation/Unit/Subviews/WebView.swift @@ -0,0 +1,23 @@ +// +// WebView.swift +// Course +// +// Created by  Stepanok Ivan on 30.05.2023. +// + +import SwiftUI +import Swinject +import Core + +struct WebView: View { + let url: String + let viewModel: CourseUnitViewModel + + var body: some View { + VStack(spacing: 0) { + WebUnitView(url: url, viewModel: Container.shared.resolve(WebUnitViewModel.self)!) + Spacer(minLength: 5) + } + .roundedBackground(strokeColor: .clear, maxIpadWidth: .infinity) + } +} diff --git a/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift b/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift new file mode 100644 index 000000000..8aeab7f13 --- /dev/null +++ b/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift @@ -0,0 +1,43 @@ +// +// YouTubeView.swift +// Course +// +// Created by  Stepanok Ivan on 30.05.2023. +// + +import SwiftUI +import Core +import Combine +import Swinject + +struct YouTubeView: View { + + let name: String + let url: String + let courseID: String + let blockID: String + let playerStateSubject: CurrentValueSubject + let languages: [SubtitleUrl] + let isOnScreen: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading) { + Text(name) + .font(Theme.Fonts.titleLarge) + .padding(.horizontal, 24) + + let vm = Container.shared.resolve( + YouTubeVideoPlayerViewModel.self, + arguments: url, + blockID, + courseID, + languages, + playerStateSubject + )! + YouTubeVideoPlayer(viewModel: vm, isOnScreen: isOnScreen) + Spacer(minLength: 100) + }.background(CoreAssets.background.swiftUIColor) + } + } +} diff --git a/Course/Course/Presentation/Unit/UnitButtonView.swift b/Course/Course/Presentation/Unit/UnitButtonView.swift deleted file mode 100644 index b2494aa80..000000000 --- a/Course/Course/Presentation/Unit/UnitButtonView.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// UnitButtonView.swift -// Course -// -// Created by  Stepanok Ivan on 14.02.2023. -// - -import SwiftUI -import Core - -struct UnitButtonView: View { - - enum UnitButtonType { - case first - case next - case previous - case last - case finish - case reload - case continueLesson - - func stringValue() -> String { - switch self { - case .first: - return CourseLocalization.Courseware.next - case .next: - return CourseLocalization.Courseware.next - case .previous: - return CourseLocalization.Courseware.previous - case .last: - return CourseLocalization.Courseware.finish - case .finish: - return CourseLocalization.Courseware.finish - case .reload: - return CourseLocalization.Error.reload - case .continueLesson: - return CourseLocalization.Courseware.continue - } - } - } - - private let action: () -> Void - private let type: UnitButtonType - - init(type: UnitButtonType, action: @escaping () -> Void) { - self.action = action - self.type = type - } - - var body: some View { - HStack { - Button(action: action) { - VStack { - switch type { - case .first: - HStack { - Text(type.stringValue()) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) - .font(Theme.Fonts.labelLarge) - CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) - .rotationEffect(Angle.degrees(180)) - } - case .next: - HStack { - Text(type.stringValue()) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) - .padding(.leading, 20) - .font(Theme.Fonts.labelLarge) - Spacer() - CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) - .rotationEffect(Angle.degrees(180)) - .padding(.trailing, 20) - } - case .previous: - HStack { - CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .padding(.leading, 20) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) - Spacer() - Text(type.stringValue()) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) - .font(Theme.Fonts.labelLarge) - .padding(.trailing, 20) - } - case .last: - HStack { - Text(type.stringValue()) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) - .padding(.leading, 16) - .font(Theme.Fonts.labelLarge) - Spacer() - CoreAssets.check.swiftUIImage.renderingMode(.template) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) - .padding(.trailing, 16) - } - case .finish: - HStack { - Text(type.stringValue()) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) - .font(Theme.Fonts.labelLarge) - CoreAssets.check.swiftUIImage.renderingMode(.template) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) - } - case .reload: - VStack(alignment: .center) { - Text(type.stringValue()) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) - .font(Theme.Fonts.labelLarge) - } - case .continueLesson: - HStack { - Text(type.stringValue()) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) - .padding(.leading, 20) - .font(Theme.Fonts.labelLarge) - CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .foregroundColor(CoreAssets.styledButtonText.swiftUIColor) - .rotationEffect(Angle.degrees(180)) - .padding(.trailing, 20) - } - } - } - .frame(maxWidth: .infinity, minHeight: 48) - .background( - VStack { - if self.type == .reload { - Theme.Shapes.buttonShape - .fill(.clear) - } else { - Theme.Shapes.buttonShape - .fill(type == .previous ? .clear : CoreAssets.accentColor.swiftUIColor) - } - } - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) - .foregroundColor(CoreAssets.accentColor.swiftUIColor) - ) - } - } - } -} - -struct UnitButtonView_Previews: PreviewProvider { - static var previews: some View { - VStack { - UnitButtonView(type: .first, action: {}) - UnitButtonView(type: .previous, action: {}) - UnitButtonView(type: .next, action: {}) - UnitButtonView(type: .last, action: {}) - UnitButtonView(type: .finish, action: {}) - UnitButtonView(type: .reload, action: {}) - UnitButtonView(type: .continueLesson, action: {}) - } - } -} diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index 0a63cb913..a3ddda18f 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -9,17 +9,20 @@ import SwiftUI import _AVKit_SwiftUI import Core import Swinject +import Combine + +public enum VideoPlayerState { + case pause + case kill +} public struct EncodedVideoPlayer: View { - @ObservedObject - private var viewModel = Container.shared.resolve(VideoPlayerViewModel.self)! + @StateObject + private var viewModel: EncodedVideoPlayerViewModel - private var blockID: String - private var courseID: String - private let languages: [SubtitleUrl] + private var isOnScreen: Bool - private var controller = AVPlayerViewController() private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @State private var orientation = UIDevice.current.orientation @State private var isLoading: Bool = true @@ -27,7 +30,7 @@ public struct EncodedVideoPlayer: View { @State private var isViewedOnce: Bool = false @State private var currentTime: Double = 0 @State private var isOrientationChanged: Bool = false - @Binding private var killPlayer: Bool + @State var showAlert = false @State var alertMessage: String? { didSet { @@ -36,33 +39,26 @@ public struct EncodedVideoPlayer: View { } } } - private let url: URL? public init( - url: URL?, - blockID: String, - courseID: String, - languages: [SubtitleUrl], - killPlayer: Binding + viewModel: EncodedVideoPlayerViewModel, + isOnScreen: Bool ) { - self.url = url - self.blockID = blockID - self.courseID = courseID - self.languages = languages - self._killPlayer = killPlayer + self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.isOnScreen = isOnScreen } public var body: some View { ZStack { VStack(alignment: .leading) { PlayerViewController( - videoURL: url, - controller: controller, + videoURL: viewModel.url, + controller: viewModel.controller, progress: { progress in if progress >= 0.8 { if !isViewedOnce { Task { - await viewModel.blockCompletionRequest(blockID: blockID, courseID: courseID) + await viewModel.blockCompletionRequest() } isViewedOnce = true } @@ -71,25 +67,30 @@ public struct EncodedVideoPlayer: View { currentTime = seconds }) .aspectRatio(16 / 9, contentMode: .fit) + .cornerRadius(12) + .padding(.horizontal, 6) .onReceive(NotificationCenter.Publisher( center: .default, - name: UIDevice.orientationDidChangeNotification)) { _ in + name: UIDevice.orientationDidChangeNotification) + ) { _ in + if isOnScreen { self.orientation = UIDevice.current.orientation if self.orientation.isLandscape { - controller.enterFullScreen(animated: true) - controller.player?.play() + viewModel.controller.enterFullScreen(animated: true) + viewModel.controller.player?.play() isOrientationChanged = true } else { if isOrientationChanged { - controller.exitFullScreen(animated: true) - controller.player?.pause() + viewModel.controller.exitFullScreen(animated: true) + viewModel.controller.player?.pause() isOrientationChanged = false } } } - SubtittlesView(languages: languages, - currentTime: $currentTime, - viewModel: viewModel) + } + SubtittlesView(languages: viewModel.languages, + currentTime: $currentTime, + viewModel: viewModel) Spacer() if !orientation.isLandscape || idiom != .pad { VStack {}.onAppear { @@ -97,22 +98,21 @@ public struct EncodedVideoPlayer: View { alertMessage = CourseLocalization.Alert.rotateDevice } } - }.onChange(of: killPlayer, perform: { _ in - controller.player?.replaceCurrentItem(with: nil) - }) + } + // MARK: - Alert - if showAlert { + if showAlert, let alertMessage { VStack(alignment: .center) { Spacer() HStack(spacing: 6) { CoreAssets.rotateDevice.swiftUIImage.renderingMode(.template) - Text(alertMessage ?? "") - }.shadowCardStyle(bgColor: CoreAssets.accentColor.swiftUIColor, + Text(alertMessage) + }.shadowCardStyle(bgColor: CoreAssets.snackbarInfoAlert.swiftUIColor, textColor: .white) .transition(.move(edge: .bottom)) .onAppear { doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - alertMessage = nil + self.alertMessage = nil showAlert = false } } @@ -122,8 +122,22 @@ public struct EncodedVideoPlayer: View { } } +#if DEBUG struct EncodedVideoPlayer_Previews: PreviewProvider { static var previews: some View { - EncodedVideoPlayer(url: nil, blockID: "", courseID: "", languages: [], killPlayer: .constant(false)) + EncodedVideoPlayer( + viewModel: EncodedVideoPlayerViewModel( + url: URL(string: "")!, + blockID: "", + courseID: "", + languages: [], + playerStateSubject: CurrentValueSubject(nil), + interactor: CourseInteractor(repository: CourseRepositoryMock()), + router: CourseRouterMock(), + connectivity: Connectivity() + ), + isOnScreen: true + ) } } +#endif diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift new file mode 100644 index 000000000..b75a57384 --- /dev/null +++ b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift @@ -0,0 +1,49 @@ +// +// EncodedVideoPlayerViewModel.swift +// Course +// +// Created by  Stepanok Ivan on 24.05.2023. +// + +import _AVKit_SwiftUI +import Core +import Combine + +public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { + + let url: URL? + + let controller = AVPlayerViewController() + private var subscription = Set() + + public init( + url: URL?, + blockID: String, + courseID: String, + languages: [SubtitleUrl], + playerStateSubject: CurrentValueSubject, + interactor: CourseInteractorProtocol, + router: CourseRouter, + connectivity: ConnectivityProtocol + ) { + self.url = url + + super.init(blockID: blockID, + courseID: courseID, + languages: languages, + interactor: interactor, + router: router, + connectivity: connectivity) + + playerStateSubject.sink(receiveValue: { [weak self] state in + switch state { + case .pause: + self?.controller.player?.pause() + case .kill: + self?.controller.player?.replaceCurrentItem(with: nil) + case .none: + break + } + }).store(in: &subscription) + } +} diff --git a/Course/Course/Presentation/Video/PlayerViewController.swift b/Course/Course/Presentation/Video/PlayerViewController.swift index e67143ca8..ef856ff04 100644 --- a/Course/Course/Presentation/Video/PlayerViewController.swift +++ b/Course/Course/Presentation/Video/PlayerViewController.swift @@ -12,12 +12,14 @@ struct PlayerViewController: UIViewControllerRepresentable { var videoURL: URL? var controller: AVPlayerViewController - public var progress: ((Float) -> Void) - public var seconds: ((Double) -> Void) + var progress: ((Float) -> Void) + var seconds: ((Double) -> Void) - init(videoURL: URL?, controller: AVPlayerViewController, - progress: @escaping ((Float) -> Void), - seconds: @escaping ((Double) -> Void)) { + init( + videoURL: URL?, controller: AVPlayerViewController, + progress: @escaping ((Float) -> Void), + seconds: @escaping ((Double) -> Void) + ) { self.videoURL = videoURL self.controller = controller self.progress = progress @@ -27,33 +29,45 @@ struct PlayerViewController: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> AVPlayerViewController { controller.modalPresentationStyle = .fullScreen controller.allowsPictureInPicturePlayback = true + controller.player = AVPlayer() - addPeriodicTimeObserver(controller, currentProgress: { progress, seconds in - self.progress(progress) - self.seconds(seconds) - }) + addPeriodicTimeObserver( + controller, + currentProgress: { progress, seconds in + self.progress(progress) + self.seconds(seconds) + } + ) return controller } - private func addPeriodicTimeObserver(_ controller: AVPlayerViewController, - currentProgress: @escaping ((Float, Double) -> Void)) { - let interval = CMTime(seconds: 0.1, - preferredTimescale: CMTimeScale(NSEC_PER_SEC)) + private func addPeriodicTimeObserver( + _ controller: AVPlayerViewController, + currentProgress: @escaping ((Float, Double) -> Void) + ) { + let interval = CMTime( + seconds: 0.1, + preferredTimescale: CMTimeScale(NSEC_PER_SEC) + ) self.controller.player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in var progress: Float = .zero let currentSeconds = CMTimeGetSeconds(time) guard let duration = controller.player?.currentItem?.duration else { return } let totalSeconds = CMTimeGetSeconds(duration) - progress = Float(currentSeconds/totalSeconds) + progress = Float(currentSeconds / totalSeconds) currentProgress(progress, currentSeconds) } } func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) { DispatchQueue.main.async { - if (playerController.player?.currentItem?.asset as? AVURLAsset)?.url.absoluteString != videoURL?.absoluteString { - playerController.player = AVPlayer(url: videoURL!) + let asset = playerController.player?.currentItem?.asset as? AVURLAsset + if asset?.url.absoluteString != videoURL?.absoluteString { + if playerController.player == nil { + playerController.player = AVPlayer() + } + playerController.player?.replaceCurrentItem(with: AVPlayerItem(url: videoURL!)) addPeriodicTimeObserver(playerController, currentProgress: { progress, seconds in self.progress(progress) self.seconds(seconds) diff --git a/Course/Course/Presentation/Video/SubtittlesView.swift b/Course/Course/Presentation/Video/SubtittlesView.swift index 493ab4f98..befc34f68 100644 --- a/Course/Course/Presentation/Video/SubtittlesView.swift +++ b/Course/Course/Presentation/Video/SubtittlesView.swift @@ -50,40 +50,41 @@ public struct SubtittlesView: View { }) } } - ScrollView { - if viewModel.subtitles.count > 0 { - VStack(alignment: .leading, spacing: 0) { - ForEach(viewModel.subtitles, id: \.id) { subtitle in - HStack { - Text(subtitle.text) - .padding(.vertical, 16) - .font(Theme.Fonts.bodyMedium) - .foregroundColor(subtitle.fromTo.contains(Date(milliseconds: currentTime)) - ? CoreAssets.textPrimary.swiftUIColor - : CoreAssets.textSecondary.swiftUIColor) - .onChange(of: currentTime, perform: { _ in - if subtitle.fromTo.contains(Date(milliseconds: currentTime)) { - if id != subtitle.id { - withAnimation { - scroll.scrollTo(subtitle.id, anchor: .top) + ZStack { + ScrollView { + if viewModel.subtitles.count > 0 { + VStack(alignment: .leading, spacing: 0) { + ForEach(viewModel.subtitles, id: \.id) { subtitle in + HStack { + Text(subtitle.text) + .padding(.vertical, 16) + .font(Theme.Fonts.bodyMedium) + .foregroundColor(subtitle.fromTo.contains(Date(milliseconds: currentTime)) + ? CoreAssets.textPrimary.swiftUIColor + : CoreAssets.textSecondary.swiftUIColor) + .onChange(of: currentTime, perform: { _ in + if subtitle.fromTo.contains(Date(milliseconds: currentTime)) { + if id != subtitle.id { + withAnimation { + scroll.scrollTo(subtitle.id, anchor: .top) + } } + self.id = subtitle.id } - self.id = subtitle.id - } - }) - }.id(subtitle.id) + }) + }.id(subtitle.id) + } } + .introspect(.scrollView, on: .iOS(.v14, .v15, .v16, .v17), customize: { scrollView in + scrollView.isScrollEnabled = false + }) } } - }.introspectScrollView(customize: { scroll in - scroll.isScrollEnabled = false - }) + // Forced disable scrolling for iOS 14, 15 + Color.white.opacity(0) + } }.padding(.horizontal, 24) .padding(.top, 34) - .onAppear { - viewModel.languages = languages - viewModel.prepareLanguages() - } } } } @@ -92,12 +93,18 @@ public struct SubtittlesView: View { struct SubtittlesView_Previews: PreviewProvider { static var previews: some View { - SubtittlesView(languages: [SubtitleUrl(language: "fr", url: "url"), - SubtitleUrl(language: "uk", url: "url2")], - currentTime: .constant(0), - viewModel: VideoPlayerViewModel(interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), - connectivity: Connectivity())) + SubtittlesView( + languages: [SubtitleUrl(language: "fr", url: "url"), + SubtitleUrl(language: "uk", url: "url2")], + currentTime: .constant(0), + viewModel: VideoPlayerViewModel( + blockID: "", courseID: "", + languages: [], + interactor: CourseInteractor(repository: CourseRepositoryMock()), + router: CourseRouterMock(), + connectivity: Connectivity() + ) + ) } } #endif diff --git a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift index 021730358..7aab1c567 100644 --- a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift @@ -7,16 +7,20 @@ import Foundation import Core +import _AVKit_SwiftUI public class VideoPlayerViewModel: ObservableObject { + private var blockID: String + private var courseID: String + private let interactor: CourseInteractorProtocol public let connectivity: ConnectivityProtocol public let router: CourseRouter private var subtitlesDownloaded: Bool = false @Published var subtitles: [Subtitle] = [] - @Published var languages: [SubtitleUrl] = [] + var languages: [SubtitleUrl] @Published var items: [PickerItem] = [] @Published var selectedLanguage: String? @@ -27,16 +31,25 @@ public class VideoPlayerViewModel: ObservableObject { } } - public init(interactor: CourseInteractorProtocol, - router: CourseRouter, - connectivity: ConnectivityProtocol) { + public init( + blockID: String, + courseID: String, + languages: [SubtitleUrl], + interactor: CourseInteractorProtocol, + router: CourseRouter, + connectivity: ConnectivityProtocol + ) { + self.blockID = blockID + self.courseID = courseID + self.languages = languages self.interactor = interactor self.router = router self.connectivity = connectivity + self.prepareLanguages() } @MainActor - func blockCompletionRequest(blockID: String, courseID: String) async { + func blockCompletionRequest() async { let fullBlockID = "block-v1:\(courseID.dropFirst(10))+type@discussion+block@\(blockID)" do { try await interactor.blockCompletionRequest(courseID: courseID, blockID: fullBlockID) @@ -51,8 +64,15 @@ public class VideoPlayerViewModel: ObservableObject { @MainActor public func getSubtitles(subtitlesUrl: String) async { - guard let result = try? await interactor.getSubtitles(url: subtitlesUrl) else { return } - subtitles = result + do { + let result = try await interactor.getSubtitles( + url: subtitlesUrl, + selectedLanguage: self.selectedLanguage ?? "en" + ) + subtitles = result + } catch { + print(">>>>> ⛔️⛔️⛔️⛔️⛔️⛔️⛔️⛔️", error) + } } public func prepareLanguages() { diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index dc80ddbfc..b8cc5d335 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -13,159 +13,83 @@ import Swinject public struct YouTubeVideoPlayer: View { - private let viewModel = Container.shared.resolve(VideoPlayerViewModel.self)! + @StateObject + private var viewModel: YouTubeVideoPlayerViewModel + private var isOnScreen: Bool - private var blockID: String - private var courseID: String - private let languages: [SubtitleUrl] - - private let youtubePlayer: YouTubePlayer - private var timePublisher: AnyPublisher - private var durationPublisher: AnyPublisher - private var currentTimePublisher: AnyPublisher - private var currentStatePublisher: AnyPublisher - private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - - @State private var duration: Double? - @State private var play = false - @State private var orientation = UIDevice.current.orientation - @State private var isLoading: Bool = true - @State private var isViewedOnce: Bool = false - @State private var currentTime: Double = 0 - @State private var showAlert = false - @State private var alertMessage: String? { + @State + private var showAlert = false + @State + private var alertMessage: String? { didSet { withAnimation { showAlert = alertMessage != nil } } } - - public init(url: String, - blockID: String, - courseID: String, - languages: [SubtitleUrl] - ) { - self.blockID = blockID - self.courseID = courseID - self.languages = languages - - let videoID = url.replacingOccurrences(of: "https://www.youtube.com/watch?v=", with: "") - let configuration = YouTubePlayer.Configuration(configure: { - $0.autoPlay = false - $0.playInline = true - $0.showFullscreenButton = true - $0.allowsPictureInPictureMediaPlayback = false - $0.showControls = true - $0.useModestBranding = false - $0.progressBarColor = .white - $0.showRelatedVideos = false - $0.showCaptions = false - $0.showAnnotations = false - $0.customUserAgent = """ - Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; ja-jp) - AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5 - """ - }) - self.youtubePlayer = YouTubePlayer(source: .video(id: videoID), - configuration: configuration) - self.timePublisher = youtubePlayer.currentTimePublisher() - self.durationPublisher = youtubePlayer.durationPublisher - self.currentTimePublisher = youtubePlayer.currentTimePublisher(updateInterval: 0.1) - self.currentStatePublisher = youtubePlayer.playbackStatePublisher + + public init(viewModel: YouTubeVideoPlayerViewModel, isOnScreen: Bool) { + self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.isOnScreen = isOnScreen } - + public var body: some View { ZStack { VStack { YouTubePlayerView( - youtubePlayer, + viewModel.youtubePlayer, transaction: .init(animation: .easeIn), - overlay: { state in - if state == .ready { - if idiom == .pad { - VStack {}.onAppear { - isLoading = false - } - } else { - VStack {}.onAppear { - isLoading = false - alertMessage = CourseLocalization.Alert.rotateDevice - } - } - } - }) - .aspectRatio(16/8.8, contentMode: .fit) - .onReceive(NotificationCenter - .Publisher(center: .default, - name: UIDevice.orientationDidChangeNotification)) { _ in - self.orientation = UIDevice.current.orientation - if self.orientation.isPortrait { - youtubePlayer.update(configuration: YouTubePlayer.Configuration(configure: { - $0.playInline = true - $0.autoPlay = play - $0.startTime = Int(currentTime) - })) - } else { - youtubePlayer.update(configuration: YouTubePlayer.Configuration(configure: { - $0.playInline = false - $0.autoPlay = true - $0.startTime = Int(currentTime) - })) - } + overlay: { _ in }) + .onAppear { + alertMessage = CourseLocalization.Alert.rotateDevice } - SubtittlesView(languages: languages, - currentTime: $currentTime, - viewModel: viewModel) - - }.onReceive(durationPublisher, perform: { duration in - self.duration = duration - }) - .onReceive(currentTimePublisher, perform: { time in - currentTime = time - }) - .onReceive(currentStatePublisher, perform: { state in - switch state { - case .unstarted: - self.play = false - case .ended: - self.play = false - case .playing: - self.play = true - case .paused: - self.play = false - case .buffering, .cued: - break - } - }) - .onReceive(timePublisher, perform: { time in - if let duration { - if (time / duration) >= 0.8 { - if !isViewedOnce { - Task { - await viewModel.blockCompletionRequest(blockID: blockID, courseID: courseID) - } - isViewedOnce = true + .cornerRadius(12) + .padding(.horizontal, 6) + .aspectRatio(16 / 8.8, contentMode: .fit) + .onReceive(NotificationCenter.Publisher( + center: .default, name: UIDevice.orientationDidChangeNotification + )) { _ in + if isOnScreen { + let orientation = UIDevice.current.orientation + if orientation.isPortrait { + viewModel.youtubePlayer.update(configuration: YouTubePlayer.Configuration(configure: { + $0.playInline = true + $0.autoPlay = viewModel.play + $0.startTime = Int(viewModel.currentTime) + })) + } else { + viewModel.youtubePlayer.update(configuration: YouTubePlayer.Configuration(configure: { + $0.playInline = false + $0.autoPlay = true + $0.startTime = Int(viewModel.currentTime) + })) } } } - }) - if isLoading { + SubtittlesView( + languages: viewModel.languages, + currentTime: $viewModel.currentTime, + viewModel: viewModel + ) + } + + if viewModel.isLoading { ProgressBar(size: 40, lineWidth: 8) } + // MARK: - Alert - if showAlert { + if showAlert, let alertMessage { VStack(alignment: .center) { Spacer() HStack(spacing: 6) { CoreAssets.rotateDevice.swiftUIImage.renderingMode(.template) - Text(alertMessage ?? "") - }.shadowCardStyle(bgColor: CoreAssets.accentColor.swiftUIColor, + Text(alertMessage) + }.shadowCardStyle(bgColor: CoreAssets.snackbarInfoAlert.swiftUIColor, textColor: .white) .transition(.move(edge: .bottom)) .onAppear { doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - alertMessage = nil + self.alertMessage = nil showAlert = false } } @@ -175,11 +99,20 @@ public struct YouTubeVideoPlayer: View { } } +#if DEBUG struct YouTubeVideoPlayer_Previews: PreviewProvider { static var previews: some View { - YouTubeVideoPlayer(url: "", - blockID: "", - courseID: "", - languages: []) + YouTubeVideoPlayer( + viewModel: YouTubeVideoPlayerViewModel( + url: "", + blockID: "", + courseID: "", + languages: [], + playerStateSubject: CurrentValueSubject(nil), + interactor: CourseInteractor(repository: CourseRepositoryMock()), + router: CourseRouterMock(), + connectivity: Connectivity()), + isOnScreen: true) } } +#endif diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift new file mode 100644 index 000000000..5aaacf7ff --- /dev/null +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift @@ -0,0 +1,124 @@ +// +// YouTubeVideoPlayerViewModel.swift +// Course +// +// Created by  Stepanok Ivan on 24.05.2023. +// + +import SwiftUI +import Core +import YouTubePlayerKit +import Combine +import Swinject + +public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { + + @Published var youtubePlayer: YouTubePlayer + private (set) var play = false + @Published var isLoading: Bool = true + @Published var currentTime: Double = 0 + + private var subscription = Set() + private var duration: Double? + private var isViewedOnce: Bool = false + private var url: String + + public init( + url: String, + blockID: String, + courseID: String, + languages: [SubtitleUrl], + playerStateSubject: CurrentValueSubject, + interactor: CourseInteractorProtocol, + router: CourseRouter, + connectivity: ConnectivityProtocol + ) { + self.url = url + + let videoID = url.replacingOccurrences(of: "https://www.youtube.com/watch?v=", with: "") + let configuration = YouTubePlayer.Configuration(configure: { + $0.autoPlay = false + $0.playInline = true + $0.showFullscreenButton = true + $0.allowsPictureInPictureMediaPlayback = false + $0.showControls = true + $0.useModestBranding = false + $0.progressBarColor = .white + $0.showRelatedVideos = false + $0.showCaptions = false + $0.showAnnotations = false + $0.customUserAgent = """ + Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; ja-jp) + AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5 + """ + }) + self.youtubePlayer = YouTubePlayer(source: .video(id: videoID), configuration: configuration) + + super.init( + blockID: blockID, + courseID: courseID, + languages: languages, + interactor: interactor, + router: router, + connectivity: connectivity + ) + + self.youtubePlayer.pause() + + subscrube(playerStateSubject: playerStateSubject) + } + + private func subscrube(playerStateSubject: CurrentValueSubject) { + playerStateSubject.sink(receiveValue: { [weak self] state in + switch state { + case .pause: + self?.youtubePlayer.pause() + case .kill, .none: + break + } + }).store(in: &subscription) + + youtubePlayer.durationPublisher.sink(receiveValue: { [weak self] duration in + self?.duration = duration + }).store(in: &subscription) + + youtubePlayer.currentTimePublisher(updateInterval: 0.1).sink(receiveValue: { [weak self] time in + guard let self else { return } + self.currentTime = time + + if let duration = self.duration { + if (time / duration) >= 0.8 { + if !isViewedOnce { + Task { + await self.blockCompletionRequest() + } + isViewedOnce = true + } + } + } + }).store(in: &subscription) + + youtubePlayer.playbackStatePublisher.sink(receiveValue: { [weak self] state in + guard let self else { return } + switch state { + case .unstarted: + self.play = false + case .ended: + self.play = false + case .playing: + self.play = true + case .paused: + self.play = false + case .buffering, .cued: + break + } + }).store(in: &subscription) + + youtubePlayer.statePublisher.sink(receiveValue: { [weak self] state in + guard let self else { return } + if state == .ready { + self.isLoading = false + } + }).store(in: &subscription) + } +} diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 85d149d8b..f5719bf9d 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -29,14 +29,14 @@ public enum CourseLocalization { public static let finish = CourseLocalization.tr("Localizable", "COURSEWARE.FINISH", fallback: "Finish") /// Good Work! public static let goodWork = CourseLocalization.tr("Localizable", "COURSEWARE.GOOD_WORK", fallback: "Good Work!") - /// is finished. - public static let isFinished = CourseLocalization.tr("Localizable", "COURSEWARE.IS_FINISHED", fallback: "is finished.") + /// “ is finished. + public static let isFinished = CourseLocalization.tr("Localizable", "COURSEWARE.IS_FINISHED", fallback: "“ is finished.") /// Next public static let next = CourseLocalization.tr("Localizable", "COURSEWARE.NEXT", fallback: "Next") - /// Previous - public static let previous = CourseLocalization.tr("Localizable", "COURSEWARE.PREVIOUS", fallback: "Previous") - /// Section - public static let section = CourseLocalization.tr("Localizable", "COURSEWARE.SECTION", fallback: "Section") + /// Prev + public static let previous = CourseLocalization.tr("Localizable", "COURSEWARE.PREVIOUS", fallback: "Prev") + /// Section “ + public static let section = CourseLocalization.tr("Localizable", "COURSEWARE.SECTION", fallback: "Section “") } public enum CourseContainer { /// Course diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index 0db063144..0f5edc88f 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -21,12 +21,12 @@ "COURSEWARE.COURSE_CONTENT" = "Course content"; "COURSEWARE.COURSE_UNITS" = "Course units"; "COURSEWARE.NEXT" = "Next"; -"COURSEWARE.PREVIOUS" = "Previous"; +"COURSEWARE.PREVIOUS" = "Prev"; "COURSEWARE.FINISH" = "Finish"; "COURSEWARE.GOOD_WORK" = "Good Work!"; "COURSEWARE.BACK_TO_OUTLINE" = "Back to outline"; -"COURSEWARE.SECTION" = "Section"; -"COURSEWARE.IS_FINISHED" = "is finished."; +"COURSEWARE.SECTION" = "Section “"; +"COURSEWARE.IS_FINISHED" = "“ is finished."; "COURSEWARE.CONTINUE" = "Continue"; "COURSEWARE.CONTINUE_WITH" = "Continue with:"; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index f3cb347c4..cedc987f1 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -24,8 +24,8 @@ "COURSEWARE.FINISH" = "Завершити"; "COURSEWARE.GOOD_WORK" = "Гарна робота!"; "COURSEWARE.BACK_TO_OUTLINE" = "Повернутись до модуля"; -"COURSEWARE.SECTION" = "Секція"; -"COURSEWARE.IS_FINISHED" = "завершена."; +"COURSEWARE.SECTION" = "Секція “"; +"COURSEWARE.IS_FINISHED" = "“ завершена."; "COURSEWARE.CONTINUE" = "Продовжити"; "COURSEWARE.CONTINUE_WITH" = "Продовжити далі:"; diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 7993fb40e..ff0dc4ced 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -121,17 +121,20 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } - open func registerUser(fields: [String: String]) throws { + open func registerUser(fields: [String: String]) throws -> User { addInvocation(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))) let perform = methodPerformValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))) as? ([String: String]) -> Void perform?(`fields`) + var __value: User do { - _ = try methodReturnValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() as Void + __value = try methodReturnValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() } catch MockError.notStubed { - // do nothing + onFatalFailure("Stub return value not specified for registerUser(fields: [String: String]). Use given") + Failure("Stub return value not specified for registerUser(fields: [String: String]). Use given") } catch { throw error } + return __value } open func validateRegistrationFields(fields: [String: String]) throws -> [String: String] { @@ -233,6 +236,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func getRegistrationFields(willReturn: [PickerFields]...) -> MethodStub { return Given(method: .m_getRegistrationFields, products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func registerUser(fields: Parameter<[String: String]>, willReturn: User...) -> MethodStub { + return Given(method: .m_registerUser__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func validateRegistrationFields(fields: Parameter<[String: String]>, willReturn: [String: String]...) -> MethodStub { return Given(method: .m_validateRegistrationFields__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -281,10 +287,10 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func registerUser(fields: Parameter<[String: String]>, willThrow: Error...) -> MethodStub { return Given(method: .m_registerUser__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func registerUser(fields: Parameter<[String: String]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func registerUser(fields: Parameter<[String: String]>, willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] let given: Given = { return Given(method: .m_registerUser__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + let stubber = given.stubThrows(for: (User).self) willProduce(stubber) return given } @@ -514,10 +520,10 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`) } - open func presentAlert(alertTitle: String, alertMessage: String, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void) { - addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) - let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) as? (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void - perform?(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`) + open func presentAlert(alertTitle: String, alertMessage: String, nextSectionName: String?, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, nextSectionTapped: @escaping () -> Void) { + addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) + let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) as? (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void + perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { @@ -544,7 +550,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showRegisterScreen case m_showForgotPasswordScreen case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) - case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>) + case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) case m_presentView__transitionStyle_transitionStylecontent_content(Parameter, Parameter<() -> any View>) @@ -590,14 +596,16 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) return Matcher.ComparisonResult(results) - case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped)): + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsNextsectionname, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped, let lhsNextsectiontapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsNextsectionname, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped, let rhsNextsectiontapped)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlertmessage, rhs: rhsAlertmessage, with: matcher), lhsAlertmessage, rhsAlertmessage, "alertMessage")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectionname, rhs: rhsNextsectionname, with: matcher), lhsNextsectionname, rhsNextsectionname, "nextSectionName")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsImage, rhs: rhsImage, with: matcher), lhsImage, rhsImage, "image")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOnclosetapped, rhs: rhsOnclosetapped, with: matcher), lhsOnclosetapped, rhsOnclosetapped, "onCloseTapped")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOktapped, rhs: rhsOktapped, with: matcher), lhsOktapped, rhsOktapped, "okTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): @@ -627,7 +635,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue - case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue case let .m_presentView__transitionStyle_transitionStylecontent_content(p0, p1): return p0.intValue + p1.intValue } @@ -644,7 +652,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" - case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped: return ".presentAlert(alertTitle:alertMessage:action:image:onCloseTapped:okTapped:)" + case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" case .m_presentView__transitionStyle_transitionStylecontent_content: return ".presentView(transitionStyle:content:)" } @@ -675,7 +683,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`))} + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} public static func presentView(transitionStyle: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStylecontent_content(`transitionStyle`, `content`))} } @@ -714,8 +722,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, perform: @escaping (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { - return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`), performs: perform) + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { + return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) @@ -994,6 +1002,461 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CourseAnalytics + +open class CourseAnalyticsMock: CourseAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func courseEnrollClicked(courseId: String, courseName: String) { + addInvocation(.m_courseEnrollClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_courseEnrollClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + + open func courseEnrollSuccess(courseId: String, courseName: String) { + addInvocation(.m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + + open func viewCourseClicked(courseId: String, courseName: String) { + addInvocation(.m_viewCourseClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_viewCourseClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + + open func resumeCourseTapped(courseId: String, courseName: String, blockId: String) { + addInvocation(.m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`))) as? (String, String, String) -> Void + perform?(`courseId`, `courseName`, `blockId`) + } + + open func sequentialClicked(courseId: String, courseName: String, blockId: String, blockName: String) { + addInvocation(.m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`), Parameter.value(`blockName`))) + let perform = methodPerformValue(.m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`), Parameter.value(`blockName`))) as? (String, String, String, String) -> Void + perform?(`courseId`, `courseName`, `blockId`, `blockName`) + } + + open func verticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) { + addInvocation(.m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`), Parameter.value(`blockName`))) + let perform = methodPerformValue(.m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`), Parameter.value(`blockName`))) as? (String, String, String, String) -> Void + perform?(`courseId`, `courseName`, `blockId`, `blockName`) + } + + open func nextBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) { + addInvocation(.m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`), Parameter.value(`blockName`))) + let perform = methodPerformValue(.m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`), Parameter.value(`blockName`))) as? (String, String, String, String) -> Void + perform?(`courseId`, `courseName`, `blockId`, `blockName`) + } + + open func prevBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) { + addInvocation(.m_prevBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`), Parameter.value(`blockName`))) + let perform = methodPerformValue(.m_prevBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`), Parameter.value(`blockName`))) as? (String, String, String, String) -> Void + perform?(`courseId`, `courseName`, `blockId`, `blockName`) + } + + open func finishVerticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) { + addInvocation(.m_finishVerticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`), Parameter.value(`blockName`))) + let perform = methodPerformValue(.m_finishVerticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`), Parameter.value(`blockName`))) as? (String, String, String, String) -> Void + perform?(`courseId`, `courseName`, `blockId`, `blockName`) + } + + open func finishVerticalNextSectionClicked(courseId: String, courseName: String, blockId: String, blockName: String) { + addInvocation(.m_finishVerticalNextSectionClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`), Parameter.value(`blockName`))) + let perform = methodPerformValue(.m_finishVerticalNextSectionClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`), Parameter.value(`blockName`))) as? (String, String, String, String) -> Void + perform?(`courseId`, `courseName`, `blockId`, `blockName`) + } + + open func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) { + addInvocation(.m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + + open func courseOutlineCourseTabClicked(courseId: String, courseName: String) { + addInvocation(.m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + + open func courseOutlineVideosTabClicked(courseId: String, courseName: String) { + addInvocation(.m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + + open func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) { + addInvocation(.m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + + open func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) { + addInvocation(.m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + + + fileprivate enum MethodType { + case m_courseEnrollClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_viewCourseClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter, Parameter, Parameter) + case m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter, Parameter, Parameter, Parameter) + case m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter, Parameter, Parameter, Parameter) + case m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter, Parameter, Parameter, Parameter) + case m_prevBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter, Parameter, Parameter, Parameter) + case m_finishVerticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter, Parameter, Parameter, Parameter) + case m_finishVerticalNextSectionClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter, Parameter, Parameter, Parameter) + case m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_courseEnrollClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseEnrollClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + + case (.m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + + case (.m_viewCourseClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_viewCourseClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + + case (.m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(let lhsCourseid, let lhsCoursename, let lhsBlockid), .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(let rhsCourseid, let rhsCoursename, let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockId")) + return Matcher.ComparisonResult(results) + + case (.m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(let lhsCourseid, let lhsCoursename, let lhsBlockid, let lhsBlockname), .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(let rhsCourseid, let rhsCoursename, let rhsBlockid, let rhsBlockname)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockname, rhs: rhsBlockname, with: matcher), lhsBlockname, rhsBlockname, "blockName")) + return Matcher.ComparisonResult(results) + + case (.m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(let lhsCourseid, let lhsCoursename, let lhsBlockid, let lhsBlockname), .m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(let rhsCourseid, let rhsCoursename, let rhsBlockid, let rhsBlockname)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockname, rhs: rhsBlockname, with: matcher), lhsBlockname, rhsBlockname, "blockName")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(let lhsCourseid, let lhsCoursename, let lhsBlockid, let lhsBlockname), .m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(let rhsCourseid, let rhsCoursename, let rhsBlockid, let rhsBlockname)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockname, rhs: rhsBlockname, with: matcher), lhsBlockname, rhsBlockname, "blockName")) + return Matcher.ComparisonResult(results) + + case (.m_prevBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(let lhsCourseid, let lhsCoursename, let lhsBlockid, let lhsBlockname), .m_prevBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(let rhsCourseid, let rhsCoursename, let rhsBlockid, let rhsBlockname)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockname, rhs: rhsBlockname, with: matcher), lhsBlockname, rhsBlockname, "blockName")) + return Matcher.ComparisonResult(results) + + case (.m_finishVerticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(let lhsCourseid, let lhsCoursename, let lhsBlockid, let lhsBlockname), .m_finishVerticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(let rhsCourseid, let rhsCoursename, let rhsBlockid, let rhsBlockname)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockname, rhs: rhsBlockname, with: matcher), lhsBlockname, rhsBlockname, "blockName")) + return Matcher.ComparisonResult(results) + + case (.m_finishVerticalNextSectionClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(let lhsCourseid, let lhsCoursename, let lhsBlockid, let lhsBlockname), .m_finishVerticalNextSectionClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(let rhsCourseid, let rhsCoursename, let rhsBlockid, let rhsBlockname)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockname, rhs: rhsBlockname, with: matcher), lhsBlockname, rhsBlockname, "blockName")) + return Matcher.ComparisonResult(results) + + case (.m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + + case (.m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + + case (.m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + + case (.m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + + case (.m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_courseEnrollClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_viewCourseClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_prevBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_finishVerticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_finishVerticalNextSectionClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_courseEnrollClicked__courseId_courseIdcourseName_courseName: return ".courseEnrollClicked(courseId:courseName:)" + case .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName: return ".courseEnrollSuccess(courseId:courseName:)" + case .m_viewCourseClicked__courseId_courseIdcourseName_courseName: return ".viewCourseClicked(courseId:courseName:)" + case .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId: return ".resumeCourseTapped(courseId:courseName:blockId:)" + case .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName: return ".sequentialClicked(courseId:courseName:blockId:blockName:)" + case .m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName: return ".verticalClicked(courseId:courseName:blockId:blockName:)" + case .m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName: return ".nextBlockClicked(courseId:courseName:blockId:blockName:)" + case .m_prevBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName: return ".prevBlockClicked(courseId:courseName:blockId:blockName:)" + case .m_finishVerticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName: return ".finishVerticalClicked(courseId:courseName:blockId:blockName:)" + case .m_finishVerticalNextSectionClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName: return ".finishVerticalNextSectionClicked(courseId:courseName:blockId:blockName:)" + case .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName: return ".finishVerticalBackToOutlineClicked(courseId:courseName:)" + case .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineCourseTabClicked(courseId:courseName:)" + case .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineVideosTabClicked(courseId:courseName:)" + case .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDiscussionTabClicked(courseId:courseName:)" + case .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineHandoutsTabClicked(courseId:courseName:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func courseEnrollClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseEnrollClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func courseEnrollSuccess(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func viewCourseClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_viewCourseClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func resumeCourseTapped(courseId: Parameter, courseName: Parameter, blockId: Parameter) -> Verify { return Verify(method: .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(`courseId`, `courseName`, `blockId`))} + public static func sequentialClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter) -> Verify { return Verify(method: .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`))} + public static func verticalClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter) -> Verify { return Verify(method: .m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`))} + public static func nextBlockClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter) -> Verify { return Verify(method: .m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`))} + public static func prevBlockClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter) -> Verify { return Verify(method: .m_prevBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`))} + public static func finishVerticalClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter) -> Verify { return Verify(method: .m_finishVerticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`))} + public static func finishVerticalNextSectionClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter) -> Verify { return Verify(method: .m_finishVerticalNextSectionClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`))} + public static func finishVerticalBackToOutlineClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func courseOutlineCourseTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func courseOutlineVideosTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func courseOutlineDiscussionTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func courseOutlineHandoutsTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func courseEnrollClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_courseEnrollClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } + public static func courseEnrollSuccess(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } + public static func viewCourseClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_viewCourseClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } + public static func resumeCourseTapped(courseId: Parameter, courseName: Parameter, blockId: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(`courseId`, `courseName`, `blockId`), performs: perform) + } + public static func sequentialClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter, perform: @escaping (String, String, String, String) -> Void) -> Perform { + return Perform(method: .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`), performs: perform) + } + public static func verticalClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter, perform: @escaping (String, String, String, String) -> Void) -> Perform { + return Perform(method: .m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`), performs: perform) + } + public static func nextBlockClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter, perform: @escaping (String, String, String, String) -> Void) -> Perform { + return Perform(method: .m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`), performs: perform) + } + public static func prevBlockClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter, perform: @escaping (String, String, String, String) -> Void) -> Perform { + return Perform(method: .m_prevBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`), performs: perform) + } + public static func finishVerticalClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter, perform: @escaping (String, String, String, String) -> Void) -> Perform { + return Perform(method: .m_finishVerticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`), performs: perform) + } + public static func finishVerticalNextSectionClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter, perform: @escaping (String, String, String, String) -> Void) -> Perform { + return Perform(method: .m_finishVerticalNextSectionClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`), performs: perform) + } + public static func finishVerticalBackToOutlineClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } + public static func courseOutlineCourseTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } + public static func courseOutlineVideosTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } + public static func courseOutlineDiscussionTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } + public static func courseOutlineHandoutsTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - CourseInteractorProtocol open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { @@ -1192,16 +1655,16 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { return __value } - open func getSubtitles(url: String) throws -> [Subtitle] { - addInvocation(.m_getSubtitles__url_url(Parameter.value(`url`))) - let perform = methodPerformValue(.m_getSubtitles__url_url(Parameter.value(`url`))) as? (String) -> Void - perform?(`url`) + open func getSubtitles(url: String, selectedLanguage: String) throws -> [Subtitle] { + addInvocation(.m_getSubtitles__url_urlselectedLanguage_selectedLanguage(Parameter.value(`url`), Parameter.value(`selectedLanguage`))) + let perform = methodPerformValue(.m_getSubtitles__url_urlselectedLanguage_selectedLanguage(Parameter.value(`url`), Parameter.value(`selectedLanguage`))) as? (String, String) -> Void + perform?(`url`, `selectedLanguage`) var __value: [Subtitle] do { - __value = try methodReturnValue(.m_getSubtitles__url_url(Parameter.value(`url`))).casted() + __value = try methodReturnValue(.m_getSubtitles__url_urlselectedLanguage_selectedLanguage(Parameter.value(`url`), Parameter.value(`selectedLanguage`))).casted() } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for getSubtitles(url: String). Use given") - Failure("Stub return value not specified for getSubtitles(url: String). Use given") + onFatalFailure("Stub return value not specified for getSubtitles(url: String, selectedLanguage: String). Use given") + Failure("Stub return value not specified for getSubtitles(url: String, selectedLanguage: String). Use given") } catch { throw error } @@ -1220,7 +1683,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case m_getHandouts__courseID_courseID(Parameter) case m_getUpdates__courseID_courseID(Parameter) case m_resumeBlock__courseID_courseID(Parameter) - case m_getSubtitles__url_url(Parameter) + case m_getSubtitles__url_urlselectedLanguage_selectedLanguage(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1275,9 +1738,10 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) return Matcher.ComparisonResult(results) - case (.m_getSubtitles__url_url(let lhsUrl), .m_getSubtitles__url_url(let rhsUrl)): + case (.m_getSubtitles__url_urlselectedLanguage_selectedLanguage(let lhsUrl, let lhsSelectedlanguage), .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(let rhsUrl, let rhsSelectedlanguage)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSelectedlanguage, rhs: rhsSelectedlanguage, with: matcher), lhsSelectedlanguage, rhsSelectedlanguage, "selectedLanguage")) return Matcher.ComparisonResult(results) default: return .none } @@ -1295,7 +1759,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case let .m_getHandouts__courseID_courseID(p0): return p0.intValue case let .m_getUpdates__courseID_courseID(p0): return p0.intValue case let .m_resumeBlock__courseID_courseID(p0): return p0.intValue - case let .m_getSubtitles__url_url(p0): return p0.intValue + case let .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { @@ -1310,7 +1774,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case .m_getHandouts__courseID_courseID: return ".getHandouts(courseID:)" case .m_getUpdates__courseID_courseID: return ".getUpdates(courseID:)" case .m_resumeBlock__courseID_courseID: return ".resumeBlock(courseID:)" - case .m_getSubtitles__url_url: return ".getSubtitles(url:)" + case .m_getSubtitles__url_urlselectedLanguage_selectedLanguage: return ".getSubtitles(url:selectedLanguage:)" } } } @@ -1351,8 +1815,8 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func resumeBlock(courseID: Parameter, willReturn: ResumeBlock...) -> MethodStub { return Given(method: .m_resumeBlock__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getSubtitles(url: Parameter, willReturn: [Subtitle]...) -> MethodStub { - return Given(method: .m_getSubtitles__url_url(`url`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func getSubtitles(url: Parameter, selectedLanguage: Parameter, willReturn: [Subtitle]...) -> MethodStub { + return Given(method: .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(`url`, `selectedLanguage`), products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func getCourseVideoBlocks(fullStructure: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [CourseStructure] = [] @@ -1451,12 +1915,12 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { willProduce(stubber) return given } - public static func getSubtitles(url: Parameter, willThrow: Error...) -> MethodStub { - return Given(method: .m_getSubtitles__url_url(`url`), products: willThrow.map({ StubProduct.throw($0) })) + public static func getSubtitles(url: Parameter, selectedLanguage: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(`url`, `selectedLanguage`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func getSubtitles(url: Parameter, willProduce: (StubberThrows<[Subtitle]>) -> Void) -> MethodStub { + public static func getSubtitles(url: Parameter, selectedLanguage: Parameter, willProduce: (StubberThrows<[Subtitle]>) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_getSubtitles__url_url(`url`), products: willThrow.map({ StubProduct.throw($0) })) }() + let given: Given = { return Given(method: .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(`url`, `selectedLanguage`), products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: ([Subtitle]).self) willProduce(stubber) return given @@ -1476,7 +1940,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getHandouts(courseID: Parameter) -> Verify { return Verify(method: .m_getHandouts__courseID_courseID(`courseID`))} public static func getUpdates(courseID: Parameter) -> Verify { return Verify(method: .m_getUpdates__courseID_courseID(`courseID`))} public static func resumeBlock(courseID: Parameter) -> Verify { return Verify(method: .m_resumeBlock__courseID_courseID(`courseID`))} - public static func getSubtitles(url: Parameter) -> Verify { return Verify(method: .m_getSubtitles__url_url(`url`))} + public static func getSubtitles(url: Parameter, selectedLanguage: Parameter) -> Verify { return Verify(method: .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(`url`, `selectedLanguage`))} } public struct Perform { @@ -1513,8 +1977,8 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func resumeBlock(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_resumeBlock__courseID_courseID(`courseID`), performs: perform) } - public static func getSubtitles(url: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_getSubtitles__url_url(`url`), performs: perform) + public static func getSubtitles(url: Parameter, selectedLanguage: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(`url`, `selectedLanguage`), performs: perform) } } diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 49969ccb3..bae62298c 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -16,7 +16,9 @@ final class CourseContainerViewModelTests: XCTestCase { func testGetCourseBlocksSuccess() async throws { let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let connectivity = ConnectivityProtocolMock() @@ -24,7 +26,9 @@ final class CourseContainerViewModelTests: XCTestCase { let viewModel = CourseContainerViewModel( interactor: interactor, + authInteractor: authInteractor, router: router, + analytics: analytics, config: config, connectivity: connectivity, manager: DownloadManagerMock(), @@ -74,6 +78,7 @@ final class CourseContainerViewModelTests: XCTestCase { let childs = [chapter] let courseStructure = CourseStructure( + courseID: "1", id: "123", graded: true, completion: 0, @@ -112,7 +117,9 @@ final class CourseContainerViewModelTests: XCTestCase { func testGetCourseBlocksOfflineSuccess() async throws { let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let connectivity = ConnectivityProtocolMock() @@ -120,7 +127,9 @@ final class CourseContainerViewModelTests: XCTestCase { let viewModel = CourseContainerViewModel( interactor: interactor, + authInteractor: authInteractor, router: router, + analytics: analytics, config: config, connectivity: connectivity, manager: DownloadManagerMock(), @@ -131,7 +140,8 @@ final class CourseContainerViewModelTests: XCTestCase { enrollmentEnd: nil ) - let courseStructure = CourseStructure(id: "123", + let courseStructure = CourseStructure(courseID: "1", + id: "123", graded: true, completion: 0, viewYouTubeUrl: "", @@ -160,7 +170,9 @@ final class CourseContainerViewModelTests: XCTestCase { func testGetCourseBlocksNoInternetError() async throws { let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let connectivity = ConnectivityProtocolMock() @@ -168,7 +180,9 @@ final class CourseContainerViewModelTests: XCTestCase { let viewModel = CourseContainerViewModel( interactor: interactor, + authInteractor: authInteractor, router: router, + analytics: analytics, config: config, connectivity: connectivity, manager: DownloadManagerMock(), @@ -196,7 +210,9 @@ final class CourseContainerViewModelTests: XCTestCase { func testGetCourseBlocksNoCacheError() async throws { let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let connectivity = ConnectivityProtocolMock() @@ -204,7 +220,9 @@ final class CourseContainerViewModelTests: XCTestCase { let viewModel = CourseContainerViewModel( interactor: interactor, + authInteractor: authInteractor, router: router, + analytics: analytics, config: config, connectivity: connectivity, manager: DownloadManagerMock(), @@ -229,7 +247,9 @@ final class CourseContainerViewModelTests: XCTestCase { func testGetCourseBlocksUnknownError() async throws { let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let connectivity = ConnectivityProtocolMock() @@ -237,7 +257,9 @@ final class CourseContainerViewModelTests: XCTestCase { let viewModel = CourseContainerViewModel( interactor: interactor, + authInteractor: authInteractor, router: router, + analytics: analytics, config: config, connectivity: connectivity, manager: DownloadManagerMock(), @@ -259,4 +281,42 @@ final class CourseContainerViewModelTests: XCTestCase { XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) XCTAssertNil(viewModel.courseStructure) } + + func testTabSelectedAnalytics() { + let interactor = CourseInteractorProtocolMock() + let authInteractor = AuthInteractorProtocolMock() + let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() + let config = ConfigMock() + let connectivity = ConnectivityProtocolMock() + + Given(connectivity, .isInternetAvaliable(getter: true)) + + let viewModel = CourseContainerViewModel( + interactor: interactor, + authInteractor: authInteractor, + router: router, + analytics: analytics, + config: config, + connectivity: connectivity, + manager: DownloadManagerMock(), + isActive: true, + courseStart: Date(), + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil + ) + + viewModel.trackSelectedTab(selection: .course, courseId: "1", courseName: "name") + Verify(analytics, .courseOutlineCourseTabClicked(courseId: .value("1"), courseName: .value("name"))) + + viewModel.trackSelectedTab(selection: .videos, courseId: "1", courseName: "name") + Verify(analytics, .courseOutlineVideosTabClicked(courseId: .value("1"), courseName: .value("name"))) + + viewModel.trackSelectedTab(selection: .discussion, courseId: "1", courseName: "name") + Verify(analytics, .courseOutlineDiscussionTabClicked(courseId: .value("1"), courseName: .value("name"))) + + viewModel.trackSelectedTab(selection: .handounds, courseId: "1", courseName: "name") + Verify(analytics, .courseOutlineHandoutsTabClicked(courseId: .value("1"), courseName: .value("name"))) + } } diff --git a/Course/CourseTests/Presentation/Details/CourseDetailsViewModelTests.swift b/Course/CourseTests/Presentation/Details/CourseDetailsViewModelTests.swift index d2cf1835e..108fc104b 100644 --- a/Course/CourseTests/Presentation/Details/CourseDetailsViewModelTests.swift +++ b/Course/CourseTests/Presentation/Details/CourseDetailsViewModelTests.swift @@ -17,6 +17,7 @@ final class CourseDetailsViewModelTests: XCTestCase { func testGetCourseDetailSuccess() async throws { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let cssInjector = CSSInjectorMock() let connectivity = ConnectivityProtocolMock() @@ -25,37 +26,11 @@ final class CourseDetailsViewModelTests: XCTestCase { let viewModel = CourseDetailsViewModel(interactor: interactor, router: router, + analytics: analytics, config: config, cssInjector: cssInjector, connectivity: connectivity) - let items = [ - CourseItem(name: "Test", - org: "org", - shortDescription: "", - imageURL: "", - isActive: true, - courseStart: Date(), - courseEnd: nil, - enrollmentStart: Date(), - enrollmentEnd: Date(), - courseID: "123", - numPages: 2, - coursesCount: 2), - CourseItem(name: "Test2", - org: "org2", - shortDescription: "", - imageURL: "", - isActive: true, - courseStart: Date(), - courseEnd: nil, - enrollmentStart: Date(), - enrollmentEnd: Date(), - courseID: "1243", - numPages: 1, - coursesCount: 2) - ] - let courseDetails = CourseDetails( courseID: "123", org: "org", @@ -67,7 +42,8 @@ final class CourseDetailsViewModelTests: XCTestCase { enrollmentEnd: nil, isEnrolled: true, overviewHTML: "", - courseBannerURL: "" + courseBannerURL: "", + courseVideoURL: nil ) @@ -86,6 +62,7 @@ final class CourseDetailsViewModelTests: XCTestCase { func testGetCourseDetailSuccessOffline() async throws { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let cssInjector = CSSInjectorMock() let connectivity = ConnectivityProtocolMock() @@ -94,37 +71,11 @@ final class CourseDetailsViewModelTests: XCTestCase { let viewModel = CourseDetailsViewModel(interactor: interactor, router: router, + analytics: analytics, config: config, cssInjector: cssInjector, connectivity: connectivity) - let items = [ - CourseItem(name: "Test", - org: "org", - shortDescription: "", - imageURL: "", - isActive: true, - courseStart: Date(), - courseEnd: nil, - enrollmentStart: Date(), - enrollmentEnd: Date(), - courseID: "123", - numPages: 2, - coursesCount: 2), - CourseItem(name: "Test2", - org: "org2", - shortDescription: "", - imageURL: "", - isActive: true, - courseStart: Date(), - courseEnd: nil, - enrollmentStart: Date(), - enrollmentEnd: Date(), - courseID: "1243", - numPages: 1, - coursesCount: 2) - ] - let courseDetails = CourseDetails( courseID: "123", org: "org", @@ -136,7 +87,8 @@ final class CourseDetailsViewModelTests: XCTestCase { enrollmentEnd: nil, isEnrolled: true, overviewHTML: "", - courseBannerURL: "" + courseBannerURL: "", + courseVideoURL: nil ) Given(interactor, .getCourseDetailsOffline(courseID: "123", @@ -154,6 +106,7 @@ final class CourseDetailsViewModelTests: XCTestCase { func testGetCourseDetailNoInternetError() async throws { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let cssInjector = CSSInjectorMock() let connectivity = ConnectivityProtocolMock() @@ -162,6 +115,7 @@ final class CourseDetailsViewModelTests: XCTestCase { let viewModel = CourseDetailsViewModel(interactor: interactor, router: router, + analytics: analytics, config: config, cssInjector: cssInjector, connectivity: connectivity) @@ -183,6 +137,7 @@ final class CourseDetailsViewModelTests: XCTestCase { func testGetCourseDetailNoCacheError() async throws { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let cssInjector = CSSInjectorMock() let connectivity = ConnectivityProtocolMock() @@ -191,6 +146,7 @@ final class CourseDetailsViewModelTests: XCTestCase { let viewModel = CourseDetailsViewModel(interactor: interactor, router: router, + analytics: analytics, config: config, cssInjector: cssInjector, connectivity: connectivity) @@ -210,6 +166,7 @@ final class CourseDetailsViewModelTests: XCTestCase { func testGetCourseDetailUnknownError() async throws { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let cssInjector = CSSInjectorMock() let connectivity = ConnectivityProtocolMock() @@ -218,6 +175,7 @@ final class CourseDetailsViewModelTests: XCTestCase { let viewModel = CourseDetailsViewModel(interactor: interactor, router: router, + analytics: analytics, config: config, cssInjector: cssInjector, connectivity: connectivity) @@ -237,6 +195,7 @@ final class CourseDetailsViewModelTests: XCTestCase { func testEnrollToCourseSuccess() async throws { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let cssInjector = CSSInjectorMock() let connectivity = ConnectivityProtocolMock() @@ -245,6 +204,7 @@ final class CourseDetailsViewModelTests: XCTestCase { let viewModel = CourseDetailsViewModel(interactor: interactor, router: router, + analytics: analytics, config: config, cssInjector: cssInjector, connectivity: connectivity) @@ -254,6 +214,8 @@ final class CourseDetailsViewModelTests: XCTestCase { await viewModel.enrollToCourse(id: "123") Verify(interactor, 1, .enrollToCourse(courseID: .any)) + Verify(analytics, .courseEnrollClicked(courseId: .any, courseName: .any)) + Verify(analytics, .courseEnrollSuccess(courseId: .any, courseName: .any)) XCTAssertFalse(viewModel.isShowProgress) XCTAssertNil(viewModel.errorMessage) @@ -263,6 +225,7 @@ final class CourseDetailsViewModelTests: XCTestCase { func testEnrollToCourseUnknownError() async throws { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let cssInjector = CSSInjectorMock() let connectivity = ConnectivityProtocolMock() @@ -271,6 +234,7 @@ final class CourseDetailsViewModelTests: XCTestCase { let viewModel = CourseDetailsViewModel(interactor: interactor, router: router, + analytics: analytics, config: config, cssInjector: cssInjector, connectivity: connectivity) @@ -281,6 +245,7 @@ final class CourseDetailsViewModelTests: XCTestCase { await viewModel.enrollToCourse(id: "123") Verify(interactor, 1, .enrollToCourse(courseID: .any)) + Verify(analytics, .courseEnrollClicked(courseId: .any, courseName: .any)) XCTAssertFalse(viewModel.isShowProgress) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) @@ -290,6 +255,7 @@ final class CourseDetailsViewModelTests: XCTestCase { func testEnrollToCourseNoInternetError() async throws { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let cssInjector = CSSInjectorMock() let connectivity = ConnectivityProtocolMock() @@ -298,6 +264,7 @@ final class CourseDetailsViewModelTests: XCTestCase { let viewModel = CourseDetailsViewModel(interactor: interactor, router: router, + analytics: analytics, config: config, cssInjector: cssInjector, connectivity: connectivity) @@ -319,6 +286,7 @@ final class CourseDetailsViewModelTests: XCTestCase { func testEnrollToCourseNoCacheError() async throws { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() + let analytics = CourseAnalyticsMock() let config = ConfigMock() let cssInjector = CSSInjectorMock() let connectivity = ConnectivityProtocolMock() @@ -327,6 +295,7 @@ final class CourseDetailsViewModelTests: XCTestCase { let viewModel = CourseDetailsViewModel(interactor: interactor, router: router, + analytics: analytics, config: config, cssInjector: cssInjector, connectivity: connectivity) diff --git a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift index c5b39f7c9..7c5be54e4 100644 --- a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift @@ -14,24 +14,14 @@ import SwiftUI final class CourseUnitViewModelTests: XCTestCase { - let blocks = [ + static let blocks = [ CourseBlock(blockId: "1", id: "1", topicId: "1", graded: false, completion: 0, - type: .course, - displayName: "One", - studentUrl: "", - videoUrl: nil, - youTubeUrl: nil), - CourseBlock(blockId: "1", - id: "1", - topicId: "1", - graded: false, - completion: 0, - type: .course, - displayName: "One", + type: .video, + displayName: "Lesson 1", studentUrl: "", videoUrl: nil, youTubeUrl: nil), @@ -40,9 +30,9 @@ final class CourseUnitViewModelTests: XCTestCase { topicId: "2", graded: false, completion: 0, - type: .html, - displayName: "Two", - studentUrl: "", + type: .video, + displayName: "Lesson 2", + studentUrl: "2", videoUrl: nil, youTubeUrl: nil), CourseBlock(blockId: "3", @@ -50,9 +40,9 @@ final class CourseUnitViewModelTests: XCTestCase { topicId: "3", graded: false, completion: 0, - type: .discussion, - displayName: "Three", - studentUrl: "", + type: .unknown, + displayName: "Lesson 3", + studentUrl: "3", videoUrl: nil, youTubeUrl: nil), CourseBlock(blockId: "4", @@ -60,57 +50,75 @@ final class CourseUnitViewModelTests: XCTestCase { topicId: "4", graded: false, completion: 0, - type: .video, - displayName: "Four", - studentUrl: "", - videoUrl: "url", - youTubeUrl: "url"), - CourseBlock(blockId: "5", - id: "5", - topicId: "5", - graded: false, - completion: 0, - type: .video, - displayName: "Five", - studentUrl: "", - videoUrl: "url", - youTubeUrl: nil), - CourseBlock(blockId: "6", - id: "6", - topicId: "6", - graded: false, - completion: 0, - type: .video, - displayName: "Six", - studentUrl: "", + type: .unknown, + displayName: "4", + studentUrl: "4", videoUrl: nil, youTubeUrl: nil), - CourseBlock(blockId: "7", - id: "7", - topicId: "7", - graded: false, - completion: 0, - type: .problem, - displayName: "Seven", - studentUrl: "", - videoUrl: nil, - youTubeUrl: nil) ] + let chapters = [ + CourseChapter( + blockId: "0", + id: "0", + displayName: "0", + type: .chapter, + childs: [ + CourseSequential(blockId: "5", + id: "5", + displayName: "5", + type: .sequential, + completion: 0, + childs: [ + CourseVertical(blockId: "6", id: "6", + displayName: "6", + type: .vertical, + completion: 0, + childs: blocks) + ]) + + ]), + CourseChapter( + blockId: "2", + id: "2", + displayName: "2", + type: .chapter, + childs: [ + CourseSequential(blockId: "3", + id: "3", + displayName: "3", + type: .sequential, + completion: 0, + childs: [ + CourseVertical(blockId: "4", id: "4", + displayName: "4", + type: .vertical, + completion: 0, + childs: blocks) + ]) + + ]) + ] + func testBlockCompletionRequestSuccess() async throws { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() + let analytics = CourseAnalyticsMock() - let viewModel = CourseUnitViewModel( - lessonID: "123", - courseID: "456", - blocks: blocks, - interactor: interactor, - router: router, - connectivity: connectivity, - manager: DownloadManagerMock() - ) + let viewModel = CourseUnitViewModel(lessonID: "123", + courseID: "456", + id: "789", + courseName: "name", + chapters: chapters, + chapterIndex: 0, + sequentialIndex: 0, + verticalIndex: 0, + interactor: interactor, + router: router, + analytics: analytics, + connectivity: connectivity, + manager: DownloadManagerMock()) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willProduce: {_ in})) @@ -123,16 +131,21 @@ final class CourseUnitViewModelTests: XCTestCase { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() + let analytics = CourseAnalyticsMock() - let viewModel = CourseUnitViewModel( - lessonID: "123", - courseID: "456", - blocks: blocks, - interactor: interactor, - router: router, - connectivity: connectivity, - manager: DownloadManagerMock() - ) + let viewModel = CourseUnitViewModel(lessonID: "123", + courseID: "456", + id: "789", + courseName: "name", + chapters: chapters, + chapterIndex: 0, + sequentialIndex: 0, + verticalIndex: 0, + interactor: interactor, + router: router, + analytics: analytics, + connectivity: connectivity, + manager: DownloadManagerMock()) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, @@ -150,16 +163,21 @@ final class CourseUnitViewModelTests: XCTestCase { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() + let analytics = CourseAnalyticsMock() - let viewModel = CourseUnitViewModel( - lessonID: "123", - courseID: "456", - blocks: blocks, - interactor: interactor, - router: router, - connectivity: connectivity, - manager: DownloadManagerMock() - ) + let viewModel = CourseUnitViewModel(lessonID: "123", + courseID: "456", + id: "789", + courseName: "name", + chapters: chapters, + chapterIndex: 0, + sequentialIndex: 0, + verticalIndex: 0, + interactor: interactor, + router: router, + analytics: analytics, + connectivity: connectivity, + manager: DownloadManagerMock()) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -179,16 +197,21 @@ final class CourseUnitViewModelTests: XCTestCase { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() + let analytics = CourseAnalyticsMock() - let viewModel = CourseUnitViewModel( - lessonID: "123", - courseID: "456", - blocks: blocks, - interactor: interactor, - router: router, - connectivity: connectivity, - manager: DownloadManagerMock() - ) + let viewModel = CourseUnitViewModel(lessonID: "123", + courseID: "456", + id: "789", + courseName: "name", + chapters: chapters, + chapterIndex: 0, + sequentialIndex: 0, + verticalIndex: 0, + interactor: interactor, + router: router, + analytics: analytics, + connectivity: connectivity, + manager: DownloadManagerMock()) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, @@ -207,31 +230,37 @@ final class CourseUnitViewModelTests: XCTestCase { let interactor = CourseInteractorProtocolMock() let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() + let analytics = CourseAnalyticsMock() - let viewModel = CourseUnitViewModel( - lessonID: "123", - courseID: "456", - blocks: blocks, - interactor: interactor, - router: router, - connectivity: connectivity, - manager: DownloadManagerMock() - ) + let viewModel = CourseUnitViewModel(lessonID: "123", + courseID: "456", + id: "789", + courseName: "name", + chapters: chapters, + chapterIndex: 0, + sequentialIndex: 0, + verticalIndex: 0, + interactor: interactor, + router: router, + analytics: analytics, + connectivity: connectivity, + manager: DownloadManagerMock()) viewModel.loadIndex() - for _ in 0...blocks.count - 1 { + for _ in 0...CourseUnitViewModelTests.blocks.count - 1 { viewModel.select(move: .next) - viewModel.createLessonType() } - XCTAssertEqual(viewModel.index, 7) + Verify(analytics, .nextBlockClicked(courseId: .any, courseName: .any, blockId: .any, blockName: .any)) + + XCTAssertEqual(viewModel.index, 3) - for _ in 0...blocks.count - 1 { + for _ in 0...CourseUnitViewModelTests.blocks.count - 1 { viewModel.select(move: .previous) - viewModel.createLessonType() } + Verify(analytics, .prevBlockClicked(courseId: .any, courseName: .any, blockId: .any, blockName: .any)) XCTAssertEqual(viewModel.index, 0) } diff --git a/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift b/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift index 763a5c420..83295daf7 100644 --- a/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift @@ -31,15 +31,18 @@ final class VideoPlayerViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - Given(interactor, .getSubtitles(url: .any, willReturn: subtitles)) + Given(interactor, .getSubtitles(url: .any, selectedLanguage: .any, willReturn: subtitles)) - let viewModel = VideoPlayerViewModel(interactor: interactor, + let viewModel = VideoPlayerViewModel(blockID: "", + courseID: "", + languages: [], + interactor: interactor, router: router, connectivity: connectivity) await viewModel.getSubtitles(subtitlesUrl: "url") - Verify(interactor, .getSubtitles(url: .any)) + Verify(interactor, .getSubtitles(url: .any, selectedLanguage: .any)) XCTAssertEqual(viewModel.subtitles.first!.text, subtitles.first!.text) XCTAssertNil(viewModel.errorMessage) @@ -54,15 +57,18 @@ final class VideoPlayerViewModelTests: XCTestCase { Given(connectivity, .isInternetAvaliable(getter: false)) - Given(interactor, .getSubtitles(url: .any, willReturn: subtitles)) + Given(interactor, .getSubtitles(url: .any, selectedLanguage: .any, willReturn: subtitles)) - let viewModel = VideoPlayerViewModel(interactor: interactor, + let viewModel = VideoPlayerViewModel(blockID: "", + courseID: "", + languages: [], + interactor: interactor, router: router, connectivity: connectivity) await viewModel.getSubtitles(subtitlesUrl: "url") - Verify(interactor, .getSubtitles(url: .any)) + Verify(interactor, .getSubtitles(url: .any, selectedLanguage: .any)) XCTAssertEqual(viewModel.subtitles.first!.text, subtitles.first!.text) XCTAssertNil(viewModel.errorMessage) @@ -74,7 +80,10 @@ final class VideoPlayerViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = VideoPlayerViewModel(interactor: interactor, + let viewModel = VideoPlayerViewModel(blockID: "", + courseID: "", + languages: [], + interactor: interactor, router: router, connectivity: connectivity) @@ -83,7 +92,7 @@ final class VideoPlayerViewModelTests: XCTestCase { SubtitleUrl(language: "uk", url: "url2") ] - Given(interactor, .getSubtitles(url: .any, willReturn: subtitles)) + Given(interactor, .getSubtitles(url: .any, selectedLanguage: .any, willReturn: subtitles)) await viewModel.getSubtitles(subtitlesUrl: "url") viewModel.prepareLanguages() @@ -98,13 +107,16 @@ final class VideoPlayerViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = VideoPlayerViewModel(interactor: interactor, + let viewModel = VideoPlayerViewModel(blockID: "", + courseID: "", + languages: [], + interactor: interactor, router: router, connectivity: connectivity) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willProduce: {_ in})) - await viewModel.blockCompletionRequest(blockID: "123", courseID: "123") + await viewModel.blockCompletionRequest() Verify(interactor, .blockCompletionRequest(courseID: .any, blockID: .any)) } @@ -114,13 +126,16 @@ final class VideoPlayerViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = VideoPlayerViewModel(interactor: interactor, + let viewModel = VideoPlayerViewModel(blockID: "", + courseID: "", + languages: [], + interactor: interactor, router: router, connectivity: connectivity) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willThrow: NSError())) - await viewModel.blockCompletionRequest(blockID: "123", courseID: "123") + await viewModel.blockCompletionRequest() Verify(interactor, .blockCompletionRequest(courseID: .any, blockID: .any)) @@ -135,13 +150,16 @@ final class VideoPlayerViewModelTests: XCTestCase { let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) - let viewModel = VideoPlayerViewModel(interactor: interactor, + let viewModel = VideoPlayerViewModel(blockID: "", + courseID: "", + languages: [], + interactor: interactor, router: router, connectivity: connectivity) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willThrow: noInternetError)) - await viewModel.blockCompletionRequest(blockID: "123", courseID: "123") + await viewModel.blockCompletionRequest() Verify(interactor, .blockCompletionRequest(courseID: .any, blockID: .any)) diff --git a/Dashboard/Dashboard.xcodeproj.xcworkspace/contents.xcworkspacedata b/Dashboard/Dashboard.xcodeproj.xcworkspace/contents.xcworkspacedata index 33772a869..fe487fea7 100644 --- a/Dashboard/Dashboard.xcodeproj.xcworkspace/contents.xcworkspacedata +++ b/Dashboard/Dashboard.xcodeproj.xcworkspace/contents.xcworkspacedata @@ -14,7 +14,7 @@ location = "group:../Discovery/Discovery.xcodeproj"> + location = "group:../OpenEdX.xcodeproj"> diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index f177c68a8..4aee39a87 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 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 */; }; + 02F175332A4DABBF0019CD70 /* DashboardAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175322A4DABBF0019CD70 /* DashboardAnalytics.swift */; }; 02F3BFE129252FCB0051930C /* DashboardRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFE029252FCB0051930C /* DashboardRouter.swift */; }; 02F6EF3D28D9EB8C00835477 /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 02F6EF3C28D9EB8C00835477 /* swiftgen.yml */; }; 02F6EF4328D9ECC500835477 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 02F6EF4528D9ECC500835477 /* Localizable.strings */; }; @@ -50,6 +51,7 @@ 02A9A92829781A4D00B55797 /* DashboardMock.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DashboardMock.generated.swift; sourceTree = ""; }; 02ED50CD29A64B9B008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02EF39E728D89F560058F6BD /* Dashboard.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Dashboard.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 02F175322A4DABBF0019CD70 /* DashboardAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnalytics.swift; sourceTree = ""; }; 02F3BFE029252FCB0051930C /* DashboardRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardRouter.swift; sourceTree = ""; }; 02F6EF3C28D9EB8C00835477 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; 02F6EF4428D9ECC500835477 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; @@ -179,6 +181,7 @@ 027DB33228D8BDBA002B6862 /* DashboardView.swift */, 027DB34428D8E9D2002B6862 /* DashboardViewModel.swift */, 02F3BFE029252FCB0051930C /* DashboardRouter.swift */, + 02F175322A4DABBF0019CD70 /* DashboardAnalytics.swift */, ); path = Presentation; sourceTree = ""; @@ -443,6 +446,7 @@ 02A48B1A295ACE3D0033D5E0 /* DashboardPersistence.swift in Sources */, 027DB33D28D8DB5E002B6862 /* DashboardRepository.swift in Sources */, 02A48B18295ACE200033D5E0 /* DashboardCoreModel.xcdatamodeld in Sources */, + 02F175332A4DABBF0019CD70 /* DashboardAnalytics.swift in Sources */, 027DB34528D8E9D2002B6862 /* DashboardViewModel.swift in Sources */, 027DB33328D8BDBA002B6862 /* DashboardView.swift in Sources */, 02F3BFE129252FCB0051930C /* DashboardRouter.swift in Sources */, @@ -482,12 +486,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DashboardTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -503,12 +507,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DashboardTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -524,12 +528,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DashboardTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -545,12 +549,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DashboardTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -566,12 +570,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DashboardTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -587,12 +591,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DashboardTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -688,7 +692,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Dashboard; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Dashboard; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -707,12 +711,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DashboardTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -802,7 +806,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Dashboard; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Dashboard; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -820,12 +824,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DashboardTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -979,7 +983,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Dashboard; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Dashboard; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1014,7 +1018,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Dashboard; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Dashboard; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1112,7 +1116,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Dashboard; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Dashboard; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1205,7 +1209,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Dashboard; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Dashboard; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1303,7 +1307,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Dashboard; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Dashboard; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1396,7 +1400,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Dashboard; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Dashboard; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/Dashboard/Dashboard/Presentation/DashboardAnalytics.swift b/Dashboard/Dashboard/Presentation/DashboardAnalytics.swift new file mode 100644 index 000000000..d5edf28d5 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/DashboardAnalytics.swift @@ -0,0 +1,19 @@ +// +// DashboardAnalytics.swift +// Dashboard +// +// Created by  Stepanok Ivan on 29.06.2023. +// + +import Foundation + +//sourcery: AutoMockable +public protocol DashboardAnalytics { + func dashboardCourseClicked(courseID: String, courseName: String) +} + +#if DEBUG +class DashboardAnalyticsMock: DashboardAnalytics { + public func dashboardCourseClicked(courseID: String, courseName: String) {} +} +#endif diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/DashboardView.swift index cc3430c26..7ddc40b05 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/DashboardView.swift @@ -39,16 +39,13 @@ public struct DashboardView: View { ZStack { Text(DashboardLocalization.title) .titleSettings() - } ZStack { RefreshableScrollViewCompat(action: { - await viewModel.getMyCourses(page: 1, - withProgress: isIOS14, - refresh: true) + await viewModel.getMyCourses(page: 1, refresh: true) }) { - if viewModel.courses.isEmpty { + if viewModel.courses.isEmpty && !viewModel.fetchInProgress { EmptyPageIcon() } else { LazyVStack(spacing: 0) { @@ -75,6 +72,7 @@ public struct DashboardView: View { } } .onTapGesture { + viewModel.dashboardCourseClicked(courseID: course.courseID, courseName: course.name) router.showCourseScreens( courseID: course.courseID, isActive: course.isActive, @@ -104,9 +102,7 @@ public struct DashboardView: View { // MARK: - Offline mode SnackBar OfflineSnackBarView(connectivity: viewModel.connectivity, reloadAction: { - await viewModel.getMyCourses( page: 1, - withProgress: isIOS14, - refresh: true) + await viewModel.getMyCourses(page: 1, refresh: true) }) // MARK: - Error Alert @@ -137,7 +133,8 @@ struct DashboardView_Previews: PreviewProvider { static var previews: some View { let vm = DashboardViewModel( interactor: DashboardInteractor.mock, - connectivity: Connectivity() + connectivity: Connectivity(), + analytics: DashboardAnalyticsMock() ) let router = DashboardRouterMock() diff --git a/Dashboard/Dashboard/Presentation/DashboardViewModel.swift b/Dashboard/Dashboard/Presentation/DashboardViewModel.swift index 1b5711508..18de4b976 100644 --- a/Dashboard/Dashboard/Presentation/DashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/DashboardViewModel.swift @@ -14,7 +14,7 @@ public class DashboardViewModel: ObservableObject { public var nextPage = 1 public var totalPages = 1 - public private(set) var fetchInProgress = false + @Published public private(set) var fetchInProgress = false @Published var courses: [CourseItem] = [] @Published var showError: Bool = false @@ -26,14 +26,17 @@ public class DashboardViewModel: ObservableObject { } } + let connectivity: ConnectivityProtocol private let interactor: DashboardInteractorProtocol - public let connectivity: ConnectivityProtocol - + private let analytics: DashboardAnalytics private var onCourseEnrolledCancellable: AnyCancellable? - public init(interactor: DashboardInteractorProtocol, connectivity: ConnectivityProtocol) { + public init(interactor: DashboardInteractorProtocol, + connectivity: ConnectivityProtocol, + analytics: DashboardAnalytics) { self.interactor = interactor self.connectivity = connectivity + self.analytics = analytics onCourseEnrolledCancellable = NotificationCenter.default .publisher(for: .onCourseEnrolled) @@ -46,8 +49,9 @@ public class DashboardViewModel: ObservableObject { } @MainActor - public func getMyCourses(page: Int, withProgress: Bool = true, refresh: Bool = false) async { + public func getMyCourses(page: Int, refresh: Bool = false) async { do { + fetchInProgress = true if connectivity.isInternetAvaliable { if refresh { courses = try await interactor.getMyCourses(page: page) @@ -77,17 +81,21 @@ public class DashboardViewModel: ObservableObject { } @MainActor - public func getMyCoursesPagination(index: Int, withProgress: Bool = true) async { + public func getMyCoursesPagination(index: Int) async { if !fetchInProgress { if totalPages > 1 { if index == courses.count - 3 { if totalPages != 1 { if nextPage <= totalPages { - await getMyCourses(page: self.nextPage, withProgress: withProgress) + await getMyCourses(page: self.nextPage) } } } } } } + + func dashboardCourseClicked(courseID: String, courseName: String) { + analytics.dashboardCourseClicked(courseID: courseID, courseName: courseName) + } } diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index d8e9e8131..551f38c47 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -121,17 +121,20 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } - open func registerUser(fields: [String: String]) throws { + open func registerUser(fields: [String: String]) throws -> User { addInvocation(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))) let perform = methodPerformValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))) as? ([String: String]) -> Void perform?(`fields`) + var __value: User do { - _ = try methodReturnValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() as Void + __value = try methodReturnValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() } catch MockError.notStubed { - // do nothing + onFatalFailure("Stub return value not specified for registerUser(fields: [String: String]). Use given") + Failure("Stub return value not specified for registerUser(fields: [String: String]). Use given") } catch { throw error } + return __value } open func validateRegistrationFields(fields: [String: String]) throws -> [String: String] { @@ -233,6 +236,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func getRegistrationFields(willReturn: [PickerFields]...) -> MethodStub { return Given(method: .m_getRegistrationFields, products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func registerUser(fields: Parameter<[String: String]>, willReturn: User...) -> MethodStub { + return Given(method: .m_registerUser__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func validateRegistrationFields(fields: Parameter<[String: String]>, willReturn: [String: String]...) -> MethodStub { return Given(method: .m_validateRegistrationFields__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -281,10 +287,10 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func registerUser(fields: Parameter<[String: String]>, willThrow: Error...) -> MethodStub { return Given(method: .m_registerUser__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func registerUser(fields: Parameter<[String: String]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func registerUser(fields: Parameter<[String: String]>, willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] let given: Given = { return Given(method: .m_registerUser__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + let stubber = given.stubThrows(for: (User).self) willProduce(stubber) return given } @@ -514,10 +520,10 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`) } - open func presentAlert(alertTitle: String, alertMessage: String, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void) { - addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) - let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) as? (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void - perform?(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`) + open func presentAlert(alertTitle: String, alertMessage: String, nextSectionName: String?, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, nextSectionTapped: @escaping () -> Void) { + addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) + let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) as? (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void + perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { @@ -544,7 +550,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showRegisterScreen case m_showForgotPasswordScreen case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) - case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>) + case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) case m_presentView__transitionStyle_transitionStylecontent_content(Parameter, Parameter<() -> any View>) @@ -590,14 +596,16 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) return Matcher.ComparisonResult(results) - case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped)): + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsNextsectionname, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped, let lhsNextsectiontapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsNextsectionname, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped, let rhsNextsectiontapped)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlertmessage, rhs: rhsAlertmessage, with: matcher), lhsAlertmessage, rhsAlertmessage, "alertMessage")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectionname, rhs: rhsNextsectionname, with: matcher), lhsNextsectionname, rhsNextsectionname, "nextSectionName")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsImage, rhs: rhsImage, with: matcher), lhsImage, rhsImage, "image")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOnclosetapped, rhs: rhsOnclosetapped, with: matcher), lhsOnclosetapped, rhsOnclosetapped, "onCloseTapped")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOktapped, rhs: rhsOktapped, with: matcher), lhsOktapped, rhsOktapped, "okTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): @@ -627,7 +635,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue - case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue case let .m_presentView__transitionStyle_transitionStylecontent_content(p0, p1): return p0.intValue + p1.intValue } @@ -644,7 +652,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" - case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped: return ".presentAlert(alertTitle:alertMessage:action:image:onCloseTapped:okTapped:)" + case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" case .m_presentView__transitionStyle_transitionStylecontent_content: return ".presentView(transitionStyle:content:)" } @@ -675,7 +683,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`))} + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} public static func presentView(transitionStyle: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStylecontent_content(`transitionStyle`, `content`))} } @@ -714,8 +722,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, perform: @escaping (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { - return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`), performs: perform) + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { + return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) @@ -994,6 +1002,181 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - DashboardAnalytics + +open class DashboardAnalyticsMock: DashboardAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func dashboardCourseClicked(courseID: String, courseName: String) { + addInvocation(.m_dashboardCourseClicked__courseID_courseIDcourseName_courseName(Parameter.value(`courseID`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_dashboardCourseClicked__courseID_courseIDcourseName_courseName(Parameter.value(`courseID`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseID`, `courseName`) + } + + + fileprivate enum MethodType { + case m_dashboardCourseClicked__courseID_courseIDcourseName_courseName(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_dashboardCourseClicked__courseID_courseIDcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_dashboardCourseClicked__courseID_courseIDcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_dashboardCourseClicked__courseID_courseIDcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_dashboardCourseClicked__courseID_courseIDcourseName_courseName: return ".dashboardCourseClicked(courseID:courseName:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func dashboardCourseClicked(courseID: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_dashboardCourseClicked__courseID_courseIDcourseName_courseName(`courseID`, `courseName`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func dashboardCourseClicked(courseID: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_dashboardCourseClicked__courseID_courseIDcourseName_courseName(`courseID`, `courseName`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DashboardInteractorProtocol open class DashboardInteractorProtocolMock: DashboardInteractorProtocol, Mock { diff --git a/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift b/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift index 8fda4d939..d3261b52d 100644 --- a/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift +++ b/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift @@ -17,7 +17,8 @@ final class DashboardViewModelTests: XCTestCase { func testGetMyCoursesSuccess() async throws { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity) + let analytics = DashboardAnalyticsMock() + let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) let items = [ CourseItem(name: "Test", @@ -61,7 +62,8 @@ final class DashboardViewModelTests: XCTestCase { func testGetMyCoursesOfflineSuccess() async throws { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity) + let analytics = DashboardAnalyticsMock() + let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) let items = [ CourseItem(name: "Test", @@ -105,7 +107,8 @@ final class DashboardViewModelTests: XCTestCase { func testGetMyCoursesNoCacheError() async throws { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity) + let analytics = DashboardAnalyticsMock() + let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .getMyCourses(page: .any, willThrow: NoCachedDataError()) ) @@ -122,7 +125,8 @@ final class DashboardViewModelTests: XCTestCase { func testGetMyCoursesUnknownError() async throws { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity) + let analytics = DashboardAnalyticsMock() + let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .getMyCourses(page: .any, willThrow: NSError()) ) diff --git a/Discovery/Discovery.xcodeproj.xcworkspace/contents.xcworkspacedata b/Discovery/Discovery.xcodeproj.xcworkspace/contents.xcworkspacedata index 9ccd451c4..21947269f 100644 --- a/Discovery/Discovery.xcodeproj.xcworkspace/contents.xcworkspacedata +++ b/Discovery/Discovery.xcodeproj.xcworkspace/contents.xcworkspacedata @@ -11,7 +11,7 @@ location = "group:Discovery.xcodeproj"> + location = "group:../OpenEdX.xcodeproj"> diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index 3099a4e0f..59b0d96ae 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 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 */; }; + 02F1752F2A4DA3B60019CD70 /* DiscoveryAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F1752E2A4DA3B60019CD70 /* DiscoveryAnalytics.swift */; }; 02F3BFDF29252F2F0051930C /* DiscoveryRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFDE29252F2F0051930C /* DiscoveryRouter.swift */; }; 072787AD28D34D15002E9142 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072787AC28D34D15002E9142 /* Core.framework */; }; 072787B428D34D91002E9142 /* DiscoveryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072787B328D34D91002E9142 /* DiscoveryView.swift */; }; @@ -55,6 +56,7 @@ 02EF39D028D867690058F6BD /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; 02EF39D828D86A380058F6BD /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 02EF39DB28D86BEF0058F6BD /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; + 02F1752E2A4DA3B60019CD70 /* DiscoveryAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryAnalytics.swift; sourceTree = ""; }; 02F3BFDE29252F2F0051930C /* DiscoveryRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryRouter.swift; sourceTree = ""; }; 0692409931272CDA39B10321 /* Pods-App-Discovery.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discovery.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Discovery/Pods-App-Discovery.releasestage.xcconfig"; sourceTree = ""; }; 0727879928D34C03002E9142 /* Discovery.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Discovery.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -165,6 +167,7 @@ CFC849422996A5150055E497 /* SearchView.swift */, CFC849442996A52A0055E497 /* SearchViewModel.swift */, 02F3BFDE29252F2F0051930C /* DiscoveryRouter.swift */, + 02F1752E2A4DA3B60019CD70 /* DiscoveryAnalytics.swift */, ); path = Presentation; sourceTree = ""; @@ -466,6 +469,7 @@ 029737422949FB3B0051696B /* DiscoveryPersistence.swift in Sources */, 0284DC0328D4922900830893 /* DiscoveryRepository.swift in Sources */, 02EF39DC28D86BEF0058F6BD /* Strings.swift in Sources */, + 02F1752F2A4DA3B60019CD70 /* DiscoveryAnalytics.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -508,12 +512,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscoveryUnitTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -529,12 +533,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscoveryUnitTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -550,12 +554,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscoveryUnitTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -571,12 +575,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscoveryUnitTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -592,12 +596,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscoveryUnitTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -613,12 +617,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscoveryUnitTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -714,7 +718,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discovery; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discovery; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -733,12 +737,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscoveryUnitTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -828,7 +832,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discovery; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discovery; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -846,12 +850,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscoveryUnitTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1005,7 +1009,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discovery; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discovery; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1040,7 +1044,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discovery; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discovery; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1138,7 +1142,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discovery; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discovery; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1237,7 +1241,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discovery; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discovery; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1330,7 +1334,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discovery; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discovery; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1422,7 +1426,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discovery; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discovery; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift b/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift new file mode 100644 index 000000000..f86ef6341 --- /dev/null +++ b/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift @@ -0,0 +1,23 @@ +// +// DiscoveryAnalytics.swift +// Discovery +// +// Created by  Stepanok Ivan on 29.06.2023. +// + +import Foundation + +//sourcery: AutoMockable +public protocol DiscoveryAnalytics { + func discoverySearchBarClicked() + func discoveryCoursesSearch(label: String, coursesCount: Int) + func discoveryCourseClicked(courseID: String, courseName: String) +} + +#if DEBUG +class DiscoveryAnalyticsMock: DiscoveryAnalytics { + public func discoverySearchBarClicked() {} + public func discoveryCoursesSearch(label: String, coursesCount: Int) {} + public func discoveryCourseClicked(courseID: String, courseName: String) {} +} +#endif diff --git a/Discovery/Discovery/Presentation/DiscoveryView.swift b/Discovery/Discovery/Presentation/DiscoveryView.swift index 492493898..043170fc7 100644 --- a/Discovery/Discovery/Presentation/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/DiscoveryView.swift @@ -22,7 +22,7 @@ public struct DiscoveryView: View { Text(DiscoveryLocalization.Header.title2) .font(Theme.Fonts.titleSmall) .foregroundColor(CoreAssets.textPrimary.swiftUIColor) - }.listRowBackground(Color.clear) + }.listRowBackground(Color.clear) public init(viewModel: DiscoveryViewModel, router: DiscoveryRouter) { self.viewModel = viewModel @@ -38,10 +38,8 @@ public struct DiscoveryView: View { // MARK: - Page name VStack(alignment: .center) { ZStack { - Text(DiscoveryLocalization.title) .titleSettings(top: 10) - } // MARK: - Search fake field @@ -55,6 +53,7 @@ public struct DiscoveryView: View { } .onTapGesture { router.showDiscoverySearch() + viewModel.discoverySearchBarClicked() } .frame(minHeight: 48) .frame(maxWidth: 532) @@ -68,6 +67,7 @@ public struct DiscoveryView: View { .fill(CoreAssets.textInputUnfocusedStroke.swiftUIColor) ).onTapGesture { router.showDiscoverySearch() + viewModel.discoverySearchBarClicked() } .padding(.horizontal, 24) .padding(.bottom, 20) @@ -92,18 +92,19 @@ public struct DiscoveryView: View { type: .discovery, index: index, cellsCount: viewModel.courses.count) - .padding(.horizontal, 24) - .onAppear { - Task { - await viewModel.getDiscoveryCourses(index: index) - } - } - .onTapGesture { - router.showCourseDetais( - courseID: course.courseID, - title: course.name - ) + .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 @@ -152,7 +153,8 @@ public struct DiscoveryView: View { #if DEBUG struct DiscoveryView_Previews: PreviewProvider { static var previews: some View { - let vm = DiscoveryViewModel(interactor: DiscoveryInteractor.mock, connectivity: Connectivity()) + let vm = DiscoveryViewModel(interactor: DiscoveryInteractor.mock, connectivity: Connectivity(), + analytics: DiscoveryAnalyticsMock()) let router = DiscoveryRouterMock() DiscoveryView(viewModel: vm, router: router) diff --git a/Discovery/Discovery/Presentation/DiscoveryViewModel.swift b/Discovery/Discovery/Presentation/DiscoveryViewModel.swift index 53e0f7e32..c99e2e3f8 100644 --- a/Discovery/Discovery/Presentation/DiscoveryViewModel.swift +++ b/Discovery/Discovery/Presentation/DiscoveryViewModel.swift @@ -26,12 +26,16 @@ public class DiscoveryViewModel: ObservableObject { } } - private let interactor: DiscoveryInteractorProtocol let connectivity: ConnectivityProtocol + private let interactor: DiscoveryInteractorProtocol + private let analytics: DiscoveryAnalytics - public init(interactor: DiscoveryInteractorProtocol, connectivity: ConnectivityProtocol) { + public init(interactor: DiscoveryInteractorProtocol, + connectivity: ConnectivityProtocol, + analytics: DiscoveryAnalytics) { self.interactor = interactor self.connectivity = connectivity + self.analytics = analytics } @MainActor @@ -76,4 +80,11 @@ public class DiscoveryViewModel: ObservableObject { } } + func discoveryCourseClicked(courseID: String, courseName: String) { + analytics.discoveryCourseClicked(courseID: courseID, courseName: courseName) + } + + func discoverySearchBarClicked() { + analytics.discoverySearchBarClicked() + } } diff --git a/Discovery/Discovery/Presentation/SearchView.swift b/Discovery/Discovery/Presentation/SearchView.swift index 2af2557ba..b330d8c5e 100644 --- a/Discovery/Discovery/Presentation/SearchView.swift +++ b/Discovery/Discovery/Presentation/SearchView.swift @@ -13,6 +13,7 @@ public struct SearchView: View { @ObservedObject private var viewModel: SearchViewModel @State private var animated: Bool = false + @State private var becomeFirstResponderRunOnce = false public init(viewModel: SearchViewModel) { self.viewModel = viewModel @@ -47,9 +48,12 @@ public struct SearchView: View { viewModel.isSearchActive = editing } ) - .introspectTextField { textField in - textField.becomeFirstResponder() - } + .introspect(.textField, on: .iOS(.v14, .v15, .v16, .v17), customize: { textField in + if !becomeFirstResponderRunOnce { + textField.becomeFirstResponder() + self.becomeFirstResponderRunOnce = true + } + }) .foregroundColor(CoreAssets.textPrimary.swiftUIColor) Spacer() if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { @@ -104,7 +108,10 @@ public struct SearchView: View { .padding(.horizontal, 24) .onAppear { Task { - await viewModel.searchCourses(index: index, searchTerm: viewModel.searchText) + await viewModel.searchCourses( + index: index, + searchTerm: viewModel.searchText + ) } } .onTapGesture { @@ -189,6 +196,7 @@ struct SearchView_Previews: PreviewProvider { interactor: DiscoveryInteractor.mock, connectivity: Connectivity(), router: router, + analytics: DiscoveryAnalyticsMock(), debounce: .searchDebounce ) diff --git a/Discovery/Discovery/Presentation/SearchViewModel.swift b/Discovery/Discovery/Presentation/SearchViewModel.swift index 68f8d61bc..8f0c6ff1c 100644 --- a/Discovery/Discovery/Presentation/SearchViewModel.swift +++ b/Discovery/Discovery/Presentation/SearchViewModel.swift @@ -31,17 +31,20 @@ public class SearchViewModel: ObservableObject { } let router: DiscoveryRouter + let analytics: DiscoveryAnalytics private let interactor: DiscoveryInteractorProtocol let connectivity: ConnectivityProtocol public init(interactor: DiscoveryInteractorProtocol, connectivity: ConnectivityProtocol, router: DiscoveryRouter, + analytics: DiscoveryAnalytics, debounce: Debounce ) { self.interactor = interactor self.connectivity = connectivity self.router = router + self.analytics = analytics self.debounce = debounce $searchText @@ -90,22 +93,26 @@ public class SearchViewModel: ObservableObject { if !searchTerm.trimmingCharacters(in: .whitespaces).isEmpty { var results: [CourseItem] = [] await results = try interactor.search(page: page, searchTerm: searchTerm) + if results.isEmpty { searchResults.removeAll() fetchInProgress = false return } - + if page == 1 { searchResults = results } else { searchResults += results } - + if !searchResults.isEmpty { self.nextPage += 1 totalPages = results[0].numPages } + + analytics.discoveryCoursesSearch(label: searchTerm, + coursesCount: searchResults.first?.coursesCount ?? 0) } fetchInProgress = false diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 77f59e84b..c0c6c1793 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -121,17 +121,20 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } - open func registerUser(fields: [String: String]) throws { + open func registerUser(fields: [String: String]) throws -> User { addInvocation(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))) let perform = methodPerformValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))) as? ([String: String]) -> Void perform?(`fields`) + var __value: User do { - _ = try methodReturnValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() as Void + __value = try methodReturnValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() } catch MockError.notStubed { - // do nothing + onFatalFailure("Stub return value not specified for registerUser(fields: [String: String]). Use given") + Failure("Stub return value not specified for registerUser(fields: [String: String]). Use given") } catch { throw error } + return __value } open func validateRegistrationFields(fields: [String: String]) throws -> [String: String] { @@ -233,6 +236,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func getRegistrationFields(willReturn: [PickerFields]...) -> MethodStub { return Given(method: .m_getRegistrationFields, products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func registerUser(fields: Parameter<[String: String]>, willReturn: User...) -> MethodStub { + return Given(method: .m_registerUser__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func validateRegistrationFields(fields: Parameter<[String: String]>, willReturn: [String: String]...) -> MethodStub { return Given(method: .m_validateRegistrationFields__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -281,10 +287,10 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func registerUser(fields: Parameter<[String: String]>, willThrow: Error...) -> MethodStub { return Given(method: .m_registerUser__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func registerUser(fields: Parameter<[String: String]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func registerUser(fields: Parameter<[String: String]>, willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] let given: Given = { return Given(method: .m_registerUser__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + let stubber = given.stubThrows(for: (User).self) willProduce(stubber) return given } @@ -514,10 +520,10 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`) } - open func presentAlert(alertTitle: String, alertMessage: String, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void) { - addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) - let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) as? (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void - perform?(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`) + open func presentAlert(alertTitle: String, alertMessage: String, nextSectionName: String?, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, nextSectionTapped: @escaping () -> Void) { + addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) + let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) as? (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void + perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { @@ -544,7 +550,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showRegisterScreen case m_showForgotPasswordScreen case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) - case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>) + case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) case m_presentView__transitionStyle_transitionStylecontent_content(Parameter, Parameter<() -> any View>) @@ -590,14 +596,16 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) return Matcher.ComparisonResult(results) - case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped)): + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsNextsectionname, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped, let lhsNextsectiontapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsNextsectionname, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped, let rhsNextsectiontapped)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlertmessage, rhs: rhsAlertmessage, with: matcher), lhsAlertmessage, rhsAlertmessage, "alertMessage")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectionname, rhs: rhsNextsectionname, with: matcher), lhsNextsectionname, rhsNextsectionname, "nextSectionName")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsImage, rhs: rhsImage, with: matcher), lhsImage, rhsImage, "image")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOnclosetapped, rhs: rhsOnclosetapped, with: matcher), lhsOnclosetapped, rhsOnclosetapped, "onCloseTapped")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOktapped, rhs: rhsOktapped, with: matcher), lhsOktapped, rhsOktapped, "okTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): @@ -627,7 +635,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue - case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue case let .m_presentView__transitionStyle_transitionStylecontent_content(p0, p1): return p0.intValue + p1.intValue } @@ -644,7 +652,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" - case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped: return ".presentAlert(alertTitle:alertMessage:action:image:onCloseTapped:okTapped:)" + case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" case .m_presentView__transitionStyle_transitionStylecontent_content: return ".presentView(transitionStyle:content:)" } @@ -675,7 +683,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`))} + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} public static func presentView(transitionStyle: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStylecontent_content(`transitionStyle`, `content`))} } @@ -714,8 +722,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, perform: @escaping (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { - return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`), performs: perform) + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { + return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) @@ -994,6 +1002,216 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - DiscoveryAnalytics + +open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func discoverySearchBarClicked() { + addInvocation(.m_discoverySearchBarClicked) + let perform = methodPerformValue(.m_discoverySearchBarClicked) as? () -> Void + perform?() + } + + open func discoveryCoursesSearch(label: String, coursesCount: Int) { + addInvocation(.m_discoveryCoursesSearch__label_labelcoursesCount_coursesCount(Parameter.value(`label`), Parameter.value(`coursesCount`))) + let perform = methodPerformValue(.m_discoveryCoursesSearch__label_labelcoursesCount_coursesCount(Parameter.value(`label`), Parameter.value(`coursesCount`))) as? (String, Int) -> Void + perform?(`label`, `coursesCount`) + } + + open func discoveryCourseClicked(courseID: String, courseName: String) { + addInvocation(.m_discoveryCourseClicked__courseID_courseIDcourseName_courseName(Parameter.value(`courseID`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_discoveryCourseClicked__courseID_courseIDcourseName_courseName(Parameter.value(`courseID`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseID`, `courseName`) + } + + + fileprivate enum MethodType { + case m_discoverySearchBarClicked + case m_discoveryCoursesSearch__label_labelcoursesCount_coursesCount(Parameter, Parameter) + case m_discoveryCourseClicked__courseID_courseIDcourseName_courseName(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_discoverySearchBarClicked, .m_discoverySearchBarClicked): return .match + + case (.m_discoveryCoursesSearch__label_labelcoursesCount_coursesCount(let lhsLabel, let lhsCoursescount), .m_discoveryCoursesSearch__label_labelcoursesCount_coursesCount(let rhsLabel, let rhsCoursescount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsLabel, rhs: rhsLabel, with: matcher), lhsLabel, rhsLabel, "label")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursescount, rhs: rhsCoursescount, with: matcher), lhsCoursescount, rhsCoursescount, "coursesCount")) + return Matcher.ComparisonResult(results) + + case (.m_discoveryCourseClicked__courseID_courseIDcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_discoveryCourseClicked__courseID_courseIDcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_discoverySearchBarClicked: return 0 + case let .m_discoveryCoursesSearch__label_labelcoursesCount_coursesCount(p0, p1): return p0.intValue + p1.intValue + case let .m_discoveryCourseClicked__courseID_courseIDcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_discoverySearchBarClicked: return ".discoverySearchBarClicked()" + case .m_discoveryCoursesSearch__label_labelcoursesCount_coursesCount: return ".discoveryCoursesSearch(label:coursesCount:)" + case .m_discoveryCourseClicked__courseID_courseIDcourseName_courseName: return ".discoveryCourseClicked(courseID:courseName:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func discoverySearchBarClicked() -> Verify { return Verify(method: .m_discoverySearchBarClicked)} + public static func discoveryCoursesSearch(label: Parameter, coursesCount: Parameter) -> Verify { return Verify(method: .m_discoveryCoursesSearch__label_labelcoursesCount_coursesCount(`label`, `coursesCount`))} + public static func discoveryCourseClicked(courseID: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_discoveryCourseClicked__courseID_courseIDcourseName_courseName(`courseID`, `courseName`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func discoverySearchBarClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_discoverySearchBarClicked, performs: perform) + } + public static func discoveryCoursesSearch(label: Parameter, coursesCount: Parameter, perform: @escaping (String, Int) -> Void) -> Perform { + return Perform(method: .m_discoveryCoursesSearch__label_labelcoursesCount_coursesCount(`label`, `coursesCount`), performs: perform) + } + public static func discoveryCourseClicked(courseID: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_discoveryCourseClicked__courseID_courseIDcourseName_courseName(`courseID`, `courseName`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DiscoveryInteractorProtocol open class DiscoveryInteractorProtocolMock: DiscoveryInteractorProtocol, Mock { diff --git a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift index 6a5a41992..a31924505 100644 --- a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift @@ -25,7 +25,8 @@ final class DiscoveryViewModelTests: XCTestCase { func testGetDiscoveryCourses() async throws { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity) + let analytics = DiscoveryAnalyticsMock() + let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) let items = [ CourseItem(name: "Test", @@ -69,7 +70,8 @@ final class DiscoveryViewModelTests: XCTestCase { func testDiscoverySuccess() async throws { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity) + let analytics = DiscoveryAnalyticsMock() + let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) let items = [ CourseItem(name: "Test", @@ -112,7 +114,8 @@ final class DiscoveryViewModelTests: XCTestCase { func testDiscoveryOfflineSuccess() async throws { let interactor = DiscoveryInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity) + let analytics = DiscoveryAnalyticsMock() + let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) let items = [ CourseItem(name: "Test", @@ -157,7 +160,8 @@ final class DiscoveryViewModelTests: XCTestCase { func testDiscoveryNoInternetError() async throws { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity) + let analytics = DiscoveryAnalyticsMock() + let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -175,7 +179,8 @@ final class DiscoveryViewModelTests: XCTestCase { func testDiscoveryUnknownError() async throws { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity) + let analytics = DiscoveryAnalyticsMock() + let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) let noInternetError = AFError.sessionInvalidated(error: NSError()) diff --git a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift index 86ddc8ff4..3baf45321 100644 --- a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift @@ -25,11 +25,13 @@ final class SearchViewModelTests: XCTestCase { func testSearchSuccess() async throws { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() + let analytics = DiscoveryAnalyticsMock() let router = DiscoveryRouterMock() let viewModel = SearchViewModel( interactor: interactor, connectivity: connectivity, router: router, + analytics: analytics, debounce: .test ) @@ -72,6 +74,7 @@ final class SearchViewModelTests: XCTestCase { wait(for: [exp], timeout: 1) Verify(interactor, .search(page: 1, searchTerm: .any)) + Verify(analytics, .discoveryCoursesSearch(label: .any, coursesCount: .any)) XCTAssertFalse(viewModel.showError) XCTAssertFalse(viewModel.fetchInProgress) @@ -80,11 +83,13 @@ final class SearchViewModelTests: XCTestCase { func testSearchEmptyQuerySuccess() async throws { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() + let analytics = DiscoveryAnalyticsMock() let router = DiscoveryRouterMock() let viewModel = SearchViewModel( interactor: interactor, connectivity: connectivity, router: router, + analytics: analytics, debounce: .test ) @@ -106,11 +111,13 @@ final class SearchViewModelTests: XCTestCase { func testSearchNoInternetError() async throws { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() + let analytics = DiscoveryAnalyticsMock() let router = DiscoveryRouterMock() let viewModel = SearchViewModel( interactor: interactor, connectivity: connectivity, router: router, + analytics: analytics, debounce: .test ) @@ -137,11 +144,13 @@ final class SearchViewModelTests: XCTestCase { func testSearchUnknownError() async throws { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() + let analytics = DiscoveryAnalyticsMock() let router = DiscoveryRouterMock() let viewModel = SearchViewModel( interactor: interactor, connectivity: connectivity, router: router, + analytics: analytics, debounce: .test ) diff --git a/Discussion/Discussion.xcodeproj.xcworkspace/contents.xcworkspacedata b/Discussion/Discussion.xcodeproj.xcworkspace/contents.xcworkspacedata index 698b13400..85b36c90c 100644 --- a/Discussion/Discussion.xcodeproj.xcworkspace/contents.xcworkspacedata +++ b/Discussion/Discussion.xcodeproj.xcworkspace/contents.xcworkspacedata @@ -19,9 +19,6 @@ - - @@ -29,6 +26,6 @@ location = "group:../Pods/Pods.xcodeproj"> + location = "group:../OpenEdX.xcodeproj"> diff --git a/Discussion/Discussion.xcodeproj/project.pbxproj b/Discussion/Discussion.xcodeproj/project.pbxproj index ad6feb624..cfd6abaff 100644 --- a/Discussion/Discussion.xcodeproj/project.pbxproj +++ b/Discussion/Discussion.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ 02D1267628F76F5D00C8E689 /* DiscussionTopicsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D1267528F76F5D00C8E689 /* DiscussionTopicsView.swift */; }; 02D1267828F76FF200C8E689 /* DiscussionTopicsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D1267728F76FF200C8E689 /* DiscussionTopicsViewModel.swift */; }; 02E4F18129A8C2FD00F31684 /* DiscussionSearchTopicsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E4F18029A8C2FD00F31684 /* DiscussionSearchTopicsViewModelTests.swift */; }; + 02F175392A4DD5AB0019CD70 /* DiscussionAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175382A4DD5AA0019CD70 /* DiscussionAnalytics.swift */; }; 02F28A5E28FF23E700AFDE1B /* ThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F28A5D28FF23E700AFDE1B /* ThreadView.swift */; }; 02F28A6028FF23F300AFDE1B /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F28A5F28FF23F300AFDE1B /* ThreadViewModel.swift */; }; 02F3BFE32925302A0051930C /* DiscussionRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFE22925302A0051930C /* DiscussionRouter.swift */; }; @@ -108,6 +109,7 @@ 02E4F18029A8C2FD00F31684 /* DiscussionSearchTopicsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionSearchTopicsViewModelTests.swift; sourceTree = ""; }; 02ED50D029A64BBF008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02ED50D129A64BBF008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = ""; }; + 02F175382A4DD5AA0019CD70 /* DiscussionAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionAnalytics.swift; sourceTree = ""; }; 02F28A5D28FF23E700AFDE1B /* ThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadView.swift; sourceTree = ""; }; 02F28A5F28FF23F300AFDE1B /* ThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewModel.swift; sourceTree = ""; }; 02F3BFE22925302A0051930C /* DiscussionRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionRouter.swift; sourceTree = ""; }; @@ -245,6 +247,7 @@ 0282DA5C28F89397003C3F07 /* DiscussionTopics */, 02CF2C8D291FA76E00FC1596 /* CheckBoxView.swift */, 02F3BFE22925302A0051930C /* DiscussionRouter.swift */, + 02F175382A4DD5AA0019CD70 /* DiscussionAnalytics.swift */, ); path = Presentation; sourceTree = ""; @@ -680,6 +683,7 @@ 029B78F5292519910097ACD8 /* ResponsesViewModel.swift in Sources */, 0282DA6128F893E9003C3F07 /* PostsViewModel.swift in Sources */, 0766DFC4299AA2C200EBEF6A /* Post.swift in Sources */, + 02F175392A4DD5AB0019CD70 /* DiscussionAnalytics.swift in Sources */, 021078E929A50BA30000938D /* DiscussionSearchTopicsViewModel.swift in Sources */, 02F3BFEB2926A5B50051930C /* Data_CommentsResponse.swift in Sources */, 075DBBB329267D1D00E56134 /* PostState.swift in Sources */, @@ -885,7 +889,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discussion; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discussion; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -919,7 +923,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discussion; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discussion; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1016,7 +1020,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discussion; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discussion; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1114,7 +1118,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discussion; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discussion; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1206,7 +1210,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discussion; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discussion; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1297,7 +1301,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discussion; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discussion; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1315,12 +1319,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscussionTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1336,12 +1340,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscussionTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1357,12 +1361,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscussionTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1378,12 +1382,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscussionTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1399,12 +1403,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscussionTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1420,12 +1424,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscussionTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1520,7 +1524,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discussion; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discussion; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1539,12 +1543,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscussionTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1633,7 +1637,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Discussion; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Discussion; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1651,12 +1655,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.DiscussionTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; diff --git a/Discussion/Discussion/Data/Model/Data_CommentsResponse.swift b/Discussion/Discussion/Data/Model/Data_CommentsResponse.swift index d99831abb..37649c682 100644 --- a/Discussion/Discussion/Data/Model/Data_CommentsResponse.swift +++ b/Discussion/Discussion/Data/Model/Data_CommentsResponse.swift @@ -73,10 +73,29 @@ public extension DataLayer { case users } - public init(id: String, author: String?, authorLabel: String?, createdAt: String, updatedAt: String, rawBody: String, - renderedBody: String, abuseFlagged: Bool, voted: Bool, voteCount: Int, editableFields: [String], - canDelete: Bool, threadID: String, parentID: String?, endorsed: Bool, endorsedBy: String?, - endorsedByLabel: String?, endorsedAt: String?, childCount: Int, children: [String], users: Users?) { + public init( + id: String, + author: String?, + authorLabel: String?, + createdAt: String, + updatedAt: String, + rawBody: String, + renderedBody: String, + abuseFlagged: Bool, + voted: Bool, + voteCount: Int, + editableFields: [String], + canDelete: Bool, + threadID: String, + parentID: String?, + endorsed: Bool, + endorsedBy: String?, + endorsedByLabel: String?, + endorsedAt: String?, + childCount: Int, + children: [String], + users: Users? + ) { self.id = id self.author = author self.authorLabel = authorLabel @@ -105,23 +124,23 @@ public extension DataLayer { public extension DataLayer.CommentsResponse { var domain: [UserComment] { self.comments.map { comment in - UserComment( - authorName: comment.author ?? DiscussionLocalization.anonymous, - authorAvatar: comment.users?.userName?.profile?.image?.imageURLLarge ?? "", - postDate: Date(iso8601: comment.createdAt), - postTitle: "", - postBody: comment.rawBody, - postBodyHtml: comment.renderedBody, - postVisible: true, - voted: comment.voted, - followed: false, - votesCount: comment.voteCount, - responsesCount: pagination.count, - threadID: comment.threadID, - commentID: comment.id, - parentID: comment.id, - abuseFlagged: comment.abuseFlagged - ) + UserComment( + authorName: comment.author ?? DiscussionLocalization.anonymous, + authorAvatar: comment.users?.userName?.profile?.image?.imageURLLarge ?? "", + postDate: Date(iso8601: comment.createdAt), + postTitle: "", + postBody: comment.rawBody, + postBodyHtml: comment.renderedBody, + postVisible: true, + voted: comment.voted, + followed: false, + votesCount: comment.voteCount, + responsesCount: comment.childCount, + threadID: comment.threadID, + commentID: comment.id, + parentID: comment.id, + abuseFlagged: comment.abuseFlagged + ) } } } diff --git a/Discussion/Discussion/Data/Model/Data_CreatedComment.swift b/Discussion/Discussion/Data/Model/Data_CreatedComment.swift index 84b7bdc92..e94cadb31 100644 --- a/Discussion/Discussion/Data/Model/Data_CreatedComment.swift +++ b/Discussion/Discussion/Data/Model/Data_CreatedComment.swift @@ -61,7 +61,9 @@ public extension DataLayer { public extension DataLayer.CreatedComment { var domain: Post { Post(authorName: author ?? DiscussionLocalization.anonymous, - authorAvatar: profileImage.imageURLSmall?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "", + authorAvatar: profileImage.imageURLSmall?.addingPercentEncoding( + withAllowedCharacters: .urlHostAllowed + ) ?? "", postDate: Date(iso8601: createdAt), postTitle: "", postBodyHtml: renderedBody, diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index db9b95627..c10bb0e7b 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -17,9 +17,9 @@ public protocol DiscussionRepositoryProtocol { page: Int) async throws -> ThreadLists func searchThreads(courseID: String, searchText: String, pageNumber: Int) async throws -> ThreadLists func getTopics(courseID: String) async throws -> Topics - func getDiscussionComments(threadID: String, page: Int) async throws -> ([UserComment], Int) - func getQuestionComments(threadID: String, page: Int) async throws -> ([UserComment], Int) - func getCommentResponses(commentID: String, page: Int) async throws -> ([UserComment], Int) + func getDiscussionComments(threadID: String, page: Int) async throws -> ([UserComment], Pagination) + func getQuestionComments(threadID: String, page: Int) async throws -> ([UserComment], Pagination) + func getCommentResponses(commentID: String, page: Int) async throws -> ([UserComment], Pagination) func addCommentTo(threadID: String, rawBody: String, parentID: String?) async throws -> Post func voteThread(voted: Bool, threadID: String) async throws func voteResponse(voted: Bool, responseID: String) async throws @@ -72,25 +72,25 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { return topics.domain } - public func getDiscussionComments(threadID: String, page: Int) async throws -> ([UserComment], Int) { + public func getDiscussionComments(threadID: String, page: Int) async throws -> ([UserComment], Pagination) { let response = try await api.requestData(DiscussionEndpoint .getDiscussionComments(threadID: threadID, page: page)) let result = try await renameUsers(data: response) - return (result.domain, result.pagination.numPages) + return (result.domain, result.pagination.domain) } - public func getQuestionComments(threadID: String, page: Int) async throws -> ([UserComment], Int) { + public func getQuestionComments(threadID: String, page: Int) async throws -> ([UserComment], Pagination) { let response = try await api.requestData(DiscussionEndpoint .getQuestionComments(threadID: threadID, page: page)) let result = try await renameUsers(data: response) - return (result.domain, result.pagination.numPages) + return (result.domain, result.pagination.domain) } - public func getCommentResponses(commentID: String, page: Int) async throws -> ([UserComment], Int) { + public func getCommentResponses(commentID: String, page: Int) async throws -> ([UserComment], Pagination) { let response = try await api.requestData(DiscussionEndpoint .getCommentResponses(commentID: commentID, page: page)) let result = try await renameUsers(data: response) - return (result.domain, result.pagination.numPages) + return (result.domain, result.pagination.domain) } public func addCommentTo(threadID: String, rawBody: String, parentID: String? = nil) async throws -> Post { @@ -132,7 +132,9 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { if let stringJSON = String(data: data, encoding: .utf8) { modifiedJSON = renameUsersInJSON(stringJSON: stringJSON) - if let modifiedParsed = try modifiedJSON.data(using: .utf8)?.mapResponse(DataLayer.ThreadListsResponse.self) { + if let modifiedParsed = try modifiedJSON.data(using: .utf8)?.mapResponse( + DataLayer.ThreadListsResponse.self + ) { return modifiedParsed } else { return parsed @@ -327,16 +329,16 @@ public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { ) } - public func getDiscussionComments(threadID: String, page: Int) async throws -> ([UserComment], Int) { - (comments, 10) + public func getDiscussionComments(threadID: String, page: Int) async throws -> ([UserComment], Pagination) { + (comments, Pagination(next: nil, previous: nil, count: 10, numPages: 1)) } - public func getQuestionComments(threadID: String, page: Int) async throws -> ([UserComment], Int) { - (comments, 10) + public func getQuestionComments(threadID: String, page: Int) async throws -> ([UserComment], Pagination) { + (comments, Pagination(next: nil, previous: nil, count: 10, numPages: 1)) } - public func getCommentResponses(commentID: String, page: Int) async throws -> ([UserComment], Int) { - (comments, 10) + public func getCommentResponses(commentID: String, page: Int) async throws -> ([UserComment], Pagination) { + (comments, Pagination(next: nil, previous: nil, count: 10, numPages: 1)) } public func addCommentTo(threadID: String, rawBody: String, parentID: String?) async throws -> Post { diff --git a/Discussion/Discussion/Domain/DiscussionInteractor.swift b/Discussion/Discussion/Domain/DiscussionInteractor.swift index d17043b03..ee6de6716 100644 --- a/Discussion/Discussion/Domain/DiscussionInteractor.swift +++ b/Discussion/Discussion/Domain/DiscussionInteractor.swift @@ -17,9 +17,9 @@ public protocol DiscussionInteractorProtocol { page: Int) async throws -> ThreadLists func getTopics(courseID: String) async throws -> Topics func searchThreads(courseID: String, searchText: String, pageNumber: Int) async throws -> ThreadLists - func getDiscussionComments(threadID: String, page: Int) async throws -> ([UserComment], Int) - func getQuestionComments(threadID: String, page: Int) async throws -> ([UserComment], Int) - func getCommentResponses(commentID: String, page: Int) async throws -> ([UserComment], Int) + func getDiscussionComments(threadID: String, page: Int) async throws -> ([UserComment], Pagination) + func getQuestionComments(threadID: String, page: Int) async throws -> ([UserComment], Pagination) + func getCommentResponses(commentID: String, page: Int) async throws -> ([UserComment], Pagination) func addCommentTo(threadID: String, rawBody: String, parentID: String?) async throws -> Post func voteThread(voted: Bool, threadID: String) async throws func voteResponse(voted: Bool, responseID: String) async throws @@ -54,15 +54,15 @@ public class DiscussionInteractor: DiscussionInteractorProtocol { return try await repository.getTopics(courseID: courseID) } - public func getDiscussionComments(threadID: String, page: Int) async throws -> ([UserComment], Int) { + public func getDiscussionComments(threadID: String, page: Int) async throws -> ([UserComment], Pagination) { return try await repository.getDiscussionComments(threadID: threadID, page: page) } - public func getQuestionComments(threadID: String, page: Int) async throws -> ([UserComment], Int) { + public func getQuestionComments(threadID: String, page: Int) async throws -> ([UserComment], Pagination) { return try await repository.getQuestionComments(threadID: threadID, page: page) } - public func getCommentResponses(commentID: String, page: Int) async throws -> ([UserComment], Int) { + public func getCommentResponses(commentID: String, page: Int) async throws -> ([UserComment], Pagination) { return try await repository.getCommentResponses(commentID: commentID, page: page) } diff --git a/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift index 3a3694fc5..d7b7ce10d 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift @@ -22,6 +22,7 @@ public class BaseResponsesViewModel { public var nextPage = 2 public var totalPages = 1 + @Published public var itemsCount = 0 public var fetchInProgress = false var errorMessage: String? { @@ -138,6 +139,7 @@ public class BaseResponsesViewModel { var newPostWithAvatar = post newPostWithAvatar.authorAvatar = storage.userProfile?.profileImage?.imageURLLarge ?? "" postComments?.comments.append(newPostWithAvatar) + itemsCount += 1 } private func toggleLikeOnParrent() { diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 2e70cb9a2..1a410b953 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -39,145 +39,149 @@ public struct ResponsesView: View { ZStack(alignment: .top) { // MARK: - Page name - VStack(alignment: .center) { - NavigationBar(title: title, - leftButtonAction: { router.back() }) - - // 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, + VStack(alignment: .center) { + NavigationBar(title: title, + leftButtonAction: { router.back() }) + + // 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, + 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 { - if let responsesCount = viewModel.postComments?.responsesCount { - Text("\(responsesCount)") - Text(DiscussionLocalization.commentsCount(responsesCount)) - 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: viewModel.postComments?.parentID) - } + 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: viewModel.postComments?.parentID + ) } } - ) - } + } + ) } } - .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) - } + } else { + viewModel.alertMessage = DiscussionLocalization.Response.Alert.commentAdded + viewModel.showAlert = true + } + }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + }.scrollAvoidKeyboard(dismissKeyboardByTap: true) + } // MARK: - Error Alert if viewModel.showError { VStack { @@ -230,19 +234,21 @@ struct ResponsesView_Previews: PreviewProvider { ) let router = DiscussionRouterMock() - ResponsesView(commentID: "", - viewModel: viewModel, - router: router, - parentComment: post + ResponsesView( + commentID: "", + viewModel: viewModel, + router: router, + parentComment: post ) .loadFonts() .preferredColorScheme(.light) .previewDisplayName("ResponsesView Light") - ResponsesView(commentID: "", - viewModel: viewModel, - router: router, - parentComment: post + ResponsesView( + commentID: "", + viewModel: viewModel, + router: router, + parentComment: post ) .loadFonts() .preferredColorScheme(.dark) diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift index f3f377d14..1b8e0acc3 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift @@ -53,7 +53,7 @@ public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { } @MainActor - public func postComment(threadID: String, rawBody: String, parentID: String?) async { + func postComment(threadID: String, rawBody: String, parentID: String?) async { isShowProgress = true do { let newComment = try await interactor.addCommentTo(threadID: threadID, @@ -72,7 +72,7 @@ public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { } @MainActor - public func fetchMorePosts(commentID: String, parentComment: Post, index: Int) async { + func fetchMorePosts(commentID: String, parentComment: Post, index: Int) async { if totalPages > 1 { if index == comments.count - 3 { if totalPages != 1 { @@ -89,12 +89,13 @@ public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { } @MainActor - public func getComments(commentID: String, parentComment: Post, page: Int) async -> Bool { + func getComments(commentID: String, parentComment: Post, page: Int) async -> Bool { guard !fetchInProgress else { return false } do { - let (comments, totalPages) = try await interactor + let (comments, pagination) = try await interactor .getCommentResponses(commentID: commentID, page: page) - self.totalPages = totalPages + self.totalPages = pagination.numPages + self.itemsCount = pagination.count self.comments += comments postComments = generateCommentsResponses(comments: self.comments, parentComment: parentComment) return true diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index dc40a34f7..38909a86d 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -96,11 +96,9 @@ public struct ThreadView: View { ) HStack { - if let responsesCount = viewModel.postComments?.responsesCount { - Text("\(responsesCount)") - Text(DiscussionLocalization.responsesCount(responsesCount)) - Spacer() - } + Text("\(viewModel.itemsCount)") + Text(DiscussionLocalization.responsesCount(viewModel.itemsCount)) + Spacer() }.padding(.top, 40) .padding(.bottom, 14) .padding(.leading, 24) @@ -171,9 +169,11 @@ public struct ThreadView: View { sendText: { commentText in if let threadID = viewModel.postComments?.threadID { Task { - await viewModel.postComment(threadID: threadID, - rawBody: commentText, - parentID: viewModel.postComments?.parentID) + await viewModel.postComment( + threadID: threadID, + rawBody: commentText, + parentID: viewModel.postComments?.parentID + ) } } } @@ -217,8 +217,10 @@ public struct ThreadView: View { if viewModel.showAlert { VStack { Text(viewModel.alertMessage ?? "") - .shadowCardStyle(bgColor: CoreAssets.accentColor.swiftUIColor, - textColor: .white) + .shadowCardStyle( + bgColor: CoreAssets.accentColor.swiftUIColor, + textColor: .white + ) .padding(.top, 80) Spacer() diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift index a4ac75651..26d82ce7e 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift @@ -45,41 +45,45 @@ public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { } func generateComments(comments: [UserComment], thread: UserThread) -> Post { - var result = Post(authorName: thread.author, - authorAvatar: thread.avatar, - postDate: thread.createdAt, - postTitle: thread.title, - postBodyHtml: thread.renderedBody, - postBody: thread.rawBody, - postVisible: true, - voted: thread.voted, - followed: thread.following, - votesCount: thread.voteCount, - responsesCount: comments.last?.responsesCount ?? 0, - comments: [], - threadID: thread.id, - commentID: thread.courseID, - parentID: nil, - abuseFlagged: thread.abuseFlagged, - closed: thread.closed) + var result = Post( + authorName: thread.author, + authorAvatar: thread.avatar, + postDate: thread.createdAt, + postTitle: thread.title, + postBodyHtml: thread.renderedBody, + postBody: thread.rawBody, + postVisible: true, + voted: thread.voted, + followed: thread.following, + votesCount: thread.voteCount, + responsesCount: comments.last?.responsesCount ?? 0, + comments: [], + threadID: thread.id, + commentID: thread.courseID, + parentID: nil, + abuseFlagged: thread.abuseFlagged, + closed: thread.closed + ) result.comments = comments.map { c in - Post(authorName: c.authorName, - authorAvatar: c.authorAvatar, - postDate: c.postDate, - postTitle: c.postTitle, - postBodyHtml: c.postBodyHtml, - postBody: c.postBody, - postVisible: c.postVisible, - voted: c.voted, - followed: c.followed, - votesCount: c.votesCount, - responsesCount: c.responsesCount, - comments: [], - threadID: c.threadID, - commentID: c.commentID, - parentID: c.parentID, - abuseFlagged: c.abuseFlagged, - closed: thread.closed) + Post( + authorName: c.authorName, + authorAvatar: c.authorAvatar, + postDate: c.postDate, + postTitle: c.postTitle, + postBodyHtml: c.postBodyHtml, + postBody: c.postBody, + postVisible: c.postVisible, + voted: c.voted, + followed: c.followed, + votesCount: c.votesCount, + responsesCount: c.responsesCount, + comments: [], + threadID: c.threadID, + commentID: c.commentID, + parentID: c.parentID, + abuseFlagged: c.abuseFlagged, + closed: thread.closed + ) } return result } @@ -128,20 +132,19 @@ public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { try await interactor.readBody(threadID: thread.id) switch thread.type { case .question: - let (comments, totalPages) = try await interactor + let (comments, pagination) = try await interactor .getQuestionComments(threadID: thread.id, page: page) - self.totalPages = totalPages + self.totalPages = pagination.numPages + self.itemsCount = pagination.count self.comments += comments - - postComments = - generateComments(comments: self.comments, thread: thread) + postComments = generateComments(comments: self.comments, thread: thread) case .discussion: - let (comments, totalPages) = try await interactor + let (comments, pagination) = try await interactor .getDiscussionComments(threadID: thread.id, page: page) - self.totalPages = totalPages + self.totalPages = pagination.numPages + self.itemsCount = pagination.count self.comments += comments - postComments = - generateComments(comments: self.comments, thread: thread) + postComments = generateComments(comments: self.comments, thread: thread) } fetchInProgress = false return true diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift index 022656027..e21e8cf3c 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift @@ -23,10 +23,12 @@ public struct CreateNewThreadView: View { @ObservedObject private var viewModel: CreateNewThreadViewModel - public init(viewModel: CreateNewThreadViewModel, - selectedTopic: String, - courseID: String, - onPostCreated: @escaping () -> Void) { + public init( + viewModel: CreateNewThreadViewModel, + selectedTopic: String, + courseID: String, + onPostCreated: @escaping () -> Void + ) { self.viewModel = viewModel self.onPostCreated = onPostCreated self.courseID = courseID @@ -41,148 +43,150 @@ 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 { - HStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(20) - }.frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - VStack(alignment: .leading) { - HStack { - Text(DiscussionLocalization.CreateThread.selectPostType) - .font(Theme.Fonts.titleMedium) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) - .padding(.top, 32) - Spacer() + // MARK: - Page name + VStack(alignment: .center) { + NavigationBar(title: DiscussionLocalization.CreateThread.newPost, + leftButtonAction: { viewModel.router.back() }) + + // MARK: - Page Body + if viewModel.isShowProgress { + HStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(20) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(alignment: .leading) { + HStack { + Text(DiscussionLocalization.CreateThread.selectPostType) + .font(Theme.Fonts.titleMedium) + .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + .padding(.top, 32) + Spacer() + } + + Picker("", selection: $postType) { + ForEach(postTypes, id: \.self) { + Text($0.localizedValue.capitalized) } + }.pickerStyle(.segmented) + .frame(maxWidth: .infinity, maxHeight: 40) + + // MARK: Topic picker + Group { + Text(DiscussionLocalization.CreateThread.topic) + .font(Theme.Fonts.titleSmall) + .padding(.top, 16) - Picker("", selection: $postType) { - ForEach(postTypes, id: \.self) { - Text($0.localizedValue.capitalized) - } - }.pickerStyle(.segmented) - .frame(maxWidth: .infinity, maxHeight: 40) - - // MARK: Topic picker - Group { - Text(DiscussionLocalization.CreateThread.topic) - .font(Theme.Fonts.titleSmall) - .padding(.top, 16) - - Menu { - Picker(selection: $viewModel.selectedTopic) { - ForEach(viewModel.allTopics, id: \.id) { - Text($0.name) - .tag($0.id) - .font(Theme.Fonts.labelLarge) - } - } label: {} - } label: { - HStack { - Text(viewModel.allTopics.first(where: { - $0.id == viewModel.selectedTopic })?.name ?? "") + Menu { + Picker(selection: $viewModel.selectedTopic) { + ForEach(viewModel.allTopics, id: \.id) { + Text($0.name) + .tag($0.id) .font(Theme.Fonts.labelLarge) - .frame(height: 40, alignment: .leading) - Spacer() - Image(systemName: "chevron.down") - }.padding(.horizontal, 14) - .accentColor(CoreAssets.textPrimary.swiftUIColor) - .background(Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(CoreAssets.textInputStroke.swiftUIColor) - ) - } - } - // MARK: End of topic picker - - Group { - Text(DiscussionLocalization.CreateThread.title) - .font(Theme.Fonts.titleSmall) - + Text(" *").foregroundColor(.red) - }.padding(.top, 16) - TextField("", text: $postTitle) - .font(Theme.Fonts.labelLarge) - .padding(14) - .frame(height: 40) - .background( - Theme.Shapes.textInputShape - .fill(CoreAssets.textInputBackground.swiftUIColor) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill( - CoreAssets.textInputStroke.swiftUIColor - ) - ) - - Group { - Text("\(postType.localizedValue.capitalized)") - .font(Theme.Fonts.titleSmall) - + Text(" *").foregroundColor(.red) - }.padding(.top, 16) - TextEditor(text: $postBody) - .font(Theme.Fonts.labelLarge) - .padding(.horizontal, 10) - .padding(.vertical, 10) - .frame(height: 200) - .hideScrollContentBackground() - .background( - Theme.Shapes.textInputShape + } + } label: {} + } label: { + HStack { + Text(viewModel.allTopics.first(where: { + $0.id == viewModel.selectedTopic })?.name ?? "") + .font(Theme.Fonts.labelLarge) + .frame(height: 40, alignment: .leading) + Spacer() + Image(systemName: "chevron.down") + }.padding(.horizontal, 14) + .accentColor(CoreAssets.textPrimary.swiftUIColor) + .background(Theme.Shapes.textInputShape .fill(CoreAssets.textInputBackground.swiftUIColor) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill( - CoreAssets.textInputStroke.swiftUIColor - ) - ) - - CheckBoxView(checked: $followPost, - text: postType == .discussion - ? DiscussionLocalization.CreateThread.followDiscussion - : DiscussionLocalization.CreateThread.followQuestion + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(CoreAssets.textInputStroke.swiftUIColor) + ) + } + } + // MARK: End of topic picker + + Group { + Text(DiscussionLocalization.CreateThread.title) + .font(Theme.Fonts.titleSmall) + + Text(" *").foregroundColor(.red) + }.padding(.top, 16) + TextField("", text: $postTitle) + .font(Theme.Fonts.labelLarge) + .padding(14) + .frame(height: 40) + .background( + Theme.Shapes.textInputShape + .fill(CoreAssets.textInputBackground.swiftUIColor) ) - .padding(.top, 16) - - StyledButton(postType == .discussion - ? DiscussionLocalization.CreateThread.createDiscussion - : DiscussionLocalization.CreateThread.createQuestion, action: { - if postTitle != "" && postBody != "" { - let newThread = DiscussionNewThread(courseID: courseID, - topicID: viewModel.selectedTopic, - type: postType, - title: postTitle, - rawBody: postBody, - followPost: followPost) - Task { - if await viewModel.createNewThread(newThread: newThread) { - onPostCreated() - } + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill( + CoreAssets.textInputStroke.swiftUIColor + ) + ) + + Group { + Text("\(postType.localizedValue.capitalized)") + .font(Theme.Fonts.titleSmall) + + Text(" *").foregroundColor(.red) + }.padding(.top, 16) + TextEditor(text: $postBody) + .font(Theme.Fonts.labelLarge) + .padding(.horizontal, 10) + .padding(.vertical, 10) + .frame(height: 200) + .hideScrollContentBackground() + .background( + Theme.Shapes.textInputShape + .fill(CoreAssets.textInputBackground.swiftUIColor) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill( + CoreAssets.textInputStroke.swiftUIColor + ) + ) + + CheckBoxView(checked: $followPost, + text: postType == .discussion + ? DiscussionLocalization.CreateThread.followDiscussion + : DiscussionLocalization.CreateThread.followQuestion + ) + .padding(.top, 16) + + StyledButton(postType == .discussion + ? DiscussionLocalization.CreateThread.createDiscussion + : DiscussionLocalization.CreateThread.createQuestion, action: { + if postTitle != "" && postBody != "" { + let newThread = DiscussionNewThread(courseID: courseID, + topicID: viewModel.selectedTopic, + type: postType, + title: postTitle, + rawBody: postBody, + followPost: followPost) + Task { + if await viewModel.createNewThread(newThread: newThread) { + onPostCreated() } } - }) - .padding(.top, 26) - .saturation(!postTitle.isEmpty && !postBody.isEmpty ? 1 : 0) - Spacer() - }.padding(.horizontal, 24) - .frameLimit() - .onRightSwipeGesture { - viewModel.router.back() } - } + }) + .padding(.top, 26) + .saturation(!postTitle.isEmpty && !postBody.isEmpty ? 1 : 0) + Spacer() + }.padding(.horizontal, 24) + .frameLimit() + .onRightSwipeGesture { + viewModel.router.back() + } }.scrollAvoidKeyboard(dismissKeyboardByTap: true) + } + } } .background( CoreAssets.background.swiftUIColor diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift index 129199e85..95c907300 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift @@ -28,9 +28,11 @@ public class CreateNewThreadViewModel: ObservableObject { public let router: DiscussionRouter public let config: Config - public init(interactor: DiscussionInteractorProtocol, - router: DiscussionRouter, - config: Config) { + public init( + interactor: DiscussionInteractorProtocol, + router: DiscussionRouter, + config: Config + ) { self.interactor = interactor self.router = router self.config = config @@ -44,8 +46,10 @@ public class CreateNewThreadViewModel: ObservableObject { if let topics { allTopics = topics.nonCoursewareTopics.map { $0 } allTopics.append(contentsOf: topics.coursewareTopics.flatMap { $0.children.map { $0 } }) - if let topic = allTopics.first { - selectedTopic = topic.id + if selectedTopic == "" { + if let topic = allTopics.first { + selectedTopic = topic.id + } } } } catch { diff --git a/Discussion/Discussion/Presentation/DiscussionAnalytics.swift b/Discussion/Discussion/Presentation/DiscussionAnalytics.swift new file mode 100644 index 000000000..969994b4c --- /dev/null +++ b/Discussion/Discussion/Presentation/DiscussionAnalytics.swift @@ -0,0 +1,23 @@ +// +// DiscussionAnalytics.swift +// Discussion +// +// Created by  Stepanok Ivan on 29.06.2023. +// + +import Foundation + +//sourcery: AutoMockable +public protocol DiscussionAnalytics { + func discussionAllPostsClicked(courseId: String, courseName: String) + func discussionFollowingClicked(courseId: String, courseName: String) + func discussionTopicClicked(courseId: String, courseName: String, topicId: String, topicName: String) +} + +#if DEBUG +class DiscussionAnalyticsMock: DiscussionAnalytics { + public func discussionAllPostsClicked(courseId: String, courseName: String) {} + public func discussionFollowingClicked(courseId: String, courseName: String) {} + public func discussionTopicClicked(courseId: String, courseName: String, topicId: String, topicName: String) {} +} +#endif diff --git a/Discussion/Discussion/Presentation/DiscussionRouter.swift b/Discussion/Discussion/Presentation/DiscussionRouter.swift index 13be8b6c9..f57e883b9 100644 --- a/Discussion/Discussion/Presentation/DiscussionRouter.swift +++ b/Discussion/Discussion/Presentation/DiscussionRouter.swift @@ -21,7 +21,8 @@ public protocol DiscussionRouter: BaseRouter { func showComments( commentID: String, parentComment: Post, - threadStateSubject: CurrentValueSubject) + threadStateSubject: CurrentValueSubject + ) func createNewThread(courseID: String, selectedTopic: String, onPostCreated: @escaping () -> Void) } @@ -41,12 +42,13 @@ public class DiscussionRouterMock: BaseRouterMock, DiscussionRouter { public func showComments( commentID: String, parentComment: Post, - threadStateSubject: CurrentValueSubject) {} + threadStateSubject: CurrentValueSubject + ) {} public func createNewThread( courseID: String, selectedTopic: String, - onPostCreated: @escaping () -> Void) {} - + onPostCreated: @escaping () -> Void + ) {} } #endif diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift index 23578ac6c..e7f925c02 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift @@ -12,6 +12,7 @@ public struct DiscussionSearchTopicsView: View { @ObservedObject private var viewModel: DiscussionSearchTopicsViewModel @State private var animated: Bool = false + @State private var becomeFirstResponderRunOnce = false public init(viewModel: DiscussionSearchTopicsViewModel) { self.viewModel = viewModel @@ -44,9 +45,12 @@ public struct DiscussionSearchTopicsView: View { viewModel.isSearchActive = editing } ) - .introspectTextField { textField in - textField.becomeFirstResponder() - } + .introspect(.textField, on: .iOS(.v14, .v15, .v16, .v17), customize: { textField in + if !becomeFirstResponderRunOnce { + textField.becomeFirstResponder() + self.becomeFirstResponderRunOnce = true + } + }) .foregroundColor(CoreAssets.textPrimary.swiftUIColor) Spacer() if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { @@ -97,7 +101,10 @@ public struct DiscussionSearchTopicsView: View { .padding(24) .onAppear { Task.detached(priority: .high) { - await viewModel.searchCourses(index: index, searchTerm: viewModel.searchText) + await viewModel.searchCourses( + index: index, + searchTerm: viewModel.searchText + ) } } if viewModel.searchResults.last != post { diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift index dcee4ebef..a8025c28c 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift @@ -95,7 +95,7 @@ public class DiscussionSearchTopicsViewModel: ObservableObject { } @MainActor - public func searchCourses(index: Int, searchTerm: String) async { + func searchCourses(index: Int, searchTerm: String) async { if !fetchInProgress { if totalPages > 1 { if index == searchResults.count - 3 { diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index 87e7d7116..acb81523d 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -161,8 +161,10 @@ public struct DiscussionTopicsView: View { struct DiscussionView_Previews: PreviewProvider { static var previews: some View { let vm = DiscussionTopicsViewModel( + title: "Course name", interactor: DiscussionInteractor.mock, router: DiscussionRouterMock(), + analytics: DiscussionAnalyticsMock(), config: ConfigMock()) let router = DiscussionRouterMock() @@ -181,6 +183,7 @@ struct DiscussionView_Previews: PreviewProvider { .loadFonts() } } +// swiftlint:enable all #endif public struct TopicCell: View { diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift index 6e2f29f0f..9364f4d6b 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import Core +import FirebaseCrashlytics public class DiscussionTopicsViewModel: ObservableObject { @@ -16,6 +17,7 @@ public class DiscussionTopicsViewModel: ObservableObject { @Published var showError: Bool = false @Published var discussionTopics: [DiscussionTopic]? @Published var courseID: String = "" + private var title: String var errorMessage: String? { didSet { @@ -25,35 +27,50 @@ public class DiscussionTopicsViewModel: ObservableObject { } } - public let interactor: DiscussionInteractorProtocol - public let router: DiscussionRouter - public let config: Config + let interactor: DiscussionInteractorProtocol + let router: DiscussionRouter + let analytics: DiscussionAnalytics + let config: Config - public init(interactor: DiscussionInteractorProtocol, router: DiscussionRouter, config: Config) { + public init(title: String, + interactor: DiscussionInteractorProtocol, + router: DiscussionRouter, + analytics: DiscussionAnalytics, + config: Config) { + self.title = title self.interactor = interactor self.router = router + self.analytics = analytics self.config = config } func generateTopics(topics: Topics?) -> [DiscussionTopic] { var result = [ - DiscussionTopic(name: DiscussionLocalization.Topics.allPosts, - action: { - self.router.showThreads( - courseID: self.courseID, - topics: topics ?? Topics(coursewareTopics: [], nonCoursewareTopics: []), - title: DiscussionLocalization.Topics.allPosts, - type: .allPosts) - }, - style: .basic), - DiscussionTopic(name: DiscussionLocalization.Topics.postImFollowing, action: { - self.router.showThreads( - courseID: self.courseID, - topics: topics ?? Topics(coursewareTopics: [], nonCoursewareTopics: []), - title: DiscussionLocalization.Topics.postImFollowing, - type: .followingPosts - ) - }, style: .followed) + DiscussionTopic( + name: DiscussionLocalization.Topics.allPosts, + action: { + self.analytics.discussionAllPostsClicked(courseId: self.courseID, + courseName: self.title) + self.router.showThreads( + courseID: self.courseID, + topics: topics ?? Topics(coursewareTopics: [], nonCoursewareTopics: []), + title: DiscussionLocalization.Topics.allPosts, + type: .allPosts) + }, + style: .basic + ), + DiscussionTopic( + name: DiscussionLocalization.Topics.postImFollowing, action: { + self.analytics.discussionFollowingClicked(courseId: self.courseID, + courseName: self.title) + self.router.showThreads( + courseID: self.courseID, + topics: topics ?? Topics(coursewareTopics: [], nonCoursewareTopics: []), + title: DiscussionLocalization.Topics.postImFollowing, + type: .followingPosts + ) + }, + style: .followed) ] if let topics = topics { for t in topics.nonCoursewareTopics { @@ -61,6 +78,12 @@ public class DiscussionTopicsViewModel: ObservableObject { DiscussionTopic( name: t.name, action: { + self.analytics.discussionTopicClicked( + courseId: self.courseID, + courseName: self.title, + topicId: t.id, + topicName: t.name + ) self.router.showThreads( courseID: self.courseID, topics: topics, @@ -75,6 +98,12 @@ public class DiscussionTopicsViewModel: ObservableObject { DiscussionTopic( name: children.name, action: { + self.analytics.discussionTopicClicked( + courseId: self.courseID, + courseName: self.title, + topicId: t.id, + topicName: t.name + ) self.router.showThreads( courseID: self.courseID, topics: topics, @@ -97,10 +126,20 @@ public class DiscussionTopicsViewModel: ObservableObject { result.append( DiscussionTopic( name: child.name, - action: { self.router.showThreads(courseID: self.courseID, - topics: topics, - title: child.name, - type: .courseTopics(topicID: child.id))}, + action: { + self.analytics.discussionTopicClicked( + courseId: self.courseID, + courseName: self.title, + topicId: child.id, + topicName: child.name + ) + self.router.showThreads( + courseID: self.courseID, + topics: topics, + title: child.name, + type: .courseTopics(topicID: child.id) + ) + }, style: .subTopic) ) } @@ -113,17 +152,17 @@ public class DiscussionTopicsViewModel: ObservableObject { public func getTopics(courseID: String, withProgress: Bool = true) async { self.courseID = courseID isShowProgress = withProgress - do { - topics = try await interactor.getTopics(courseID: courseID) - discussionTopics = generateTopics(topics: topics) - isShowProgress = false - } catch let error { - isShowProgress = false - if error.isInternetError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } + do { + topics = try await interactor.getTopics(courseID: courseID) + discussionTopics = generateTopics(topics: topics) + isShowProgress = false + } catch let error { + isShowProgress = false + if error.isInternetError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError } + } } } diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 838b00cd6..e3936c3b3 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -17,13 +17,15 @@ public struct PostsView: View { @State private var listAnimation: Animation? private let router: DiscussionRouter private let title: String + private let currentBlockID: String private let courseID: String private var showTopMenu: Bool - public init(courseID: String, topics: Topics, title: String, type: ThreadType, + public init(courseID: String, currentBlockID: String, topics: Topics, title: String, type: ThreadType, viewModel: PostsViewModel, router: DiscussionRouter, showTopMenu: Bool = true) { self.courseID = courseID self.title = title + self.currentBlockID = currentBlockID self.router = router self.showTopMenu = showTopMenu self.viewModel = viewModel @@ -31,17 +33,18 @@ public struct PostsView: View { self.viewModel.topics = topics viewModel.type = type Task { - await viewModel.getPosts(courseID: courseID, pageNumber: 1, withProgress: true) + await viewModel.getPosts(courseID: courseID, pageNumber: 1, withProgress: true) } } public init(courseID: String, router: DiscussionRouter, viewModel: PostsViewModel) { self.courseID = courseID self.title = "" + self.currentBlockID = "" self.router = router self.viewModel = viewModel Task { - await viewModel.getPosts(courseID: courseID, pageNumber: 1, withProgress: true) + await viewModel.getPosts(courseID: courseID, pageNumber: 1, withProgress: true) } self.showTopMenu = true self.viewModel.courseID = courseID @@ -101,67 +104,96 @@ public struct PostsView: View { pageNumber: 1, withProgress: isIOS14) }) { - LazyVStack { - VStack {}.frame(height: 1) - .id(1) - let posts = Array(viewModel.filteredPosts.enumerated()) - HStack { - Text(title) - .font(Theme.Fonts.titleLarge) - .foregroundColor(CoreAssets.textPrimary.swiftUIColor) - .padding(.horizontal, 24) - .padding(.top, 12) - Spacer() - } - ForEach(posts, id: \.offset) { index, post in - PostCell(post: post).padding(24) - .onAppear { - Task { - await viewModel.getPostsPagination(courseID: self.courseID, index: index) + 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(CoreAssets.textPrimary.swiftUIColor) + 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(CoreAssets.accentColor.swiftUIColor) + ) + }) + } + .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) } - if posts.last?.element != post { - Divider().padding(.horizontal, 24) } + Spacer(minLength: 84) + } + } else { + if !viewModel.isShowProgress { + VStack(spacing: 0) { + CoreAssets.discussionIcon.swiftUIImage + .renderingMode(.template) + .foregroundColor(CoreAssets.textPrimary.swiftUIColor) + 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) } - Spacer(minLength: 84) - }.id(UUID()) + } } }.frameLimit() .animation(listAnimation) .onRightSwipeGesture { router.back() } - - VStack { - Spacer() - Button(action: { - router.createNewThread(courseID: courseID, - selectedTopic: title, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } - }) - }) - }, label: { - VStack { - HStack(alignment: .center) { - CoreAssets.addComment.swiftUIImage - .font(Theme.Fonts.labelLarge) - Text(DiscussionLocalization.Posts.createNewPost) - }.frame(maxHeight: 42) - .padding(.horizontal, 20) - } - .foregroundColor(.white) - .background( - Theme.Shapes.buttonShape - .foregroundColor(CoreAssets.accentColor.swiftUIColor) - ) - .padding(.bottom, 30) - }) - } } }.frame(maxWidth: .infinity) } @@ -198,7 +230,6 @@ public struct PostsView: View { } #if DEBUG -// swiftlint:disable all struct PostsView_Previews: PreviewProvider { static var previews: some View { let topics = Topics(coursewareTopics: [], nonCoursewareTopics: []) @@ -210,6 +241,7 @@ struct PostsView_Previews: PreviewProvider { ) PostsView(courseID: "course_id", + currentBlockID: "123", topics: topics, title: "Lesson question", type: .allPosts, @@ -220,6 +252,7 @@ struct PostsView_Previews: PreviewProvider { .loadFonts() PostsView(courseID: "course_id", + currentBlockID: "123", topics: topics, title: "Lesson question", type: .allPosts, diff --git a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift index f4a20a45b..7d93b50cd 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift @@ -41,7 +41,7 @@ public class PostsViewModel: ObservableObject { @Published var filterTitle: ThreadsFilter = .allThreads { willSet { if let courseID { - resetPosts() + resetPosts() Task { _ = await getPosts(courseID: courseID, pageNumber: 1) } @@ -49,15 +49,15 @@ public class PostsViewModel: ObservableObject { } } @Published var sortTitle: SortType = .recentActivity { - willSet { - if let courseID { - resetPosts() - Task { - _ = await getPosts(courseID: courseID, pageNumber: 1) - } - } - } - } + willSet { + if let courseID { + resetPosts() + Task { + _ = await getPosts(courseID: courseID, pageNumber: 1) + } + } + } + } @Published var filterButtons: [ActionSheet.Button] = [] public var courseID: String? @@ -80,9 +80,11 @@ public class PostsViewModel: ObservableObject { internal let postStateSubject = CurrentValueSubject(nil) private var cancellable: AnyCancellable? - public init(interactor: DiscussionInteractorProtocol, - router: DiscussionRouter, - config: Config) { + public init( + interactor: DiscussionInteractorProtocol, + router: DiscussionRouter, + config: Config + ) { self.interactor = interactor self.router = router self.config = config @@ -159,7 +161,6 @@ public class PostsViewModel: ObservableObject { guard let self, let actualThread = self.threads.threads .first(where: {$0.id == thread.id }) else { return } - print(">>>>>", actualThread) self.router.showThread(thread: actualThread, postStateSubject: self.postStateSubject) })) } @@ -172,7 +173,7 @@ public class PostsViewModel: ObservableObject { func getPostsPagination(courseID: String, index: Int, withProgress: Bool = true) async { if !fetchInProgress { if totalPages > 1 { - if index == threads.threads.count - 3 { + if index == filteredPosts.count - 3 { if totalPages != 1 { if nextPage <= totalPages { _ = await getPosts(courseID: courseID, @@ -187,6 +188,7 @@ public class PostsViewModel: ObservableObject { @MainActor public func getPosts(courseID: String, pageNumber: Int, withProgress: Bool = true) async -> Bool { + fetchInProgress = true isShowProgress = withProgress do { switch type { @@ -200,6 +202,7 @@ 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 @@ -211,6 +214,7 @@ 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 @@ -222,6 +226,7 @@ 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 @@ -233,6 +238,7 @@ 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 diff --git a/Discussion/Discussion/SwiftGen/Strings.swift b/Discussion/Discussion/SwiftGen/Strings.swift index d23922bea..61b06aecd 100644 --- a/Discussion/Discussion/SwiftGen/Strings.swift +++ b/Discussion/Discussion/SwiftGen/Strings.swift @@ -86,6 +86,14 @@ public enum DiscussionLocalization { /// Unread public static let unread = DiscussionLocalization.tr("Localizable", "POSTS.FILTER.UNREAD", fallback: "Unread") } + public enum NoDiscussion { + /// Create discussion + public static let createbutton = DiscussionLocalization.tr("Localizable", "POSTS.NO_DISCUSSION.CREATEBUTTON", fallback: "Create discussion") + /// Click the button below to create your first discussion. + public static let description = DiscussionLocalization.tr("Localizable", "POSTS.NO_DISCUSSION.DESCRIPTION", fallback: "Click the button below to create your first discussion.") + /// No discussions yet + public static let title = DiscussionLocalization.tr("Localizable", "POSTS.NO_DISCUSSION.TITLE", fallback: "No discussions yet") + } public enum Sort { /// Most Activity public static let mostActivity = DiscussionLocalization.tr("Localizable", "POSTS.SORT.MOST_ACTIVITY", fallback: "Most Activity") diff --git a/Discussion/Discussion/en.lproj/Localizable.strings b/Discussion/Discussion/en.lproj/Localizable.strings index 1e9b4413a..110b0cae5 100644 --- a/Discussion/Discussion/en.lproj/Localizable.strings +++ b/Discussion/Discussion/en.lproj/Localizable.strings @@ -16,6 +16,9 @@ "POSTS.SORT.RECENT_ACTIVITY" = "Recent Activity"; "POSTS.SORT.MOST_ACTIVITY" = "Most Activity"; "POSTS.SORT.MOST_VOTES" = "Most Votes"; +"POSTS.NO_DISCUSSION.TITLE" = "No discussions yet"; +"POSTS.NO_DISCUSSION.DESCRIPTION" = "Click the button below to create your first discussion."; +"POSTS.NO_DISCUSSION.CREATEBUTTON" = "Create discussion"; "POSTS.FILTER.ALL_POSTS" = "All Posts"; "POSTS.FILTER.UNREAD" = "Unread"; diff --git a/Discussion/Discussion/uk.lproj/Localizable.strings b/Discussion/Discussion/uk.lproj/Localizable.strings index fbb1300dd..f54f4796f 100644 --- a/Discussion/Discussion/uk.lproj/Localizable.strings +++ b/Discussion/Discussion/uk.lproj/Localizable.strings @@ -20,6 +20,9 @@ "POSTS.FILTER.ALL_POSTS" = "Всі пости"; "POSTS.FILTER.UNREAD" = "Непрочитаних"; "POSTS.FILTER.UNANSWERED" = "Без відповіді"; +"POSTS.NO_DISCUSSION.TITLE" = "Ще немає дискусій"; +"POSTS.NO_DISCUSSION.DESCRIPTION" = "Натисніть кнопку нижче, щоб створити свою першу дискусію."; +"POSTS.NO_DISCUSSION.CREATEBUTTON" = "Створити дискусію"; "POSTS.CREATE_NEW_POST" = "Створити новий пост"; "POSTS.ALERT.MAKE_SELECTION" = "Оберіть"; diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index f08d3bc5a..7533d76d0 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -121,17 +121,20 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } - open func registerUser(fields: [String: String]) throws { + open func registerUser(fields: [String: String]) throws -> User { addInvocation(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))) let perform = methodPerformValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))) as? ([String: String]) -> Void perform?(`fields`) + var __value: User do { - _ = try methodReturnValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() as Void + __value = try methodReturnValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() } catch MockError.notStubed { - // do nothing + onFatalFailure("Stub return value not specified for registerUser(fields: [String: String]). Use given") + Failure("Stub return value not specified for registerUser(fields: [String: String]). Use given") } catch { throw error } + return __value } open func validateRegistrationFields(fields: [String: String]) throws -> [String: String] { @@ -233,6 +236,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func getRegistrationFields(willReturn: [PickerFields]...) -> MethodStub { return Given(method: .m_getRegistrationFields, products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func registerUser(fields: Parameter<[String: String]>, willReturn: User...) -> MethodStub { + return Given(method: .m_registerUser__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func validateRegistrationFields(fields: Parameter<[String: String]>, willReturn: [String: String]...) -> MethodStub { return Given(method: .m_validateRegistrationFields__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -281,10 +287,10 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func registerUser(fields: Parameter<[String: String]>, willThrow: Error...) -> MethodStub { return Given(method: .m_registerUser__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func registerUser(fields: Parameter<[String: String]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func registerUser(fields: Parameter<[String: String]>, willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] let given: Given = { return Given(method: .m_registerUser__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + let stubber = given.stubThrows(for: (User).self) willProduce(stubber) return given } @@ -514,10 +520,10 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`) } - open func presentAlert(alertTitle: String, alertMessage: String, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void) { - addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) - let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) as? (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void - perform?(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`) + open func presentAlert(alertTitle: String, alertMessage: String, nextSectionName: String?, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, nextSectionTapped: @escaping () -> Void) { + addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) + let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) as? (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void + perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { @@ -544,7 +550,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showRegisterScreen case m_showForgotPasswordScreen case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) - case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>) + case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) case m_presentView__transitionStyle_transitionStylecontent_content(Parameter, Parameter<() -> any View>) @@ -590,14 +596,16 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) return Matcher.ComparisonResult(results) - case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped)): + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsNextsectionname, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped, let lhsNextsectiontapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsNextsectionname, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped, let rhsNextsectiontapped)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlertmessage, rhs: rhsAlertmessage, with: matcher), lhsAlertmessage, rhsAlertmessage, "alertMessage")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectionname, rhs: rhsNextsectionname, with: matcher), lhsNextsectionname, rhsNextsectionname, "nextSectionName")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsImage, rhs: rhsImage, with: matcher), lhsImage, rhsImage, "image")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOnclosetapped, rhs: rhsOnclosetapped, with: matcher), lhsOnclosetapped, rhsOnclosetapped, "onCloseTapped")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOktapped, rhs: rhsOktapped, with: matcher), lhsOktapped, rhsOktapped, "okTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): @@ -627,7 +635,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue - case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue case let .m_presentView__transitionStyle_transitionStylecontent_content(p0, p1): return p0.intValue + p1.intValue } @@ -644,7 +652,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" - case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped: return ".presentAlert(alertTitle:alertMessage:action:image:onCloseTapped:okTapped:)" + case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" case .m_presentView__transitionStyle_transitionStylecontent_content: return ".presentView(transitionStyle:content:)" } @@ -675,7 +683,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`))} + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} public static func presentView(transitionStyle: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStylecontent_content(`transitionStyle`, `content`))} } @@ -714,8 +722,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, perform: @escaping (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { - return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`), performs: perform) + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { + return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) @@ -994,6 +1002,222 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - DiscussionAnalytics + +open class DiscussionAnalyticsMock: DiscussionAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func discussionAllPostsClicked(courseId: String, courseName: String) { + addInvocation(.m_discussionAllPostsClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_discussionAllPostsClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + + open func discussionFollowingClicked(courseId: String, courseName: String) { + addInvocation(.m_discussionFollowingClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_discussionFollowingClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + + open func discussionTopicClicked(courseId: String, courseName: String, topicId: String, topicName: String) { + addInvocation(.m_discussionTopicClicked__courseId_courseIdcourseName_courseNametopicId_topicIdtopicName_topicName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`topicId`), Parameter.value(`topicName`))) + let perform = methodPerformValue(.m_discussionTopicClicked__courseId_courseIdcourseName_courseNametopicId_topicIdtopicName_topicName(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`topicId`), Parameter.value(`topicName`))) as? (String, String, String, String) -> Void + perform?(`courseId`, `courseName`, `topicId`, `topicName`) + } + + + fileprivate enum MethodType { + case m_discussionAllPostsClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_discussionFollowingClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_discussionTopicClicked__courseId_courseIdcourseName_courseNametopicId_topicIdtopicName_topicName(Parameter, Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_discussionAllPostsClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_discussionAllPostsClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + + case (.m_discussionFollowingClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_discussionFollowingClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + + case (.m_discussionTopicClicked__courseId_courseIdcourseName_courseNametopicId_topicIdtopicName_topicName(let lhsCourseid, let lhsCoursename, let lhsTopicid, let lhsTopicname), .m_discussionTopicClicked__courseId_courseIdcourseName_courseNametopicId_topicIdtopicName_topicName(let rhsCourseid, let rhsCoursename, let rhsTopicid, let rhsTopicname)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTopicid, rhs: rhsTopicid, with: matcher), lhsTopicid, rhsTopicid, "topicId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTopicname, rhs: rhsTopicname, with: matcher), lhsTopicname, rhsTopicname, "topicName")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_discussionAllPostsClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_discussionFollowingClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_discussionTopicClicked__courseId_courseIdcourseName_courseNametopicId_topicIdtopicName_topicName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + } + } + func assertionName() -> String { + switch self { + case .m_discussionAllPostsClicked__courseId_courseIdcourseName_courseName: return ".discussionAllPostsClicked(courseId:courseName:)" + case .m_discussionFollowingClicked__courseId_courseIdcourseName_courseName: return ".discussionFollowingClicked(courseId:courseName:)" + case .m_discussionTopicClicked__courseId_courseIdcourseName_courseNametopicId_topicIdtopicName_topicName: return ".discussionTopicClicked(courseId:courseName:topicId:topicName:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func discussionAllPostsClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_discussionAllPostsClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func discussionFollowingClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_discussionFollowingClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func discussionTopicClicked(courseId: Parameter, courseName: Parameter, topicId: Parameter, topicName: Parameter) -> Verify { return Verify(method: .m_discussionTopicClicked__courseId_courseIdcourseName_courseNametopicId_topicIdtopicName_topicName(`courseId`, `courseName`, `topicId`, `topicName`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func discussionAllPostsClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_discussionAllPostsClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } + public static func discussionFollowingClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_discussionFollowingClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } + public static func discussionTopicClicked(courseId: Parameter, courseName: Parameter, topicId: Parameter, topicName: Parameter, perform: @escaping (String, String, String, String) -> Void) -> Perform { + return Perform(method: .m_discussionTopicClicked__courseId_courseIdcourseName_courseNametopicId_topicIdtopicName_topicName(`courseId`, `courseName`, `topicId`, `topicName`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DiscussionInteractorProtocol open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock { @@ -1038,16 +1262,16 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock - open func getThreadsList(courseID: String, type: ThreadType, filter: ThreadsFilter, page: Int) throws -> ThreadLists { - addInvocation(.m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page(Parameter.value(`courseID`), Parameter.value(`type`), Parameter.value(`filter`), Parameter.value(`page`))) - let perform = methodPerformValue(.m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page(Parameter.value(`courseID`), Parameter.value(`type`), Parameter.value(`filter`), Parameter.value(`page`))) as? (String, ThreadType, ThreadsFilter, Int) -> Void - perform?(`courseID`, `type`, `filter`, `page`) + open func getThreadsList(courseID: String, type: ThreadType, sort: SortType, filter: ThreadsFilter, page: Int) throws -> ThreadLists { + addInvocation(.m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(Parameter.value(`courseID`), Parameter.value(`type`), Parameter.value(`sort`), Parameter.value(`filter`), Parameter.value(`page`))) + let perform = methodPerformValue(.m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(Parameter.value(`courseID`), Parameter.value(`type`), Parameter.value(`sort`), Parameter.value(`filter`), Parameter.value(`page`))) as? (String, ThreadType, SortType, ThreadsFilter, Int) -> Void + perform?(`courseID`, `type`, `sort`, `filter`, `page`) var __value: ThreadLists do { - __value = try methodReturnValue(.m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page(Parameter.value(`courseID`), Parameter.value(`type`), Parameter.value(`filter`), Parameter.value(`page`))).casted() + __value = try methodReturnValue(.m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(Parameter.value(`courseID`), Parameter.value(`type`), Parameter.value(`sort`), Parameter.value(`filter`), Parameter.value(`page`))).casted() } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for getThreadsList(courseID: String, type: ThreadType, filter: ThreadsFilter, page: Int). Use given") - Failure("Stub return value not specified for getThreadsList(courseID: String, type: ThreadType, filter: ThreadsFilter, page: Int). Use given") + onFatalFailure("Stub return value not specified for getThreadsList(courseID: String, type: ThreadType, sort: SortType, filter: ThreadsFilter, page: Int). Use given") + Failure("Stub return value not specified for getThreadsList(courseID: String, type: ThreadType, sort: SortType, filter: ThreadsFilter, page: Int). Use given") } catch { throw error } @@ -1086,11 +1310,11 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock return __value } - open func getDiscussionComments(threadID: String, page: Int) throws -> ([UserComment], Int) { + open func getDiscussionComments(threadID: String, page: Int) throws -> ([UserComment], Pagination) { addInvocation(.m_getDiscussionComments__threadID_threadIDpage_page(Parameter.value(`threadID`), Parameter.value(`page`))) let perform = methodPerformValue(.m_getDiscussionComments__threadID_threadIDpage_page(Parameter.value(`threadID`), Parameter.value(`page`))) as? (String, Int) -> Void perform?(`threadID`, `page`) - var __value: ([UserComment], Int) + var __value: ([UserComment], Pagination) do { __value = try methodReturnValue(.m_getDiscussionComments__threadID_threadIDpage_page(Parameter.value(`threadID`), Parameter.value(`page`))).casted() } catch MockError.notStubed { @@ -1102,11 +1326,11 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock return __value } - open func getQuestionComments(threadID: String, page: Int) throws -> ([UserComment], Int) { + open func getQuestionComments(threadID: String, page: Int) throws -> ([UserComment], Pagination) { addInvocation(.m_getQuestionComments__threadID_threadIDpage_page(Parameter.value(`threadID`), Parameter.value(`page`))) let perform = methodPerformValue(.m_getQuestionComments__threadID_threadIDpage_page(Parameter.value(`threadID`), Parameter.value(`page`))) as? (String, Int) -> Void perform?(`threadID`, `page`) - var __value: ([UserComment], Int) + var __value: ([UserComment], Pagination) do { __value = try methodReturnValue(.m_getQuestionComments__threadID_threadIDpage_page(Parameter.value(`threadID`), Parameter.value(`page`))).casted() } catch MockError.notStubed { @@ -1118,11 +1342,11 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock return __value } - open func getCommentResponses(commentID: String, page: Int) throws -> ([UserComment], Int) { + open func getCommentResponses(commentID: String, page: Int) throws -> ([UserComment], Pagination) { addInvocation(.m_getCommentResponses__commentID_commentIDpage_page(Parameter.value(`commentID`), Parameter.value(`page`))) let perform = methodPerformValue(.m_getCommentResponses__commentID_commentIDpage_page(Parameter.value(`commentID`), Parameter.value(`page`))) as? (String, Int) -> Void perform?(`commentID`, `page`) - var __value: ([UserComment], Int) + var __value: ([UserComment], Pagination) do { __value = try methodReturnValue(.m_getCommentResponses__commentID_commentIDpage_page(Parameter.value(`commentID`), Parameter.value(`page`))).casted() } catch MockError.notStubed { @@ -1243,7 +1467,7 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock fileprivate enum MethodType { - case m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page(Parameter, Parameter, Parameter, Parameter) + case m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(Parameter, Parameter, Parameter, Parameter, Parameter) case m_getTopics__courseID_courseID(Parameter) case m_searchThreads__courseID_courseIDsearchText_searchTextpageNumber_pageNumber(Parameter, Parameter, Parameter) case m_getDiscussionComments__threadID_threadIDpage_page(Parameter, Parameter) @@ -1260,10 +1484,11 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { - case (.m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page(let lhsCourseid, let lhsType, let lhsFilter, let lhsPage), .m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page(let rhsCourseid, let rhsType, let rhsFilter, let rhsPage)): + case (.m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(let lhsCourseid, let lhsType, let lhsSort, let lhsFilter, let lhsPage), .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(let rhsCourseid, let rhsType, let rhsSort, let rhsFilter, let rhsPage)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSort, rhs: rhsSort, with: matcher), lhsSort, rhsSort, "sort")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFilter, rhs: rhsFilter, with: matcher), lhsFilter, rhsFilter, "filter")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPage, rhs: rhsPage, with: matcher), lhsPage, rhsPage, "page")) return Matcher.ComparisonResult(results) @@ -1350,7 +1575,7 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock func intValue() -> Int { switch self { - case let .m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(p0, p1, p2, p3, p4): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue case let .m_getTopics__courseID_courseID(p0): return p0.intValue case let .m_searchThreads__courseID_courseIDsearchText_searchTextpageNumber_pageNumber(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_getDiscussionComments__threadID_threadIDpage_page(p0, p1): return p0.intValue + p1.intValue @@ -1368,7 +1593,7 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock } func assertionName() -> String { switch self { - case .m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page: return ".getThreadsList(courseID:type:filter:page:)" + case .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page: return ".getThreadsList(courseID:type:sort:filter:page:)" case .m_getTopics__courseID_courseID: return ".getTopics(courseID:)" case .m_searchThreads__courseID_courseIDsearchText_searchTextpageNumber_pageNumber: return ".searchThreads(courseID:searchText:pageNumber:)" case .m_getDiscussionComments__threadID_threadIDpage_page: return ".getDiscussionComments(threadID:page:)" @@ -1395,8 +1620,8 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock } - public static func getThreadsList(courseID: Parameter, type: Parameter, filter: Parameter, page: Parameter, willReturn: ThreadLists...) -> MethodStub { - return Given(method: .m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page(`courseID`, `type`, `filter`, `page`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func getThreadsList(courseID: Parameter, type: Parameter, sort: Parameter, filter: Parameter, page: Parameter, willReturn: ThreadLists...) -> MethodStub { + return Given(method: .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(`courseID`, `type`, `sort`, `filter`, `page`), products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func getTopics(courseID: Parameter, willReturn: Topics...) -> MethodStub { return Given(method: .m_getTopics__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) @@ -1404,24 +1629,24 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock public static func searchThreads(courseID: Parameter, searchText: Parameter, pageNumber: Parameter, willReturn: ThreadLists...) -> MethodStub { return Given(method: .m_searchThreads__courseID_courseIDsearchText_searchTextpageNumber_pageNumber(`courseID`, `searchText`, `pageNumber`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getDiscussionComments(threadID: Parameter, page: Parameter, willReturn: ([UserComment], Int)...) -> MethodStub { + public static func getDiscussionComments(threadID: Parameter, page: Parameter, willReturn: ([UserComment], Pagination)...) -> MethodStub { return Given(method: .m_getDiscussionComments__threadID_threadIDpage_page(`threadID`, `page`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getQuestionComments(threadID: Parameter, page: Parameter, willReturn: ([UserComment], Int)...) -> MethodStub { + public static func getQuestionComments(threadID: Parameter, page: Parameter, willReturn: ([UserComment], Pagination)...) -> MethodStub { return Given(method: .m_getQuestionComments__threadID_threadIDpage_page(`threadID`, `page`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getCommentResponses(commentID: Parameter, page: Parameter, willReturn: ([UserComment], Int)...) -> MethodStub { + public static func getCommentResponses(commentID: Parameter, page: Parameter, willReturn: ([UserComment], Pagination)...) -> MethodStub { return Given(method: .m_getCommentResponses__commentID_commentIDpage_page(`commentID`, `page`), products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func addCommentTo(threadID: Parameter, rawBody: Parameter, parentID: Parameter, willReturn: Post...) -> MethodStub { return Given(method: .m_addCommentTo__threadID_threadIDrawBody_rawBodyparentID_parentID(`threadID`, `rawBody`, `parentID`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getThreadsList(courseID: Parameter, type: Parameter, filter: Parameter, page: Parameter, willThrow: Error...) -> MethodStub { - return Given(method: .m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page(`courseID`, `type`, `filter`, `page`), products: willThrow.map({ StubProduct.throw($0) })) + public static func getThreadsList(courseID: Parameter, type: Parameter, sort: Parameter, filter: Parameter, page: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(`courseID`, `type`, `sort`, `filter`, `page`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func getThreadsList(courseID: Parameter, type: Parameter, filter: Parameter, page: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func getThreadsList(courseID: Parameter, type: Parameter, sort: Parameter, filter: Parameter, page: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page(`courseID`, `type`, `filter`, `page`), products: willThrow.map({ StubProduct.throw($0) })) }() + let given: Given = { return Given(method: .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(`courseID`, `type`, `sort`, `filter`, `page`), products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: (ThreadLists).self) willProduce(stubber) return given @@ -1449,30 +1674,30 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock public static func getDiscussionComments(threadID: Parameter, page: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_getDiscussionComments__threadID_threadIDpage_page(`threadID`, `page`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func getDiscussionComments(threadID: Parameter, page: Parameter, willProduce: (StubberThrows<([UserComment], Int)>) -> Void) -> MethodStub { + public static func getDiscussionComments(threadID: Parameter, page: Parameter, willProduce: (StubberThrows<([UserComment], Pagination)>) -> Void) -> MethodStub { let willThrow: [Error] = [] let given: Given = { return Given(method: .m_getDiscussionComments__threadID_threadIDpage_page(`threadID`, `page`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (([UserComment], Int)).self) + let stubber = given.stubThrows(for: (([UserComment], Pagination)).self) willProduce(stubber) return given } public static func getQuestionComments(threadID: Parameter, page: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_getQuestionComments__threadID_threadIDpage_page(`threadID`, `page`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func getQuestionComments(threadID: Parameter, page: Parameter, willProduce: (StubberThrows<([UserComment], Int)>) -> Void) -> MethodStub { + public static func getQuestionComments(threadID: Parameter, page: Parameter, willProduce: (StubberThrows<([UserComment], Pagination)>) -> Void) -> MethodStub { let willThrow: [Error] = [] let given: Given = { return Given(method: .m_getQuestionComments__threadID_threadIDpage_page(`threadID`, `page`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (([UserComment], Int)).self) + let stubber = given.stubThrows(for: (([UserComment], Pagination)).self) willProduce(stubber) return given } public static func getCommentResponses(commentID: Parameter, page: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_getCommentResponses__commentID_commentIDpage_page(`commentID`, `page`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func getCommentResponses(commentID: Parameter, page: Parameter, willProduce: (StubberThrows<([UserComment], Int)>) -> Void) -> MethodStub { + public static func getCommentResponses(commentID: Parameter, page: Parameter, willProduce: (StubberThrows<([UserComment], Pagination)>) -> Void) -> MethodStub { let willThrow: [Error] = [] let given: Given = { return Given(method: .m_getCommentResponses__commentID_commentIDpage_page(`commentID`, `page`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (([UserComment], Int)).self) + let stubber = given.stubThrows(for: (([UserComment], Pagination)).self) willProduce(stubber) return given } @@ -1561,7 +1786,7 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock public struct Verify { fileprivate var method: MethodType - public static func getThreadsList(courseID: Parameter, type: Parameter, filter: Parameter, page: Parameter) -> Verify { return Verify(method: .m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page(`courseID`, `type`, `filter`, `page`))} + public static func getThreadsList(courseID: Parameter, type: Parameter, sort: Parameter, filter: Parameter, page: Parameter) -> Verify { return Verify(method: .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(`courseID`, `type`, `sort`, `filter`, `page`))} public static func getTopics(courseID: Parameter) -> Verify { return Verify(method: .m_getTopics__courseID_courseID(`courseID`))} public static func searchThreads(courseID: Parameter, searchText: Parameter, pageNumber: Parameter) -> Verify { return Verify(method: .m_searchThreads__courseID_courseIDsearchText_searchTextpageNumber_pageNumber(`courseID`, `searchText`, `pageNumber`))} public static func getDiscussionComments(threadID: Parameter, page: Parameter) -> Verify { return Verify(method: .m_getDiscussionComments__threadID_threadIDpage_page(`threadID`, `page`))} @@ -1581,8 +1806,8 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock fileprivate var method: MethodType var performs: Any - public static func getThreadsList(courseID: Parameter, type: Parameter, filter: Parameter, page: Parameter, perform: @escaping (String, ThreadType, ThreadsFilter, Int) -> Void) -> Perform { - return Perform(method: .m_getThreadsList__courseID_courseIDtype_typefilter_filterpage_page(`courseID`, `type`, `filter`, `page`), performs: perform) + public static func getThreadsList(courseID: Parameter, type: Parameter, sort: Parameter, filter: Parameter, page: Parameter, perform: @escaping (String, ThreadType, SortType, ThreadsFilter, Int) -> Void) -> Perform { + return Perform(method: .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(`courseID`, `type`, `sort`, `filter`, `page`), performs: perform) } public static func getTopics(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_getTopics__courseID_courseID(`courseID`), performs: perform) @@ -1832,10 +2057,10 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { perform?(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`) } - open func presentAlert(alertTitle: String, alertMessage: String, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void) { - addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) - let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) as? (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void - perform?(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`) + open func presentAlert(alertTitle: String, alertMessage: String, nextSectionName: String?, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, nextSectionTapped: @escaping () -> Void) { + addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) + let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) as? (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void + perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { @@ -1867,7 +2092,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case m_showRegisterScreen case m_showForgotPasswordScreen case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) - case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>) + case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) case m_presentView__transitionStyle_transitionStylecontent_content(Parameter, Parameter<() -> any View>) @@ -1946,14 +2171,16 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) return Matcher.ComparisonResult(results) - case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped)): + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsNextsectionname, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped, let lhsNextsectiontapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsNextsectionname, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped, let rhsNextsectiontapped)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlertmessage, rhs: rhsAlertmessage, with: matcher), lhsAlertmessage, rhsAlertmessage, "alertMessage")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectionname, rhs: rhsNextsectionname, with: matcher), lhsNextsectionname, rhsNextsectionname, "nextSectionName")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsImage, rhs: rhsImage, with: matcher), lhsImage, rhsImage, "image")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOnclosetapped, rhs: rhsOnclosetapped, with: matcher), lhsOnclosetapped, rhsOnclosetapped, "onCloseTapped")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOktapped, rhs: rhsOktapped, with: matcher), lhsOktapped, rhsOktapped, "okTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): @@ -1988,7 +2215,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue - case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue case let .m_presentView__transitionStyle_transitionStylecontent_content(p0, p1): return p0.intValue + p1.intValue } @@ -2010,7 +2237,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" - case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped: return ".presentAlert(alertTitle:alertMessage:action:image:onCloseTapped:okTapped:)" + case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" case .m_presentView__transitionStyle_transitionStylecontent_content: return ".presentView(transitionStyle:content:)" } @@ -2046,7 +2273,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`))} + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} public static func presentView(transitionStyle: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStylecontent_content(`transitionStyle`, `content`))} } @@ -2100,8 +2327,8 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, perform: @escaping (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { - return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`), performs: perform) + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { + return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) diff --git a/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift index fe84c0c57..ee4da8ee4 100644 --- a/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift @@ -41,12 +41,14 @@ final class BaseResponsesViewModelTests: XCTestCase { threadID: "1", commentID: "1", parentID: nil, - abuseFlagged: false) + abuseFlagged: false, + closed: false) ], threadID: "1", commentID: "1", parentID: nil, - abuseFlagged: false) + abuseFlagged: false, + closed: false) func testVoteThreadSuccess() async throws { let interactor = DiscussionInteractorProtocolMock() @@ -348,7 +350,8 @@ final class BaseResponsesViewModelTests: XCTestCase { threadID: "3", commentID: "3", parentID: nil, - abuseFlagged: false) + abuseFlagged: false, + closed: false) viewModel.addNewPost(newPost) diff --git a/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift index 774039847..d0909522b 100644 --- a/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift @@ -76,6 +76,7 @@ final class ThreadViewModelTests: XCTestCase { type: .question, title: "1", pinned: false, + closed: false, following: false, commentCount: 1, avatar: "1", @@ -96,6 +97,7 @@ final class ThreadViewModelTests: XCTestCase { type: .discussion, title: "2", pinned: false, + closed: false, following: false, commentCount: 2, avatar: "2", @@ -116,6 +118,7 @@ final class ThreadViewModelTests: XCTestCase { type: .discussion, title: "3", pinned: false, + closed: false, following: false, commentCount: 3, avatar: "3", @@ -136,6 +139,7 @@ final class ThreadViewModelTests: XCTestCase { type: .question, title: "4", pinned: false, + closed: false, following: false, commentCount: 1, avatar: "4", @@ -172,7 +176,8 @@ final class ThreadViewModelTests: XCTestCase { threadID: "2", commentID: "2", parentID: nil, - abuseFlagged: false), + abuseFlagged: false, + closed: false), Post(authorName: "2", authorAvatar: "2", postDate: Date(), @@ -188,12 +193,14 @@ final class ThreadViewModelTests: XCTestCase { threadID: "2", commentID: "2", parentID: nil, - abuseFlagged: false) + abuseFlagged: false, + closed: false) ], threadID: "1", commentID: "1", parentID: nil, - abuseFlagged: false + abuseFlagged: false, + closed: false ) func testGetQuestionPostsSuccess() async { @@ -209,8 +216,12 @@ final class ThreadViewModelTests: XCTestCase { postStateSubject: .init(.readed(id: "1"))) Given(interactor, .readBody(threadID: .any, willProduce: {_ in})) - Given(interactor, .getQuestionComments(threadID: .any, page: .any, willReturn: (userComments, 1))) - + Given(interactor, .getQuestionComments(threadID: .any, page: .any, + willReturn: (userComments, Pagination(next: "", + previous: "", + count: 1, + numPages: 1)))) + result = await viewModel.getPosts(thread: threads.threads[0], page: 1) Verify(interactor, .readBody(threadID: .value(threads.threads[0].id))) @@ -235,7 +246,11 @@ final class ThreadViewModelTests: XCTestCase { postStateSubject: .init(.readed(id: "1"))) Given(interactor, .readBody(threadID: .any, willProduce: {_ in})) - Given(interactor, .getDiscussionComments(threadID: .any, page: .any, willReturn: (userComments, 1))) + Given(interactor, .getDiscussionComments(threadID: .any, page: .any, + willReturn: (userComments, Pagination(next: "", + previous: "", + count: 1, + numPages: 1)))) result = await viewModel.getPosts(thread: threads.threads[1], page: 1) @@ -335,7 +350,8 @@ final class ThreadViewModelTests: XCTestCase { threadID: "", commentID: "", parentID: nil, - abuseFlagged: true) + abuseFlagged: true, + closed: false) Given(interactor, .addCommentTo(threadID: .any, rawBody: .any, parentID: .any, willReturn: post) ) @@ -412,7 +428,11 @@ final class ThreadViewModelTests: XCTestCase { viewModel.totalPages = 2 viewModel.comments = userComments + userComments - Given(interactor, .getQuestionComments(threadID: .any, page: .any, willReturn: (userComments, 1))) + Given(interactor, .getQuestionComments(threadID: .any, page: .any, + willReturn: (userComments, Pagination(next: "", + previous: "", + count: 1, + numPages: 1)))) result = await viewModel.fetchMorePosts(thread: threads.threads[0], index: 3) diff --git a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift index 4297f2e52..f94484fc2 100644 --- a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift @@ -37,6 +37,7 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { type: .discussion, title: "1", pinned: false, + closed: false, following: true, commentCount: 1, avatar: "avatar", @@ -74,31 +75,6 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { debounce: .test) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) - - let items = ThreadLists( - threads: [ - UserThread(id: "1", - author: "1", - authorLabel: "1", - createdAt: Date(), - updatedAt: Date(), - rawBody: "1", - renderedBody: "1", - voted: false, - voteCount: 1, - courseID: "1", - type: .discussion, - title: "1", - pinned: false, - following: true, - commentCount: 1, - avatar: "avatar", - unreadCommentCount: 1, - abuseFlagged: false, - hasEndorsed: true, - numPages: 1) - ] - ) Given(interactor, .searchThreads(courseID: .any, searchText: .any, pageNumber: .any, willThrow: noInternetError)) @@ -126,31 +102,6 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { interactor: interactor, router: router, debounce: .test) - - let items = ThreadLists( - threads: [ - UserThread(id: "1", - author: "1", - authorLabel: "1", - createdAt: Date(), - updatedAt: Date(), - rawBody: "1", - renderedBody: "1", - voted: false, - voteCount: 1, - courseID: "1", - type: .discussion, - title: "1", - pinned: false, - following: true, - commentCount: 1, - avatar: "avatar", - unreadCommentCount: 1, - abuseFlagged: false, - hasEndorsed: true, - numPages: 1) - ] - ) Given(interactor, .searchThreads(courseID: .any, searchText: .any, pageNumber: .any, willThrow: NSError())) diff --git a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift index 8b62b4049..e411e7fcd 100644 --- a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift @@ -39,8 +39,13 @@ final class DiscussionTopicsViewModelTests: XCTestCase { func testGetTopicsSuccess() async throws { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() + let analytics = DiscussionAnalyticsMock() let config = ConfigMock() - let viewModel = DiscussionTopicsViewModel(interactor: interactor, router: router, config: config) + let viewModel = DiscussionTopicsViewModel(title: "", + interactor: interactor, + router: router, + analytics: analytics, + config: config) Given(interactor, .getTopics(courseID: .any, willReturn: topics)) @@ -58,8 +63,13 @@ final class DiscussionTopicsViewModelTests: XCTestCase { func testGetTopicsNoInternetError() async throws { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() + let analytics = DiscussionAnalyticsMock() let config = ConfigMock() - let viewModel = DiscussionTopicsViewModel(interactor: interactor, router: router, config: config) + let viewModel = DiscussionTopicsViewModel(title: "", + interactor: interactor, + router: router, + analytics: analytics, + config: config) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -79,9 +89,14 @@ final class DiscussionTopicsViewModelTests: XCTestCase { func testGetTopicsUnknownError() async throws { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() + let analytics = DiscussionAnalyticsMock() let config = ConfigMock() - let viewModel = DiscussionTopicsViewModel(interactor: interactor, router: router, config: config) - + let viewModel = DiscussionTopicsViewModel(title: "", + interactor: interactor, + router: router, + analytics: analytics, + config: config) + Given(interactor, .getTopics(courseID: .any, willThrow: NSError())) await viewModel.getTopics(courseID: "1") diff --git a/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift index 3250b8d9d..cd4dbab3c 100644 --- a/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift @@ -28,6 +28,7 @@ final class PostViewModelTests: XCTestCase { type: .question, title: "1", pinned: false, + closed: false, following: false, commentCount: 1, avatar: "1", @@ -48,6 +49,7 @@ final class PostViewModelTests: XCTestCase { type: .discussion, title: "2", pinned: false, + closed: false, following: false, commentCount: 2, avatar: "2", @@ -68,6 +70,7 @@ final class PostViewModelTests: XCTestCase { type: .question, title: "3", pinned: false, + closed: false, following: false, commentCount: 3, avatar: "3", @@ -88,6 +91,7 @@ final class PostViewModelTests: XCTestCase { type: .question, title: "4", pinned: false, + closed: false, following: false, commentCount: 4, avatar: "4", @@ -106,7 +110,7 @@ final class PostViewModelTests: XCTestCase { viewModel.type = .allPosts - Given(interactor, .getThreadsList(courseID: .any, type: .any, filter: .any, page: .any, willReturn: threads)) + 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) @@ -132,7 +136,7 @@ final class PostViewModelTests: XCTestCase { result = await viewModel.getPosts(courseID: "1", pageNumber: 1) XCTAssertFalse(result) - Verify(interactor, 4, .getThreadsList(courseID: .value("1"), type: .any, filter: .any, page: .value(1))) + Verify(interactor, 4, .getThreadsList(courseID: .value("1"), type: .any, sort: .any, filter: .any, page: .value(1))) XCTAssertFalse(viewModel.isShowProgress) XCTAssertFalse(viewModel.showError) @@ -148,12 +152,12 @@ final class PostViewModelTests: XCTestCase { let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) - Given(interactor, .getThreadsList(courseID: .any, type: .any, filter: .any, page: .any, willThrow: noInternetError)) + Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any, willThrow: noInternetError)) viewModel.type = .allPosts result = await viewModel.getPosts(courseID: "1", pageNumber: 1) - Verify(interactor, 1, .getThreadsList(courseID: .any, type: .any, filter: .any, page: .any)) + Verify(interactor, 1, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any)) XCTAssertFalse(result) XCTAssertFalse(viewModel.isShowProgress) @@ -168,12 +172,12 @@ final class PostViewModelTests: XCTestCase { var result = false let viewModel = PostsViewModel(interactor: interactor, router: router, config: config) - Given(interactor, .getThreadsList(courseID: .any, type: .any, filter: .any, page: .any, willThrow: NSError())) + Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any, willThrow: NSError())) viewModel.type = .allPosts result = await viewModel.getPosts(courseID: "1", pageNumber: 1) - Verify(interactor, 1, .getThreadsList(courseID: .any, type: .any, filter: .any, page: .any)) + Verify(interactor, 1, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any)) XCTAssertFalse(result) XCTAssertFalse(viewModel.isShowProgress) @@ -187,26 +191,32 @@ final class PostViewModelTests: XCTestCase { let config = ConfigMock() let viewModel = PostsViewModel(interactor: interactor, router: router, config: config) - Given(interactor, .getThreadsList(courseID: .any, type: .any, filter: .any, page: .any, + Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any, willReturn: threads)) viewModel.type = .allPosts viewModel.sortTitle = .mostActivity _ = await viewModel.getPosts(courseID: "1", pageNumber: 1) - XCTAssertTrue(viewModel.filteredPosts[0].title == "4") + XCTAssertTrue(viewModel.filteredPosts[0].title == "1") + + Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .value(.recentActivity), filter: .any, page: .any, + willReturn: threads)) viewModel.filterTitle = .unread viewModel.sortTitle = .recentActivity _ = await viewModel.getPosts(courseID: "1", pageNumber: 1) - XCTAssertTrue(viewModel.filteredPosts[0].title == "2") + XCTAssertTrue(viewModel.filteredPosts[0].title == "1") XCTAssertNotNil(viewModel.filteredPosts.first(where: {$0.unreadCommentCount == 4})) + Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .value(.mostVotes), filter: .any, page: .any, + willReturn: threads)) + viewModel.filterTitle = .unanswered viewModel.sortTitle = .mostVotes _ = await viewModel.getPosts(courseID: "1", pageNumber: 1) - XCTAssertTrue(viewModel.filteredPosts[0].title == "3") + XCTAssertTrue(viewModel.filteredPosts[0].title == "1") XCTAssertNotNil(viewModel.filteredPosts.first(where: { $0.hasEndorsed })) - Verify(interactor, .getThreadsList(courseID: .any, type: .any, filter: .any, page: .any)) + Verify(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any)) } } diff --git a/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift index 6e9a0c9e1..183174526 100644 --- a/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift @@ -89,12 +89,14 @@ final class ResponsesViewModelTests: XCTestCase { threadID: "1", commentID: "1", parentID: nil, - abuseFlagged: false) + abuseFlagged: false, + closed: false) ], threadID: "1", commentID: "1", parentID: nil, - abuseFlagged: false + abuseFlagged: false, + closed: false ) func testGetCommentsSuccess() async throws { @@ -109,7 +111,11 @@ final class ResponsesViewModelTests: XCTestCase { storage: .mock, threadStateSubject: .init(.postAdded(id: "1"))) - Given(interactor, .getCommentResponses(commentID: .any, page: .any, willReturn: (userComments, 1))) + Given(interactor, .getCommentResponses(commentID: .any, page: .any, + willReturn: (userComments, Pagination(next: "", + previous: "", + count: 1, + numPages: 1)))) result = await viewModel.getComments(commentID: "1", parentComment: post, page: 1) @@ -255,7 +261,11 @@ final class ResponsesViewModelTests: XCTestCase { viewModel.totalPages = 2 viewModel.comments = userComments - Given(interactor, .getCommentResponses(commentID: .any, page: .any, willReturn: (userComments, 0))) + Given(interactor, .getCommentResponses(commentID: .any, page: .any, + willReturn: (userComments, Pagination(next: "", + previous: "", + count: 1, + numPages: 1)))) await viewModel.fetchMorePosts(commentID: "1", parentComment: post, index: 0) diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..7a118b49b --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..0ee64adc5 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,218 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.6) + rexml + addressable (2.8.4) + public_suffix (>= 2.0.2, < 6.0) + artifactory (3.0.15) + atomos (0.1.3) + aws-eventstream (1.2.0) + aws-partitions (1.782.0) + aws-sdk-core (3.176.1) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.5) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.68.0) + aws-sdk-core (~> 3, >= 3.176.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.127.0) + aws-sdk-core (~> 3, >= 3.176.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.6) + aws-sigv4 (1.6.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.6.4) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.100.0) + faraday (1.10.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) + fastimage (2.2.7) + fastlane (2.213.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (~> 0.1.1) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.3) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (>= 1.4.5, < 2.0.0) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.44.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + webrick + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.19.0) + google-apis-core (>= 0.9.0, < 2.a) + google-cloud-core (1.6.0) + google-cloud-env (~> 1.0) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.3.1) + google-cloud-storage (1.44.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.19.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.6.0) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + memoist (~> 0.16) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.5) + domain_name (~> 0.5) + httpclient (2.8.3) + jmespath (1.6.2) + json (2.6.3) + jwt (2.7.1) + memoist (0.16.2) + mini_magick (4.12.0) + mini_mime (1.1.2) + multi_json (1.15.0) + multipart-post (2.3.0) + nanaimo (0.3.0) + naturally (2.2.1) + optparse (0.1.1) + os (1.1.4) + plist (3.7.0) + public_suffix (5.0.1) + rake (13.0.6) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.2.5) + rouge (2.0.7) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + security (0.1.3) + signet (0.17.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.1) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.2) + unicode-display_width (1.8.0) + webrick (1.8.1) + word_wrap (1.0.0) + xcodeproj (1.22.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + rexml (~> 3.2.4) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-22 + +DEPENDENCIES + fastlane + +BUNDLED WITH + 2.4.10 diff --git a/NewEdX/Environment.swift b/NewEdX/Environment.swift deleted file mode 100644 index ac6f5d2ab..000000000 --- a/NewEdX/Environment.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// Environment.swift -// NewEdX -// -// Created by Vladimir Chekyrta on 14.09.2022. -// - -import Foundation - -enum `Environment`: String { - case debugDev = "DebugDev" - case releaseDev = "ReleaseDev" - - case debugStage = "DebugStage" - case releaseStage = "ReleaseStage" - - case debugProd = "DebugProd" - case releaseProd = "ReleaseProd" -} - -class BuildConfiguration { - static let shared = BuildConfiguration() - - var environment: Environment - - var baseURL: String { - switch environment { - case .debugDev, .releaseDev: - return "https://example-dev.com" - case .debugStage, .releaseStage: - return "https://example-stage.com" - case .debugProd, .releaseProd: - return "https://example.com" - } - } - - var clientId: String { - switch environment { - case .debugDev, .releaseDev: - return "DEV_CLIENT_ID" - case .debugStage, .releaseStage: - return "STAGE_CLIENT_ID" - case .debugProd, .releaseProd: - return "PROD_CLIENT_ID" - } - } - - init() { - let currentConfiguration = Bundle.main.object(forInfoDictionaryKey: "Configuration") as! String - environment = Environment(rawValue: currentConfiguration)! - } -} diff --git a/NewEdX/StringExtension.swift b/NewEdX/StringExtension.swift deleted file mode 100644 index 96f2ba425..000000000 --- a/NewEdX/StringExtension.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// StringExtension.swift -// Core -// -// Created by  Stepanok Ivan on 29.09.2022. -// - -import Foundation -import SwiftUI -import Core -import Swinject - -public extension String { - public func injectCSS(colorScheme: ColorScheme) -> String { - let baseUrl = Container.shared.resolve(Config.self)!.baseURL.absoluteString - var replacedHTML = self.replacingOccurrences(of: "../..", with: baseUrl) - - func currentColor() -> String { - switch colorScheme { - case .light: - return "black" - case .dark: - return "white" - @unknown default: - return "black" - } - } - - let style = """ - - - -
- """ - print(">>>> STYLE", style) - return style + replacedHTML - } -} diff --git a/NewEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj similarity index 85% rename from NewEdX.xcodeproj/project.pbxproj rename to OpenEdX.xcodeproj/project.pbxproj index 31c72814e..35a452168 100644 --- a/NewEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -16,8 +16,10 @@ 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, ); }; }; + 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 */; }; + 02F175312A4DA95B0019CD70 /* MainScreenAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */; }; 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 071009C828D1DB3F00344290 /* ScreenAssembly.swift */; }; 0727876D28D23312002E9142 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727876C28D23312002E9142 /* Environment.swift */; }; 0727878E28D347C7002E9142 /* MainScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727878D28D347C7002E9142 /* MainScreenView.swift */; }; @@ -36,7 +38,7 @@ 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */; }; 07D5DA3E28D075AB00752FD9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */; }; 07D5DA4128D075AB00752FD9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 07D5DA3F28D075AB00752FD9 /* LaunchScreen.storyboard */; }; - 955D45D9B3C2A224A5869AD6 /* Pods_App_NewEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9840FE925678B7723ACA7167 /* Pods_App_NewEdX.framework */; }; + 95C140F3BDF778364986E83B /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F138C15C3A2515F8F94DAA8B /* Pods_App_OpenEdX.framework */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -66,14 +68,16 @@ 02512FF1299534300024D438 /* CoreDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHandler.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 /* NewEdX.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NewEdX.entitlements; sourceTree = ""; }; + 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; }; + 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 = ""; }; 02ED50D529A6554E008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = uk; path = "uk.lproj/сountries.json"; sourceTree = ""; }; 02ED50D729A65554008341CD /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = Base; path = "Base.lproj/сountries.json"; sourceTree = ""; }; 02ED50D929A66007008341CD /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = Base; path = Base.lproj/languages.json; sourceTree = ""; }; 02ED50DB29A6600B008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = uk; path = uk.lproj/languages.json; sourceTree = ""; }; + 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreenAnalytics.swift; sourceTree = ""; }; 071009C828D1DB3F00344290 /* ScreenAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenAssembly.swift; sourceTree = ""; }; 0727876C28D23312002E9142 /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; 0727878D28D347C7002E9142 /* MainScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreenView.swift; sourceTree = ""; }; @@ -87,17 +91,17 @@ 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 = ""; }; 07A7D78E28F5C9060000BE81 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 07D5DA3128D075AA00752FD9 /* NewEdX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NewEdX.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 07D5DA3128D075AA00752FD9 /* OpenEdX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenEdX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 07D5DA4028D075AB00752FD9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 40A7E74C7E8BA16CF1C37A27 /* Pods-App-NewEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-NewEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-NewEdX/Pods-App-NewEdX.releasestage.xcconfig"; sourceTree = ""; }; - 629FEC6E87F0A0CE4EF3BFE3 /* Pods-App-NewEdX.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-NewEdX.debugprod.xcconfig"; path = "Target Support Files/Pods-App-NewEdX/Pods-App-NewEdX.debugprod.xcconfig"; sourceTree = ""; }; - 6C40659E4A0D422E211C112C /* Pods-App-NewEdX.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-NewEdX.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-NewEdX/Pods-App-NewEdX.releaseprod.xcconfig"; sourceTree = ""; }; - 7C063BCA6EAA90159BF3AEE0 /* Pods-App-NewEdX.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-NewEdX.debugdev.xcconfig"; path = "Target Support Files/Pods-App-NewEdX/Pods-App-NewEdX.debugdev.xcconfig"; sourceTree = ""; }; - 850717CD110D52547BED165B /* Pods-App-NewEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-NewEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-NewEdX/Pods-App-NewEdX.releasedev.xcconfig"; sourceTree = ""; }; - 9840FE925678B7723ACA7167 /* Pods_App_NewEdX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_NewEdX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - E7DE8FAB4E16DE50EDE7A5BF /* Pods-App-NewEdX.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-NewEdX.debugstage.xcconfig"; path = "Target Support Files/Pods-App-NewEdX/Pods-App-NewEdX.debugstage.xcconfig"; sourceTree = ""; }; + 1499CCAD7A0D8A3E6AF39794 /* Pods-App-OpenEdX.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugprod.xcconfig"; sourceTree = ""; }; + 6F54C19C823A769E18923FA8 /* Pods-App-OpenEdX.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugstage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugstage.xcconfig"; sourceTree = ""; }; + 8284179FC05AEE2591573E20 /* Pods-App-OpenEdX.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugdev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugdev.xcconfig"; sourceTree = ""; }; + A24D6A8E1BC4DF46AD68904C /* Pods-App-OpenEdX.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releaseprod.xcconfig"; sourceTree = ""; }; + A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; + BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; + F138C15C3A2515F8F94DAA8B /* Pods_App_OpenEdX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_OpenEdX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -112,7 +116,7 @@ 0218196428F734FA00202564 /* Discussion.framework in Frameworks */, 0219C67728F4347600D64452 /* Course.framework in Frameworks */, 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */, - 955D45D9B3C2A224A5869AD6 /* Pods_App_NewEdX.framework in Frameworks */, + 95C140F3BDF778364986E83B /* Pods_App_OpenEdX.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -140,7 +144,7 @@ 07D5DA2828D075AA00752FD9 = { isa = PBXGroup; children = ( - 07D5DA3328D075AA00752FD9 /* NewEdX */, + 07D5DA3328D075AA00752FD9 /* OpenEdX */, 07D5DA3228D075AA00752FD9 /* Products */, 55A895025FB07897BA68E063 /* Pods */, 4E6FB43543890E90BB88D64D /* Frameworks */, @@ -150,18 +154,20 @@ 07D5DA3228D075AA00752FD9 /* Products */ = { isa = PBXGroup; children = ( - 07D5DA3128D075AA00752FD9 /* NewEdX.app */, + 07D5DA3128D075AA00752FD9 /* OpenEdX.app */, ); name = Products; sourceTree = ""; }; - 07D5DA3328D075AA00752FD9 /* NewEdX */ = { + 07D5DA3328D075AA00752FD9 /* OpenEdX */ = { isa = PBXGroup; children = ( - 025EF2F7297177F300B838AB /* NewEdX.entitlements */, + 025EF2F7297177F300B838AB /* OpenEdX.entitlements */, 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */, 0770DE1628D080A1006D8A5D /* RouteController.swift */, 0770DE1F28D0858A006D8A5D /* Router.swift */, + 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */, + 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */, 02512FF1299534300024D438 /* CoreDataHandler.swift */, 0770DE2628D09209006D8A5D /* SwiftUIHostController.swift */, 0727876C28D23312002E9142 /* Environment.swift */, @@ -174,7 +180,7 @@ 02ED50D629A6554E008341CD /* сountries.json */, 0770DE6628D0BCC7006D8A5D /* Localizable.strings */, ); - path = NewEdX; + path = OpenEdX; sourceTree = ""; }; 4E6FB43543890E90BB88D64D /* Frameworks */ = { @@ -190,7 +196,7 @@ 072787B028D34D83002E9142 /* Discovery.framework */, 0770DE4A28D0A462006D8A5D /* Authorization.framework */, 0770DE1228D07845006D8A5D /* Core.framework */, - 9840FE925678B7723ACA7167 /* Pods_App_NewEdX.framework */, + F138C15C3A2515F8F94DAA8B /* Pods_App_OpenEdX.framework */, ); name = Frameworks; sourceTree = ""; @@ -198,12 +204,12 @@ 55A895025FB07897BA68E063 /* Pods */ = { isa = PBXGroup; children = ( - 629FEC6E87F0A0CE4EF3BFE3 /* Pods-App-NewEdX.debugprod.xcconfig */, - 7C063BCA6EAA90159BF3AEE0 /* Pods-App-NewEdX.debugdev.xcconfig */, - 6C40659E4A0D422E211C112C /* Pods-App-NewEdX.releaseprod.xcconfig */, - 850717CD110D52547BED165B /* Pods-App-NewEdX.releasedev.xcconfig */, - E7DE8FAB4E16DE50EDE7A5BF /* Pods-App-NewEdX.debugstage.xcconfig */, - 40A7E74C7E8BA16CF1C37A27 /* Pods-App-NewEdX.releasestage.xcconfig */, + 1499CCAD7A0D8A3E6AF39794 /* Pods-App-OpenEdX.debugprod.xcconfig */, + 6F54C19C823A769E18923FA8 /* Pods-App-OpenEdX.debugstage.xcconfig */, + 8284179FC05AEE2591573E20 /* Pods-App-OpenEdX.debugdev.xcconfig */, + A24D6A8E1BC4DF46AD68904C /* Pods-App-OpenEdX.releaseprod.xcconfig */, + A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */, + BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */, ); path = Pods; sourceTree = ""; @@ -211,24 +217,25 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 07D5DA3028D075AA00752FD9 /* NewEdX */ = { + 07D5DA3028D075AA00752FD9 /* OpenEdX */ = { isa = PBXNativeTarget; - buildConfigurationList = 07D5DA4528D075AB00752FD9 /* Build configuration list for PBXNativeTarget "NewEdX" */; + buildConfigurationList = 07D5DA4528D075AB00752FD9 /* Build configuration list for PBXNativeTarget "OpenEdX" */; buildPhases = ( - 8739D71CE4167C18E475C4E7 /* [CP] Check Pods Manifest.lock */, + 3165870BC90D2FA438CFF0A9 /* [CP] Check Pods Manifest.lock */, 0770DE2328D08647006D8A5D /* SwiftLint */, 07D5DA2D28D075AA00752FD9 /* Sources */, 07D5DA2E28D075AA00752FD9 /* Frameworks */, 07D5DA2F28D075AA00752FD9 /* Resources */, 0770DE1528D07845006D8A5D /* Embed Frameworks */, + 02F175442A4E3B320019CD70 /* ShellScript */, ); buildRules = ( ); dependencies = ( ); - name = NewEdX; - productName = NewEdX; - productReference = 07D5DA3128D075AA00752FD9 /* NewEdX.app */; + name = OpenEdX; + productName = OpenEdX; + productReference = 07D5DA3128D075AA00752FD9 /* OpenEdX.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -246,7 +253,7 @@ }; }; }; - buildConfigurationList = 07D5DA2C28D075AA00752FD9 /* Build configuration list for PBXProject "NewEdX" */; + buildConfigurationList = 07D5DA2C28D075AA00752FD9 /* Build configuration list for PBXProject "OpenEdX" */; compatibilityVersion = "Xcode 13.0"; developmentRegion = en; hasScannedForEncodings = 0; @@ -260,7 +267,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( - 07D5DA3028D075AA00752FD9 /* NewEdX */, + 07D5DA3028D075AA00752FD9 /* OpenEdX */, ); }; /* End PBXProject section */ @@ -281,6 +288,25 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 02F175442A4E3B320019CD70 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 8; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}", + "$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 1; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n"; + }; 0770DE2328D08647006D8A5D /* SwiftLint */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -299,7 +325,7 @@ shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n"; }; - 8739D71CE4167C18E475C4E7 /* [CP] Check Pods Manifest.lock */ = { + 3165870BC90D2FA438CFF0A9 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -314,7 +340,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-App-NewEdX-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-App-OpenEdX-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -328,7 +354,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0298DF302A4EF7230023A257 /* AnalyticsManager.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 */, @@ -415,7 +443,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -444,16 +472,16 @@ }; 02DD1C9629E80CC200F35DCE /* DebugStage */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E7DE8FAB4E16DE50EDE7A5BF /* Pods-App-NewEdX.debugstage.xcconfig */; + baseConfigurationReference = 6F54C19C823A769E18923FA8 /* Pods-App-OpenEdX.debugstage.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = NewEdX/NewEdX.entitlements; + CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = NewEdX/Info.plist; + INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -466,7 +494,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 0.1; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.NewEdX.stage; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.stage; PRODUCT_NAME = "$(TARGET_NAME) Stage"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -533,16 +561,16 @@ }; 02DD1C9829E80CCB00F35DCE /* ReleaseStage */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 40A7E74C7E8BA16CF1C37A27 /* Pods-App-NewEdX.releasestage.xcconfig */; + baseConfigurationReference = A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = NewEdX/NewEdX.entitlements; + CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = NewEdX/Info.plist; + INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -555,7 +583,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 0.1; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.NewEdX.stage; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.stage; PRODUCT_NAME = "$(TARGET_NAME) Stage"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -599,7 +627,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -628,16 +656,16 @@ }; 0727875928D231FD002E9142 /* DebugDev */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7C063BCA6EAA90159BF3AEE0 /* Pods-App-NewEdX.debugdev.xcconfig */; + baseConfigurationReference = 8284179FC05AEE2591573E20 /* Pods-App-OpenEdX.debugdev.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = NewEdX/NewEdX.entitlements; + CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = NewEdX/Info.plist; + INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -650,7 +678,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 0.1; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.NewEdX.dev; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.dev; PRODUCT_NAME = "$(TARGET_NAME) Dev"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -717,16 +745,16 @@ }; 0727875B28D23204002E9142 /* ReleaseDev */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 850717CD110D52547BED165B /* Pods-App-NewEdX.releasedev.xcconfig */; + baseConfigurationReference = BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = NewEdX/NewEdX.entitlements; + CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = NewEdX/Info.plist; + INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -739,7 +767,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 0.1; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.NewEdX.dev; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.dev; PRODUCT_NAME = "$(TARGET_NAME) Dev"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -783,7 +811,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -866,16 +894,16 @@ }; 07D5DA4628D075AB00752FD9 /* DebugProd */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 629FEC6E87F0A0CE4EF3BFE3 /* Pods-App-NewEdX.debugprod.xcconfig */; + baseConfigurationReference = 1499CCAD7A0D8A3E6AF39794 /* Pods-App-OpenEdX.debugprod.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = NewEdX/NewEdX.entitlements; + CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = NewEdX/Info.plist; + INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -888,7 +916,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 0.1; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.NewEdX; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -901,16 +929,16 @@ }; 07D5DA4728D075AB00752FD9 /* ReleaseProd */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 6C40659E4A0D422E211C112C /* Pods-App-NewEdX.releaseprod.xcconfig */; + baseConfigurationReference = A24D6A8E1BC4DF46AD68904C /* Pods-App-OpenEdX.releaseprod.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = NewEdX/NewEdX.entitlements; + CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = NewEdX/Info.plist; + INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -923,7 +951,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 0.1; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.NewEdX; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -937,7 +965,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 07D5DA2C28D075AA00752FD9 /* Build configuration list for PBXProject "NewEdX" */ = { + 07D5DA2C28D075AA00752FD9 /* Build configuration list for PBXProject "OpenEdX" */ = { isa = XCConfigurationList; buildConfigurations = ( 07D5DA4328D075AB00752FD9 /* DebugProd */, @@ -950,7 +978,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = ReleaseProd; }; - 07D5DA4528D075AB00752FD9 /* Build configuration list for PBXNativeTarget "NewEdX" */ = { + 07D5DA4528D075AB00752FD9 /* Build configuration list for PBXNativeTarget "OpenEdX" */ = { isa = XCConfigurationList; buildConfigurations = ( 07D5DA4628D075AB00752FD9 /* DebugProd */, diff --git a/NewEdX.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/OpenEdX.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from NewEdX.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to OpenEdX.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/NewEdX.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/OpenEdX.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from NewEdX.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to OpenEdX.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/NewEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate b/OpenEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate similarity index 100% rename from NewEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate rename to OpenEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate diff --git a/NewEdX.xcodeproj/xcshareddata/xcschemes/NewEdXDev.xcscheme b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme similarity index 92% rename from NewEdX.xcodeproj/xcshareddata/xcschemes/NewEdXDev.xcscheme rename to OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme index 598dba3ba..3a38de2f5 100644 --- a/NewEdX.xcodeproj/xcshareddata/xcschemes/NewEdXDev.xcscheme +++ b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme @@ -15,9 +15,9 @@ + BuildableName = "OpenEdX.app" + BlueprintName = "OpenEdX" + ReferencedContainer = "container:OpenEdX.xcodeproj"> @@ -114,9 +114,9 @@ + BuildableName = "OpenEdX.app" + BlueprintName = "OpenEdX" + ReferencedContainer = "container:OpenEdX.xcodeproj"> + BuildableName = "OpenEdX.app" + BlueprintName = "OpenEdX" + ReferencedContainer = "container:OpenEdX.xcodeproj"> diff --git a/NewEdX.xcodeproj/xcshareddata/xcschemes/NewEdXProd.xcscheme b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXProd.xcscheme similarity index 94% rename from NewEdX.xcodeproj/xcshareddata/xcschemes/NewEdXProd.xcscheme rename to OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXProd.xcscheme index ec6caa524..7e7b99171 100644 --- a/NewEdX.xcodeproj/xcshareddata/xcschemes/NewEdXProd.xcscheme +++ b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXProd.xcscheme @@ -15,9 +15,9 @@ + BuildableName = "OpenEdX.app" + BlueprintName = "OpenEdX" + ReferencedContainer = "container:OpenEdX.xcodeproj"> @@ -150,9 +150,9 @@ + BuildableName = "OpenEdX.app" + BlueprintName = "OpenEdX" + ReferencedContainer = "container:OpenEdX.xcodeproj"> + BuildableName = "OpenEdX.app" + BlueprintName = "OpenEdX" + ReferencedContainer = "container:OpenEdX.xcodeproj"> diff --git a/NewEdX.xcodeproj/xcshareddata/xcschemes/NewEdXStage.xcscheme b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXStage.xcscheme similarity index 92% rename from NewEdX.xcodeproj/xcshareddata/xcschemes/NewEdXStage.xcscheme rename to OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXStage.xcscheme index 9d9ab0527..8e378a196 100644 --- a/NewEdX.xcodeproj/xcshareddata/xcschemes/NewEdXStage.xcscheme +++ b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXStage.xcscheme @@ -15,9 +15,9 @@ + BuildableName = "OpenEdX.app" + BlueprintName = "OpenEdX" + ReferencedContainer = "container:OpenEdX.xcodeproj"> @@ -114,9 +114,9 @@ + BuildableName = "OpenEdX.app" + BlueprintName = "OpenEdX" + ReferencedContainer = "container:OpenEdX.xcodeproj"> + BuildableName = "OpenEdX.app" + BlueprintName = "OpenEdX" + ReferencedContainer = "container:OpenEdX.xcodeproj"> diff --git a/NewEdX.xcworkspace/contents.xcworkspacedata b/OpenEdX.xcworkspace/contents.xcworkspacedata similarity index 94% rename from NewEdX.xcworkspace/contents.xcworkspacedata rename to OpenEdX.xcworkspace/contents.xcworkspacedata index c93c80108..6afcf4628 100644 --- a/NewEdX.xcworkspace/contents.xcworkspacedata +++ b/OpenEdX.xcworkspace/contents.xcworkspacedata @@ -2,7 +2,7 @@ + location = "group:OpenEdX.xcodeproj"> diff --git a/NewEdX.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/OpenEdX.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from NewEdX.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to OpenEdX.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/OpenEdX/AnalyticsManager.swift b/OpenEdX/AnalyticsManager.swift new file mode 100644 index 000000000..04a11b640 --- /dev/null +++ b/OpenEdX/AnalyticsManager.swift @@ -0,0 +1,369 @@ +// +// AnalyticsManager.swift +// OpenEdX +// +// Created by  Stepanok Ivan on 27.06.2023. +// + +import Foundation +import Core +import FirebaseAnalytics +import Authorization +import Discovery +import Dashboard +import Profile +import Course +import Discussion + +class AnalyticsManager: AuthorizationAnalytics, + MainScreenAnalytics, + DiscoveryAnalytics, + DashboardAnalytics, + ProfileAnalytics, + CourseAnalytics, + DiscussionAnalytics { + + public func setUserID(_ id: String) { + Analytics.setUserID(id) + } + + public func userLogin(method: LoginMethod) { + logEvent(.userLogin, parameters: [Key.method: method.rawValue]) + } + + public func signUpClicked() { + logEvent(.signUpClicked) + } + + public func createAccountClicked() { + logEvent(.createAccountClicked) + } + + public func registrationSuccess() { + logEvent(.registrationSuccess) + } + + public func forgotPasswordClicked() { + logEvent(.forgotPasswordClicked) + } + + public func resetPasswordClicked(success: Bool) { + logEvent(.resetPasswordClicked, parameters: [Key.success: success]) + } + + // MARK: MainScreenAnalytics + + public func mainDiscoveryTabClicked() { + logEvent(.mainDiscoveryTabClicked) + } + + public func mainDashboardTabClicked() { + logEvent(.mainDashboardTabClicked) + } + + public func mainProgramsTabClicked() { + logEvent(.mainProgramsTabClicked) + } + + public func mainProfileTabClicked() { + logEvent(.mainProfileTabClicked) + } + + // MARK: Discovery + + public func discoverySearchBarClicked() { + logEvent(.discoverySearchBarClicked) + } + + public func discoveryCoursesSearch(label: String, coursesCount: Int) { + logEvent(.discoveryCoursesSearch, + parameters: [Key.label: label, + Key.coursesCount: coursesCount]) + } + + public func discoveryCourseClicked(courseID: String, courseName: String) { + let parameters = [ + Key.courseID: courseID, + Key.courseName: courseName + ] + logEvent(.discoveryCourseClicked, parameters: parameters) + } + + // MARK: Dashboard + + public func dashboardCourseClicked(courseID: String, courseName: String) { + let parameters = [ + Key.courseID: courseID, + Key.courseName: courseName + ] + logEvent(.dashboardCourseClicked, parameters: parameters) + } + + // MARK: Profile + + public func profileEditClicked() { + logEvent(.profileEditClicked) + } + + public func profileEditDoneClicked() { + logEvent(.profileEditDoneClicked) + } + + public func profileDeleteAccountClicked() { + logEvent(.profileDeleteAccountClicked) + } + + public func profileVideoSettingsClicked() { + logEvent(.profileVideoSettingsClicked) + } + + public func privacyPolicyClicked() { + logEvent(.privacyPolicyClicked) + } + + public func cookiePolicyClicked() { + logEvent(.cookiePolicyClicked) + } + + public func emailSupportClicked() { + logEvent(.emailSupportClicked) + } + + public func userLogout(force: Bool) { + logEvent(.userLogout, parameters: [Key.force: force]) + } + + // MARK: Course + + public func courseEnrollClicked(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.courseEnrollClicked, parameters: parameters) + } + + public func courseEnrollSuccess(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.courseEnrollSuccess, parameters: parameters) + } + + public func viewCourseClicked(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.viewCourseClicked, parameters: parameters) + } + + public func resumeCourseTapped(courseId: String, courseName: String, blockId: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName, + Key.blockID: blockId + ] + logEvent(.resumeCourseTapped, parameters: parameters) + } + + public func sequentialClicked(courseId: String, courseName: String, blockId: String, blockName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName, + Key.blockID: blockId, + Key.blockName: blockName + ] + logEvent(.sequentialClicked, parameters: parameters) + } + + public func verticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName, + Key.blockID: blockId, + Key.blockName: blockName + ] + logEvent(.verticalClicked, parameters: parameters) + } + + public func nextBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName, + Key.blockID: blockId, + Key.blockName: blockName + ] + logEvent(.nextBlockClicked, parameters: parameters) + } + + public func prevBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName, + Key.blockID: blockId, + Key.blockName: blockName + ] + logEvent(.prevBlockClicked, parameters: parameters) + } + + public func finishVerticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName, + Key.blockID: blockId, + Key.blockName: blockName + ] + logEvent(.finishVerticalClicked, parameters: parameters) + } + + public func finishVerticalNextSectionClicked( + courseId: String, + courseName: String, + blockId: String, + blockName: String + ) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName, + Key.blockID: blockId, + Key.blockName: blockName + ] + logEvent(.finishVerticalNextSectionClicked, parameters: parameters) + } + + public func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.finishVerticalBackToOutlineClicked, parameters: parameters) + } + + public func courseOutlineCourseTabClicked(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.courseOutlineCourseTabClicked, parameters: parameters) + } + + public func courseOutlineVideosTabClicked(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.courseOutlineVideosTabClicked, parameters: parameters) + } + + public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.courseOutlineDiscussionTabClicked, parameters: parameters) + } + + public func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.courseOutlineHandoutsTabClicked, parameters: parameters) + } + + // MARK: Discussion + public func discussionAllPostsClicked(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.discussionAllPostsClicked, parameters: parameters) + } + + public func discussionFollowingClicked(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.discussionFollowingClicked, parameters: parameters) + } + + public func discussionTopicClicked(courseId: String, courseName: String, topicId: String, topicName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName, + Key.topicID: topicId, + Key.topicName: topicName + ] + logEvent(.discussionTopicClicked, parameters: parameters) + } + + private func logEvent(_ event: Event, parameters: [String: Any]? = nil) { + Analytics.logEvent(event.rawValue, parameters: parameters) + } +} + +struct Key { + static let courseID = "course_id" + static let courseName = "course_name" + static let topicID = "topic_id" + static let topicName = "topic_name" + static let blockID = "block_id" + static let blockName = "block_name" + static let method = "method" + static let label = "label" + static let coursesCount = "courses_count" + static let force = "force" + static let success = "success" +} + +enum Event: String { + case userLogin = "User_Login" + case signUpClicked = "Sign_up_Clicked" + case createAccountClicked = "Create_Account_Clicked" + case registrationSuccess = "Registration_Success" + case userLogout = "User_Logout" + case forgotPasswordClicked = "Forgot_password_Clicked" + case resetPasswordClicked = "Reset_password_Clicked" + + case mainDiscoveryTabClicked = "Main_Discovery_tab_Clicked" + case mainDashboardTabClicked = "Main_Dashboard_tab_Clicked" + case mainProgramsTabClicked = "Main_Programs_tab_Clicked" + case mainProfileTabClicked = "Main_Profile_tab_Clicked" + + case discoverySearchBarClicked = "Discovery_Search_Bar_Clicked" + case discoveryCoursesSearch = "Discovery_Courses_Search" + case discoveryCourseClicked = "Discovery_Course_Clicked" + + case dashboardCourseClicked = "Dashboard_Course_Clicked" + + case profileEditClicked = "Profile_Edit_Clicked" + case profileEditDoneClicked = "Profile_Edit_Done_Clicked" + case profileDeleteAccountClicked = "Profile_Delete_Account_Clicked" + case profileVideoSettingsClicked = "Profile_Video_settings_Clicked" + case privacyPolicyClicked = "Privacy_Policy_Clicked" + case cookiePolicyClicked = "Cookie_Policy_Clicked" + case emailSupportClicked = "Email_Support_Clicked" + + case courseEnrollClicked = "Course_Enroll_Clicked" + case courseEnrollSuccess = "Course_Enroll_Success" + case viewCourseClicked = "View_Course_Clicked" + case resumeCourseTapped = "Resume_Course_Tapped" + case sequentialClicked = "Sequential_Clicked" + case verticalClicked = "Vertical_Clicked" + case nextBlockClicked = "Next_Block_Clicked" + case prevBlockClicked = "Prev_Block_Clicked" + case finishVerticalClicked = "Finish_Vertical_Clicked" + case finishVerticalNextSectionClicked = "Finish_Vertical_Next_section_Clicked" + case finishVerticalBackToOutlineClicked = "Finish_Vertical_Back_to_outline_Clicked" + case courseOutlineCourseTabClicked = "Course_Outline_Course_tab_Clicked" + case courseOutlineVideosTabClicked = "Course_Outline_Videos_tab_Clicked" + case courseOutlineDiscussionTabClicked = "Course_Outline_Discussion_tab_Clicked" + case courseOutlineHandoutsTabClicked = "Course_Outline_Handouts_tab_Clicked" + + case discussionAllPostsClicked = "Discussion_All_Posts_Clicked" + case discussionFollowingClicked = "Discussion_Following_Clicked" + case discussionTopicClicked = "Discussion_Topic_Clicked" +} diff --git a/NewEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift similarity index 63% rename from NewEdX/AppDelegate.swift rename to OpenEdX/AppDelegate.swift index 25fa5976b..931f65420 100644 --- a/NewEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -1,6 +1,6 @@ // // AppDelegate.swift -// NewEdX +// OpenEdX // // Created by Vladimir Chekyrta on 13.09.2022. // @@ -8,6 +8,10 @@ import UIKit import Core import Swinject +import FirebaseCore +import FirebaseAnalytics +import FirebaseCrashlytics +import Profile @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -18,6 +22,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? + private var orientationLock: UIInterfaceOrientationMask = .portrait + private var assembler: Assembler? private var lastForceLogoutTime: TimeInterval = 0 @@ -27,6 +33,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + if BuildConfiguration.shared.firebaseOptions.apiKey != "" { + FirebaseApp.configure(options: BuildConfiguration.shared.firebaseOptions) + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) + } + initDI() Theme.Fonts.registerFonts() @@ -44,6 +55,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + func application( + _ application: UIApplication, + supportedInterfaceOrientationsFor window: UIWindow? + ) -> UIInterfaceOrientationMask { + //Allows external windows, such as WebView Player, to work in any orientation + if window == self.window { + return UIDevice.current.userInterfaceIdiom == .phone ? orientationLock : .all + } else { + return UIDevice.current.userInterfaceIdiom == .phone ? .allButUpsideDown : .all + } + } + private func initDI() { let navigation = UINavigationController() navigation.modalPresentationStyle = .fullScreen @@ -62,6 +85,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { guard Date().timeIntervalSince1970 - lastForceLogoutTime > 5 else { return } + let analytics = Container.shared.resolve(AnalyticsManager.self) + analytics?.userLogout(force: true) + lastForceLogoutTime = Date().timeIntervalSince1970 Container.shared.resolve(AppStorage.self)?.clear() diff --git a/NewEdX/Assets.xcassets/AccentColor.colorset/Contents.json b/OpenEdX/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from NewEdX/Assets.xcassets/AccentColor.colorset/Contents.json rename to OpenEdX/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/NewEdX/Assets.xcassets/AppIcon.appiconset/Contents.json b/OpenEdX/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from NewEdX/Assets.xcassets/AppIcon.appiconset/Contents.json rename to OpenEdX/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/NewEdX/Assets.xcassets/AppIcon.appiconset/app icon.jpg b/OpenEdX/Assets.xcassets/AppIcon.appiconset/app icon.jpg similarity index 100% rename from NewEdX/Assets.xcassets/AppIcon.appiconset/app icon.jpg rename to OpenEdX/Assets.xcassets/AppIcon.appiconset/app icon.jpg diff --git a/NewEdX/Assets.xcassets/Contents.json b/OpenEdX/Assets.xcassets/Contents.json similarity index 100% rename from NewEdX/Assets.xcassets/Contents.json rename to OpenEdX/Assets.xcassets/Contents.json diff --git a/NewEdX/Assets.xcassets/SplachBackground.colorset/Contents.json b/OpenEdX/Assets.xcassets/SplachBackground.colorset/Contents.json similarity index 100% rename from NewEdX/Assets.xcassets/SplachBackground.colorset/Contents.json rename to OpenEdX/Assets.xcassets/SplachBackground.colorset/Contents.json diff --git a/NewEdX/Assets.xcassets/appLogo.imageset/Contents.json b/OpenEdX/Assets.xcassets/appLogo.imageset/Contents.json similarity index 100% rename from NewEdX/Assets.xcassets/appLogo.imageset/Contents.json rename to OpenEdX/Assets.xcassets/appLogo.imageset/Contents.json diff --git a/NewEdX/Assets.xcassets/appLogo.imageset/Group 21.svg b/OpenEdX/Assets.xcassets/appLogo.imageset/Group 21.svg similarity index 100% rename from NewEdX/Assets.xcassets/appLogo.imageset/Group 21.svg rename to OpenEdX/Assets.xcassets/appLogo.imageset/Group 21.svg diff --git a/NewEdX/Base.lproj/LaunchScreen.storyboard b/OpenEdX/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from NewEdX/Base.lproj/LaunchScreen.storyboard rename to OpenEdX/Base.lproj/LaunchScreen.storyboard diff --git a/NewEdX/Base.lproj/languages.json b/OpenEdX/Base.lproj/languages.json similarity index 100% rename from NewEdX/Base.lproj/languages.json rename to OpenEdX/Base.lproj/languages.json diff --git "a/NewEdX/Base.lproj/\321\201ountries.json" "b/OpenEdX/Base.lproj/\321\201ountries.json" similarity index 100% rename from "NewEdX/Base.lproj/\321\201ountries.json" rename to "OpenEdX/Base.lproj/\321\201ountries.json" diff --git a/OpenEdX/Configuration.json b/OpenEdX/Configuration.json new file mode 100644 index 000000000..68f6b7a89 --- /dev/null +++ b/OpenEdX/Configuration.json @@ -0,0 +1,26 @@ +{ + "DebugDev": { + "baseURL": "https://lms-rg-app-ios-dev.raccoongang.com", + "clientId": "T7od4OFlYni7hTMnepfQuF1XUoqsESjEClltL40T" + }, + "ReleaseDev": { + "baseURL": "https://lms-rg-app-ios-dev.raccoongang.com", + "clientId": "T7od4OFlYni7hTMnepfQuF1XUoqsESjEClltL40T" + }, + "DebugStage": { + "baseURL": "https://lms-rg-app-ios-stage.raccoongang.com", + "clientId": "kHDbLaYlc1lpY1obmyAAEp9dX9qPqeDrBiVGQFIy" + }, + "ReleaseStage": { + "baseURL": "https://lms-rg-app-ios-stage.raccoongang.com", + "clientId": "kHDbLaYlc1lpY1obmyAAEp9dX9qPqeDrBiVGQFIy" + }, + "DebugProd": { + "baseURL": "https://example.com", + "clientId": "PROD_CLIENT_ID" + }, + "ReleaseProd": { + "baseURL": "https://example.com", + "clientId": "PROD_CLIENT_ID" + } +} diff --git a/NewEdX/CoreDataHandler.swift b/OpenEdX/CoreDataHandler.swift similarity index 78% rename from NewEdX/CoreDataHandler.swift rename to OpenEdX/CoreDataHandler.swift index dd9480756..9746c78fd 100644 --- a/NewEdX/CoreDataHandler.swift +++ b/OpenEdX/CoreDataHandler.swift @@ -1,6 +1,6 @@ // // CoreDataHandler.swift -// NewEdX +// OpenEdX // // Created by  Stepanok Ivan on 09.02.2023. // @@ -17,9 +17,11 @@ class CoreDataHandler: CoreDataHandlerProtocol { private let discoveryPersistence: DiscoveryPersistenceProtocol private let coursePersistence: CoursePersistenceProtocol - init(dashboardPersistence: DashboardPersistenceProtocol, - discoveryPersistence: DiscoveryPersistenceProtocol, - coursePersistence: CoursePersistenceProtocol) { + init( + dashboardPersistence: DashboardPersistenceProtocol, + discoveryPersistence: DiscoveryPersistenceProtocol, + coursePersistence: CoursePersistenceProtocol + ) { self.dashboardPersistence = dashboardPersistence self.discoveryPersistence = discoveryPersistence self.coursePersistence = coursePersistence diff --git a/NewEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift similarity index 70% rename from NewEdX/DI/AppAssembly.swift rename to OpenEdX/DI/AppAssembly.swift index 255da5901..2e1dabda0 100644 --- a/NewEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -1,6 +1,6 @@ // // AppAssembly.swift -// NewEdX +// OpenEdX // // Created by Vladimir Chekyrta on 13.09.2022. // @@ -16,6 +16,7 @@ import Discussion import Authorization import Profile +// swiftlint:disable function_body_length class AppAssembly: Assembly { private let navigation: UINavigationController @@ -33,6 +34,38 @@ class AppAssembly: Assembly { Router(navigationController: r.resolve(UINavigationController.self)!, container: container) } + container.register(AnalyticsManager.self) { _ in + AnalyticsManager() + } + + container.register(AuthorizationAnalytics.self) { r in + r.resolve(AnalyticsManager.self)! + }.inObjectScope(.container) + + container.register(MainScreenAnalytics.self) { r in + r.resolve(AnalyticsManager.self)! + }.inObjectScope(.container) + + container.register(DiscoveryAnalytics.self) { r in + r.resolve(AnalyticsManager.self)! + }.inObjectScope(.container) + + container.register(DashboardAnalytics.self) { r in + r.resolve(AnalyticsManager.self)! + }.inObjectScope(.container) + + container.register(ProfileAnalytics.self) { r in + r.resolve(AnalyticsManager.self)! + }.inObjectScope(.container) + + container.register(CourseAnalytics.self) { r in + r.resolve(AnalyticsManager.self)! + }.inObjectScope(.container) + + container.register(DiscussionAnalytics.self) { r in + r.resolve(AnalyticsManager.self)! + }.inObjectScope(.container) + container.register(ConnectivityProtocol.self) { _ in Connectivity() } @@ -90,7 +123,8 @@ class AppAssembly: Assembly { container.register(AppStorage.self) { r in AppStorage( keychain: r.resolve(KeychainSwift.self)!, - userDefaults: r.resolve(UserDefaults.self)!) + userDefaults: r.resolve(UserDefaults.self)! + ) }.inObjectScope(.container) container.register(Validator.self) { _ in @@ -98,3 +132,4 @@ class AppAssembly: Assembly { }.inObjectScope(.container) } } +// swiftlint:enable function_body_length diff --git a/NewEdX/DI/NetworkAssembly.swift b/OpenEdX/DI/NetworkAssembly.swift similarity index 99% rename from NewEdX/DI/NetworkAssembly.swift rename to OpenEdX/DI/NetworkAssembly.swift index cf92bed98..f609b5bc5 100644 --- a/NewEdX/DI/NetworkAssembly.swift +++ b/OpenEdX/DI/NetworkAssembly.swift @@ -1,6 +1,6 @@ // // NetworkAssembly.swift -// NewEdX +// OpenEdX // // Created by Vladimir Chekyrta on 13.09.2022. // diff --git a/NewEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift similarity index 74% rename from NewEdX/DI/ScreenAssembly.swift rename to OpenEdX/DI/ScreenAssembly.swift index f0636f596..a4b8fa1b9 100644 --- a/NewEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -1,6 +1,6 @@ // // ScreenAssembly.swift -// NewEdX +// OpenEdX // // Created by Vladimir Chekyrta on 14.09.2022. // @@ -15,7 +15,7 @@ import Profile import Course import Discussion -// swiftlint:disable function_body_length +// swiftlint:disable function_body_length type_body_length class ScreenAssembly: Assembly { func assemble(container: Container) { @@ -47,6 +47,7 @@ class ScreenAssembly: Assembly { SignInViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(AuthorizationRouter.self)!, + analytics: r.resolve(AuthorizationAnalytics.self)!, validator: r.resolve(Validator.self)! ) } @@ -54,6 +55,7 @@ class ScreenAssembly: Assembly { SignUpViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(AuthorizationRouter.self)!, + analytics: r.resolve(AuthorizationAnalytics.self)!, config: r.resolve(Config.self)!, cssInjector: r.resolve(CSSInjector.self)!, validator: r.resolve(Validator.self)! @@ -63,6 +65,7 @@ class ScreenAssembly: Assembly { ResetPasswordViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(AuthorizationRouter.self)!, + analytics: r.resolve(AuthorizationAnalytics.self)!, validator: r.resolve(Validator.self)! ) } @@ -88,7 +91,8 @@ class ScreenAssembly: Assembly { container.register(DiscoveryViewModel.self) { r in DiscoveryViewModel( interactor: r.resolve(DiscoveryInteractorProtocol.self)!, - connectivity: r.resolve(ConnectivityProtocol.self)! + connectivity: r.resolve(ConnectivityProtocol.self)!, + analytics: r.resolve(DiscoveryAnalytics.self)! ) } @@ -97,6 +101,7 @@ class ScreenAssembly: Assembly { interactor: r.resolve(DiscoveryInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, router: r.resolve(DiscoveryRouter.self)!, + analytics: r.resolve(DiscoveryAnalytics.self)!, debounce: .searchDebounce ) } @@ -122,7 +127,8 @@ class ScreenAssembly: Assembly { container.register(DashboardViewModel.self) { r in DashboardViewModel( interactor: r.resolve(DashboardInteractorProtocol.self)!, - connectivity: r.resolve(ConnectivityProtocol.self)! + connectivity: r.resolve(ConnectivityProtocol.self)!, + analytics: r.resolve(DashboardAnalytics.self)! ) } @@ -145,6 +151,7 @@ class ScreenAssembly: Assembly { ProfileViewModel( interactor: r.resolve(ProfileInteractor.self)!, router: r.resolve(ProfileRouter.self)!, + analytics: r.resolve(ProfileAnalytics.self)!, config: r.resolve(Config.self)!, connectivity: r.resolve(ConnectivityProtocol.self)! ) @@ -153,7 +160,9 @@ class ScreenAssembly: Assembly { EditProfileViewModel( userModel: userModel, interactor: r.resolve(ProfileInteractor.self)!, - router: r.resolve(ProfileRouter.self)! + router: r.resolve(ProfileRouter.self)!, + analytics: r.resolve(ProfileAnalytics.self)! + ) } @@ -194,6 +203,7 @@ class ScreenAssembly: Assembly { CourseDetailsViewModel( interactor: r.resolve(CourseInteractorProtocol.self)!, router: r.resolve(CourseRouter.self)!, + analytics: r.resolve(CourseAnalytics.self)!, config: r.resolve(Config.self)!, cssInjector: r.resolve(CSSInjector.self)!, connectivity: r.resolve(ConnectivityProtocol.self)! @@ -201,11 +211,14 @@ class ScreenAssembly: Assembly { } // MARK: CourseScreensView - container - .register(CourseContainerViewModel.self) { r, isActive, courseStart, courseEnd, enrollmentStart, enrollmentEnd in + container.register( + CourseContainerViewModel.self + ) { r, isActive, courseStart, courseEnd, enrollmentStart, enrollmentEnd in CourseContainerViewModel( interactor: r.resolve(CourseInteractorProtocol.self)!, + authInteractor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(CourseRouter.self)!, + analytics: r.resolve(CourseAnalytics.self)!, config: r.resolve(Config.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, manager: r.resolve(DownloadManagerProtocol.self)!, @@ -217,48 +230,80 @@ class ScreenAssembly: Assembly { ) } - container.register(CourseBlocksViewModel.self) { r, blocks in - CourseBlocksViewModel(blocks: blocks, - manager: r.resolve(DownloadManagerProtocol.self)!, - router: r.resolve(CourseRouter.self)!, - connectivity: r.resolve(ConnectivityProtocol.self)!) - } - - container.register(CourseVerticalViewModel.self) { r, verticals in - CourseVerticalViewModel(verticals: verticals, - manager: r.resolve(DownloadManagerProtocol.self)!, - router: r.resolve(CourseRouter.self)!, - connectivity: r.resolve(ConnectivityProtocol.self)!) + container.register(CourseVerticalViewModel.self) { r, chapters, chapterIndex, sequentialIndex in + CourseVerticalViewModel( + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex, + manager: r.resolve(DownloadManagerProtocol.self)!, + router: r.resolve(CourseRouter.self)!, + analytics: r.resolve(CourseAnalytics.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)! + ) } - container.register(CourseUnitViewModel.self) { r, blockId, courseId, blocks in + container.register( + CourseUnitViewModel.self + ) { r, blockId, courseId, id, courseName, chapters, chapterIndex, sequentialIndex, verticalIndex in CourseUnitViewModel( lessonID: blockId, courseID: courseId, - blocks: blocks, + id: id, + courseName: courseName, + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex, + verticalIndex: verticalIndex, interactor: r.resolve(CourseInteractorProtocol.self)!, router: r.resolve(CourseRouter.self)!, + analytics: r.resolve(CourseAnalytics.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, manager: r.resolve(DownloadManagerProtocol.self)! ) } container.register(WebUnitViewModel.self) { r in - WebUnitViewModel(authInteractor: r.resolve(AuthInteractorProtocol.self)!) + WebUnitViewModel(authInteractor: r.resolve(AuthInteractorProtocol.self)!, + config: r.resolve(Config.self)!) + } + + container.register( + YouTubeVideoPlayerViewModel.self + ) { r, url, blockID, courseID, languages, playerStateSubject in + YouTubeVideoPlayerViewModel( + url: url, + blockID: blockID, + courseID: courseID, + languages: languages, + playerStateSubject: playerStateSubject, + interactor: r.resolve(CourseInteractorProtocol.self)!, + router: r.resolve(CourseRouter.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)! + ) } - container.register(VideoPlayerViewModel.self) { r in - VideoPlayerViewModel(interactor: r.resolve(CourseInteractorProtocol.self)!, - router: r.resolve(CourseRouter.self)!, - connectivity: r.resolve(ConnectivityProtocol.self)!) + container.register( + EncodedVideoPlayerViewModel.self + ) { r, url, blockID, courseID, languages, playerStateSubject in + EncodedVideoPlayerViewModel( + url: url, + blockID: blockID, + courseID: courseID, + languages: languages, + playerStateSubject: playerStateSubject, + interactor: r.resolve(CourseInteractorProtocol.self)!, + router: r.resolve(CourseRouter.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)! + ) } container.register(HandoutsViewModel.self) { r, courseID in - HandoutsViewModel(interactor: r.resolve(CourseInteractorProtocol.self)!, - router: r.resolve(CourseRouter.self)!, - cssInjector: r.resolve(CSSInjector.self)!, - connectivity: r.resolve(ConnectivityProtocol.self)!, - courseID: courseID + HandoutsViewModel( + interactor: r.resolve(CourseInteractorProtocol.self)!, + router: r.resolve(CourseRouter.self)!, + cssInjector: r.resolve(CSSInjector.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)!, + courseID: courseID ) } @@ -271,18 +316,23 @@ class ScreenAssembly: Assembly { router: r.resolve(DiscussionRouter.self)! ) } + container.register(DiscussionInteractorProtocol.self) { r in DiscussionInteractor( repository: r.resolve(DiscussionRepositoryProtocol.self)! ) } - container.register(DiscussionTopicsViewModel.self) { r in + + container.register(DiscussionTopicsViewModel.self) { r, title in DiscussionTopicsViewModel( + title: title, interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, + analytics: r.resolve(DiscussionAnalytics.self)!, config: r.resolve(Config.self)! ) } + container.register(DiscussionSearchTopicsViewModel.self) { r, courseID in DiscussionSearchTopicsViewModel( courseID: courseID, @@ -291,6 +341,7 @@ class ScreenAssembly: Assembly { debounce: .searchDebounce ) } + container.register(PostsViewModel.self) { r in PostsViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, @@ -298,6 +349,7 @@ class ScreenAssembly: Assembly { config: r.resolve(Config.self)! ) } + container.register(ThreadViewModel.self) { r, subject in ThreadViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, @@ -307,6 +359,7 @@ class ScreenAssembly: Assembly { postStateSubject: subject ) } + container.register(ResponsesViewModel.self) { r, subject in ResponsesViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, @@ -316,6 +369,7 @@ class ScreenAssembly: Assembly { threadStateSubject: subject ) } + container.register(CreateNewThreadViewModel.self) { r in CreateNewThreadViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, @@ -325,3 +379,4 @@ class ScreenAssembly: Assembly { } } } +// swiftlint:enable function_body_length type_body_length diff --git a/OpenEdX/Environment.swift b/OpenEdX/Environment.swift new file mode 100644 index 000000000..e89c0bb88 --- /dev/null +++ b/OpenEdX/Environment.swift @@ -0,0 +1,89 @@ +// +// Environment.swift +// OpenEdX +// +// Created by Vladimir Chekyrta on 14.09.2022. +// + +import Foundation +import Core +import FirebaseCore + +enum `Environment`: String { + case debugDev = "DebugDev" + case releaseDev = "ReleaseDev" + + case debugStage = "DebugStage" + case releaseStage = "ReleaseStage" + + case debugProd = "DebugProd" + case releaseProd = "ReleaseProd" +} + +class BuildConfiguration { + static let shared = BuildConfiguration() + + var environment: Environment + + var baseURL: String { + switch environment { + case .debugDev, .releaseDev: + return "https://example-dev.com" + case .debugStage, .releaseStage: + return "https://example-stage.com" + case .debugProd, .releaseProd: + return "https://example.com" + } + } + + var clientId: String { + switch environment { + case .debugDev, .releaseDev: + return "DEV_CLIENT_ID" + case .debugStage, .releaseStage: + return "STAGE_CLIENT_ID" + case .debugProd, .releaseProd: + return "PROD_CLIENT_ID" + } + } + + var firebaseOptions: FirebaseOptions { + switch environment { + case .debugDev, .releaseDev: + let firebaseOptions = FirebaseOptions(googleAppID: "", + gcmSenderID: "") + firebaseOptions.apiKey = "" + firebaseOptions.projectID = "" + firebaseOptions.bundleID = "" + firebaseOptions.clientID = "" + firebaseOptions.storageBucket = "" + + return firebaseOptions + case .debugStage, .releaseStage: + let firebaseOptions = FirebaseOptions(googleAppID: "", + gcmSenderID: "") + firebaseOptions.apiKey = "" + firebaseOptions.projectID = "" + firebaseOptions.bundleID = "" + firebaseOptions.clientID = "" + firebaseOptions.storageBucket = "" + + return firebaseOptions + case .debugProd, .releaseProd: + let firebaseOptions = FirebaseOptions(googleAppID: "", + gcmSenderID: "") + firebaseOptions.apiKey = "" + firebaseOptions.projectID = "" + firebaseOptions.bundleID = "" + firebaseOptions.clientID = "" + firebaseOptions.storageBucket = "" + + return firebaseOptions + } + } + + init() { + let currentConfiguration = Bundle.main.object(forInfoDictionaryKey: "Configuration") as! String + environment = Environment(rawValue: currentConfiguration)! + } +} diff --git a/NewEdX/Info.plist b/OpenEdX/Info.plist similarity index 77% rename from NewEdX/Info.plist rename to OpenEdX/Info.plist index b66a28b6c..b94522839 100644 --- a/NewEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -4,6 +4,10 @@ Configuration $(CONFIGURATION) + FirebaseAppDelegateProxyEnabled + + FirebaseAutomaticScreenReportingEnabled + ITSAppUsesNonExemptEncryption UIAppFonts diff --git a/OpenEdX/MainScreenAnalytics.swift b/OpenEdX/MainScreenAnalytics.swift new file mode 100644 index 000000000..39dd9e484 --- /dev/null +++ b/OpenEdX/MainScreenAnalytics.swift @@ -0,0 +1,16 @@ +// +// MainScreenAnalytics.swift +// OpenEdX +// +// Created by  Stepanok Ivan on 29.06.2023. +// + +import Foundation + +//sourcery: AutoMockable +public protocol MainScreenAnalytics { + func mainDiscoveryTabClicked() + func mainDashboardTabClicked() + func mainProgramsTabClicked() + func mainProfileTabClicked() +} diff --git a/NewEdX/NewEdX.entitlements b/OpenEdX/OpenEdX.entitlements similarity index 100% rename from NewEdX/NewEdX.entitlements rename to OpenEdX/OpenEdX.entitlements diff --git a/NewEdX/RouteController.swift b/OpenEdX/RouteController.swift similarity index 82% rename from NewEdX/RouteController.swift rename to OpenEdX/RouteController.swift index 87e6c4204..a4264ca0a 100644 --- a/NewEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -1,6 +1,6 @@ // // RouteController.swift -// NewEdX +// OpenEdX // // Created by Vladimir Chekyrta on 13.09.2022. // @@ -19,10 +19,15 @@ class RouteController: UIViewController { diContainer.resolve(AppStorage.self)! }() + private lazy var analytics: AuthorizationAnalytics = { + diContainer.resolve(AuthorizationAnalytics.self)! + }() + override func viewDidLoad() { super.viewDidLoad() - if appStorage.accessToken != nil && appStorage.user != nil { + if let user = appStorage.user, appStorage.accessToken != nil { + analytics.setUserID("\(user.id)") DispatchQueue.main.async { self.showMainScreen() } diff --git a/NewEdX/Router.swift b/OpenEdX/Router.swift similarity index 60% rename from NewEdX/Router.swift rename to OpenEdX/Router.swift index a4c7d0b67..03f48dfee 100644 --- a/NewEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -1,6 +1,6 @@ // // RouterImpl.swift -// NewEdX +// OpenEdX // // Created by Vladimir Chekyrta on 13.09.2022. // @@ -18,7 +18,12 @@ import Dashboard import Profile import Combine -public class Router: AuthorizationRouter, DiscoveryRouter, ProfileRouter, DashboardRouter, CourseRouter, DiscussionRouter { +public class Router: AuthorizationRouter, + DiscoveryRouter, + ProfileRouter, + DashboardRouter, + CourseRouter, + DiscussionRouter { public var container: Container @@ -85,19 +90,23 @@ public class Router: AuthorizationRouter, DiscoveryRouter, ProfileRouter, Dashbo public func presentAlert( alertTitle: String, alertMessage: String, + nextSectionName: String? = nil, action: String, image: Image, onCloseTapped: @escaping () -> Void, - okTapped: @escaping () -> Void + okTapped: @escaping () -> Void, + nextSectionTapped: @escaping () -> Void ) { presentView(transitionStyle: .crossDissolve, content: { AlertView( alertTitle: alertTitle, alertMessage: alertMessage, + nextSectionName: nextSectionName, mainAction: action, image: image, onCloseTapped: onCloseTapped, - okTapped: okTapped + okTapped: okTapped, + nextSectionTapped: { nextSectionTapped() } ) }) } @@ -107,8 +116,8 @@ public class Router: AuthorizationRouter, DiscoveryRouter, ProfileRouter, Dashbo } public func presentView(transitionStyle: UIModalTransitionStyle, content: () -> any View) { - navigationController.present(prepareToPresent(content(), - transitionStyle: transitionStyle), animated: true) + let view = prepareToPresent(content(), transitionStyle: transitionStyle) + navigationController.present(view, animated: true) } public func showRegisterScreen() { @@ -123,10 +132,12 @@ public class Router: AuthorizationRouter, DiscoveryRouter, ProfileRouter, Dashbo navigationController.pushViewController(controller, animated: true) } - public func showCourseDetais(courseID: String, - title: String) { - let view = CourseDetailsView(viewModel: Container.shared.resolve(CourseDetailsViewModel.self)!, - courseID: courseID, title: title) + public func showCourseDetais(courseID: String, title: String) { + let view = CourseDetailsView( + viewModel: Container.shared.resolve(CourseDetailsViewModel.self)!, + courseID: courseID, + title: title + ) let controller = SwiftUIHostController(view: view) navigationController.pushViewController(controller, animated: true) } @@ -147,85 +158,158 @@ public class Router: AuthorizationRouter, DiscoveryRouter, ProfileRouter, Dashbo navigationController.pushFade(viewController: controller) } - public func showCourseVerticalView(title: String, - verticals: [CourseVertical]) { - - let viewModel = Container.shared.resolve(CourseVerticalViewModel.self, argument: verticals)! - - let view = CourseVerticalView(title: title, viewModel: viewModel) - let controller = SwiftUIHostController(view: view) - navigationController.pushViewController(controller, animated: true) - } - - public func showCourseBlocksView(title: String, - blocks: [CourseBlock]) { - let viewModel = Container.shared.resolve(CourseBlocksViewModel.self, argument: blocks)! + public func showCourseVerticalView( + id: String, + courseID: String, + courseName: String, + title: String, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) { + let viewModel = Container.shared.resolve( + CourseVerticalViewModel.self, + arguments: chapters, + chapterIndex, + sequentialIndex + )! - let view = CourseBlocksView(title: title, viewModel: viewModel) + let view = CourseVerticalView(title: title, courseName: courseName, courseID: courseID, id: id, viewModel: viewModel) let controller = SwiftUIHostController(view: view) navigationController.pushViewController(controller, animated: true) } - public func showCourseVerticalAndBlocksView(verticals: (String, [CourseVertical]), - blocks: (String, [CourseBlock])) { - let viewModelVertical = Container.shared.resolve(CourseVerticalViewModel.self, argument: verticals.1)! - let verticalView = CourseVerticalView(title: verticals.0, viewModel: viewModelVertical) - let verticalController = SwiftUIHostController(view: verticalView) - - let viewModelBlocks = Container.shared.resolve(CourseBlocksViewModel.self, argument: blocks.1)! - let blocksView = CourseBlocksView(title: blocks.0, viewModel: viewModelBlocks) - let blocksController = SwiftUIHostController(view: blocksView) - - var currentViews = navigationController.viewControllers - currentViews.append(verticalController) - currentViews.append(blocksController) - - navigationController.setViewControllers(currentViews, animated: true) - } - - public func showCourseScreens(courseID: String, - isActive: Bool?, - courseStart: Date?, - courseEnd: Date?, - enrollmentStart: Date?, - enrollmentEnd: Date?, - title: String) { + public func showCourseScreens( + courseID: String, + isActive: Bool?, + courseStart: Date?, + courseEnd: Date?, + enrollmentStart: Date?, + enrollmentEnd: Date?, + title: String + ) { + let vm = Container.shared.resolve( + CourseContainerViewModel.self, + arguments: isActive, + courseStart, + courseEnd, + enrollmentStart, + enrollmentEnd + )! let screensView = CourseContainerView( - viewModel: Container.shared.resolve(CourseContainerViewModel.self, - arguments: isActive, courseStart, courseEnd, - enrollmentStart, enrollmentEnd)!, + viewModel: vm, courseID: courseID, title: title ) - + let controller = SwiftUIHostController(view: screensView) navigationController.pushViewController(controller, animated: true) } - public func showHandoutsUpdatesView(handouts: String?, - announcements: [CourseUpdate]?, - router: Course.CourseRouter, - cssInjector: CSSInjector) { - let view = HandoutsUpdatesDetailView(handouts: handouts, - announcements: announcements, - router: router, - cssInjector: cssInjector) + public func showHandoutsUpdatesView( + handouts: String?, + announcements: [CourseUpdate]?, + router: Course.CourseRouter, + cssInjector: CSSInjector + ) { + let view = HandoutsUpdatesDetailView( + handouts: handouts, + announcements: announcements, + router: router, + cssInjector: cssInjector + ) let controller = SwiftUIHostController(view: view) navigationController.pushViewController(controller, animated: true) } - public func showCourseUnit(blockId: String, courseID: String, sectionName: String, blocks: [CourseBlock]) { - let viewModel = Container.shared.resolve(CourseUnitViewModel.self, arguments: blockId, courseID, blocks)! + public func showCourseUnit( + courseName: String, + id: String, + blockId: String, + courseID: String, + sectionName: String, + verticalIndex: Int, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) { + let viewModel = Container.shared.resolve( + CourseUnitViewModel.self, + arguments: blockId, + courseID, + id, + courseName, + chapters, + chapterIndex, + sequentialIndex, + verticalIndex + )! let view = CourseUnitView(viewModel: viewModel, sectionName: sectionName) let controller = SwiftUIHostController(view: view) navigationController.pushViewController(controller, animated: true) } + public func replaceCourseUnit( + id: String, + courseName: String, + blockId: String, + courseID: String, + sectionName: String, + verticalIndex: Int, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) { + + let vmVertical = Container.shared.resolve( + CourseVerticalViewModel.self, + arguments: chapters, + chapterIndex, + sequentialIndex + )! + + let viewVertical = CourseVerticalView( + title: chapters[chapterIndex].childs[sequentialIndex].displayName, + courseName: courseName, + courseID: courseID, + id: id, + viewModel: vmVertical + ) + let controllerVertical = SwiftUIHostController(view: viewVertical) + + let verticals = chapters[chapterIndex].childs[sequentialIndex].childs + + let viewModel = Container.shared.resolve( + CourseUnitViewModel.self, + arguments: blockId, + courseID, + id, + courseName, + chapters, + chapterIndex, + sequentialIndex, + verticalIndex + )! + let view = CourseUnitView(viewModel: viewModel, sectionName: sectionName) + let controllerUnit = SwiftUIHostController(view: view) + var controllers = navigationController.viewControllers + controllers.removeLast(2) + controllers.append(contentsOf: [controllerVertical, controllerUnit]) + navigationController.setViewControllers(controllers, animated: true) + } + public func showThreads(courseID: String, topics: Topics, title: String, type: ThreadType) { let router = Container.shared.resolve(DiscussionRouter.self)! let viewModel = Container.shared.resolve(PostsViewModel.self)! - let view = PostsView(courseID: courseID, topics: topics, title: title, - type: type, viewModel: viewModel, router: router) + let view = PostsView( + courseID: courseID, + currentBlockID: "", + topics: topics, + title: title, + type: type, + viewModel: viewModel, + router: router + ) let controller = SwiftUIHostController(view: view) navigationController.pushViewController(controller, animated: true) } @@ -244,7 +328,12 @@ public class Router: AuthorizationRouter, DiscoveryRouter, ProfileRouter, Dashbo ) { let router = Container.shared.resolve(DiscussionRouter.self)! let viewModel = Container.shared.resolve(ResponsesViewModel.self, argument: threadStateSubject)! - let view = ResponsesView(commentID: commentID, viewModel: viewModel, router: router, parentComment: parentComment) + let view = ResponsesView( + commentID: commentID, + viewModel: viewModel, + router: router, + parentComment: parentComment + ) let controller = SwiftUIHostController(view: view) navigationController.pushViewController(controller, animated: true) } @@ -255,19 +344,27 @@ public class Router: AuthorizationRouter, DiscoveryRouter, ProfileRouter, Dashbo onPostCreated: @escaping () -> Void ) { let viewModel = Container.shared.resolve(CreateNewThreadViewModel.self)! - let view = CreateNewThreadView(viewModel: viewModel, selectedTopic: selectedTopic, - courseID: courseID, onPostCreated: onPostCreated) + let view = CreateNewThreadView( + viewModel: viewModel, + selectedTopic: selectedTopic, + courseID: courseID, + onPostCreated: onPostCreated + ) let controller = SwiftUIHostController(view: view) navigationController.pushViewController(controller, animated: true) } - public func showEditProfile(userModel: Core.UserProfile, - avatar: UIImage?, - profileDidEdit: @escaping ((UserProfile?, UIImage?)) -> Void) { + public func showEditProfile( + userModel: Core.UserProfile, + avatar: UIImage?, + profileDidEdit: @escaping ((UserProfile?, UIImage?)) -> Void + ) { let viewModel = Container.shared.resolve(EditProfileViewModel.self, argument: userModel)! - let view = EditProfileView(viewModel: viewModel, - avatar: avatar, - profileDidEdit: profileDidEdit) + let view = EditProfileView( + viewModel: viewModel, + avatar: avatar, + profileDidEdit: profileDidEdit + ) let controller = SwiftUIHostController(view: view) navigationController.pushViewController(controller, animated: true) } @@ -286,9 +383,11 @@ public class Router: AuthorizationRouter, DiscoveryRouter, ProfileRouter, Dashbo } private func present(transitionStyle: UIModalTransitionStyle, view: ToPresent) { - navigationController.present(prepareToPresent(view, transitionStyle: transitionStyle), - animated: true, - completion: {}) + navigationController.present( + prepareToPresent(view, transitionStyle: transitionStyle), + animated: true, + completion: {} + ) } public func showDeleteProfileView() { diff --git a/NewEdX/SwiftUIHostController.swift b/OpenEdX/SwiftUIHostController.swift similarity index 99% rename from NewEdX/SwiftUIHostController.swift rename to OpenEdX/SwiftUIHostController.swift index 09bcfde30..922edce15 100644 --- a/NewEdX/SwiftUIHostController.swift +++ b/OpenEdX/SwiftUIHostController.swift @@ -1,6 +1,6 @@ // // SwiftUIHostController.swift -// NewEdX +// OpenEdX // // Created by Vladimir Chekyrta on 13.09.2022. // diff --git a/NewEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift similarity index 77% rename from NewEdX/View/MainScreenView.swift rename to OpenEdX/View/MainScreenView.swift index a3014b2f2..fa57435d4 100644 --- a/NewEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -1,6 +1,6 @@ // // MainScreenView.swift -// NewEdX +// OpenEdX // // Created by Vladimir Chekyrta on 15.09.2022. // @@ -11,6 +11,7 @@ import Core import Swinject import Dashboard import Profile +import SwiftUIIntrospect struct MainScreenView: View { @@ -23,6 +24,8 @@ struct MainScreenView: View { case profile } + let analytics = Container.shared.resolve(MainScreenAnalytics.self)! + init() { UITabBar.appearance().isTranslucent = false UITabBar.appearance().barTintColor = CoreAssets.textInputUnfocusedBackground.color @@ -41,8 +44,8 @@ struct MainScreenView: View { Text(CoreLocalization.Mainscreen.discovery) } .tag(MainTab.discovery) - .navigationBarHidden(true) - + .hideNavigationBar() + VStack { DashboardView( viewModel: Container.shared.resolve(DashboardViewModel.self)!, @@ -54,10 +57,7 @@ struct MainScreenView: View { Text(CoreLocalization.Mainscreen.dashboard) } .tag(MainTab.dashboard) - .navigationBarHidden(true) - .introspectViewController { vc in - vc.navigationController?.setNavigationBarHidden(true, animated: false) - } + .hideNavigationBar() VStack { Text(CoreLocalization.Mainscreen.inDeveloping) @@ -67,8 +67,8 @@ struct MainScreenView: View { Text(CoreLocalization.Mainscreen.programs) } .tag(MainTab.programs) - .navigationBarHidden(true) - + .hideNavigationBar() + VStack { ProfileView( viewModel: Container.shared.resolve(ProfileViewModel.self)! @@ -79,11 +79,20 @@ struct MainScreenView: View { Text(CoreLocalization.Mainscreen.profile) } .tag(MainTab.profile) - .navigationBarHidden(true) - .introspectViewController { vc in - vc.navigationController?.setNavigationBarHidden(true, animated: false) - } - } .navigationBarHidden(true) + .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() + } + }) } struct MainScreenView_Previews: PreviewProvider { diff --git a/NewEdX/en.lproj/Localizable.strings b/OpenEdX/en.lproj/Localizable.strings similarity index 88% rename from NewEdX/en.lproj/Localizable.strings rename to OpenEdX/en.lproj/Localizable.strings index ed63e3c25..8e7d62729 100644 --- a/NewEdX/en.lproj/Localizable.strings +++ b/OpenEdX/en.lproj/Localizable.strings @@ -1,6 +1,6 @@ /* Localizable.strings - NewEdX + OpenEdX Created by Vladimir Chekyrta on 13.09.2022. diff --git a/NewEdX/uk.lproj/LaunchScreen.strings b/OpenEdX/uk.lproj/LaunchScreen.strings similarity index 100% rename from NewEdX/uk.lproj/LaunchScreen.strings rename to OpenEdX/uk.lproj/LaunchScreen.strings diff --git a/NewEdX/uk.lproj/Localizable.strings b/OpenEdX/uk.lproj/Localizable.strings similarity index 88% rename from NewEdX/uk.lproj/Localizable.strings rename to OpenEdX/uk.lproj/Localizable.strings index ed63e3c25..8e7d62729 100644 --- a/NewEdX/uk.lproj/Localizable.strings +++ b/OpenEdX/uk.lproj/Localizable.strings @@ -1,6 +1,6 @@ /* Localizable.strings - NewEdX + OpenEdX Created by Vladimir Chekyrta on 13.09.2022. diff --git a/NewEdX/uk.lproj/languages.json b/OpenEdX/uk.lproj/languages.json similarity index 99% rename from NewEdX/uk.lproj/languages.json rename to OpenEdX/uk.lproj/languages.json index 15d873ff6..5c07f7b8c 100644 --- a/NewEdX/uk.lproj/languages.json +++ b/OpenEdX/uk.lproj/languages.json @@ -27,7 +27,7 @@ "hy": "Вірменська" }, { - "as": "Сссамська" + "as": "Саамська" }, { "av": "Аварська" diff --git "a/NewEdX/uk.lproj/\321\201ountries.json" "b/OpenEdX/uk.lproj/\321\201ountries.json" similarity index 100% rename from "NewEdX/uk.lproj/\321\201ountries.json" rename to "OpenEdX/uk.lproj/\321\201ountries.json" diff --git a/Podfile b/Podfile index 3777caffe..1d97fa467 100644 --- a/Podfile +++ b/Podfile @@ -4,26 +4,30 @@ use_frameworks! :linkage => :static abstract_target "App" do #Code style - pod 'SwiftLint', '0.49.1' + pod 'SwiftLint', '~> 0.5' #CodeGen for resources - pod 'SwiftGen', '~> 6.0' + pod 'SwiftGen', '~> 6.6' - target "NewEdX" do + target "OpenEdX" do inherit! :complete - workspace './NewEdX.xcodeproj' + workspace './OpenEdX.xcodeproj' end target "Core" do project './Core/Core.xcodeproj' workspace './Core/Core.xcodeproj' + #Firebase + pod 'FirebaseAnalytics', '~> 10.11' + pod 'FirebaseCrashlytics', '~> 10.11' #Networking - pod 'Alamofire', '5.6.4' + pod 'Alamofire', '~> 5.7' #Keychain pod 'KeychainSwift', '~> 20.0' #SwiftUI backward UIKit access - pod 'Introspect', '0.1.4' - pod 'Kingfisher', '~> 7.6.2' - pod 'Swinject', '2.8.2' + #pod 'Introspect', '~> 0.6' + pod 'SwiftUIIntrospect', '~> 0.8' + pod 'Kingfisher', '~> 7.8' + pod 'Swinject', '2.8.3' end target "Authorization" do diff --git a/Podfile.lock b/Podfile.lock index 20ab398af..5b998cd5e 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,36 +1,149 @@ PODS: - - Alamofire (5.6.4) - - Introspect (0.1.4) + - Alamofire (5.7.1) + - FirebaseAnalytics (10.11.0): + - FirebaseAnalytics/AdIdSupport (= 10.11.0) + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - FirebaseAnalytics/AdIdSupport (10.11.0): + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleAppMeasurement (= 10.11.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - FirebaseCore (10.11.0): + - FirebaseCoreInternal (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/Logger (~> 7.8) + - FirebaseCoreExtension (10.11.0): + - FirebaseCore (~> 10.0) + - FirebaseCoreInternal (10.11.0): + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseCrashlytics (10.11.0): + - FirebaseCore (~> 10.5) + - FirebaseInstallations (~> 10.0) + - FirebaseSessions (~> 10.5) + - GoogleDataTransport (~> 9.2) + - GoogleUtilities/Environment (~> 7.8) + - nanopb (< 2.30910.0, >= 2.30908.0) + - PromisesObjC (~> 2.1) + - FirebaseInstallations (10.11.0): + - FirebaseCore (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/UserDefaults (~> 7.8) + - PromisesObjC (~> 2.1) + - FirebaseSessions (10.11.0): + - FirebaseCore (~> 10.5) + - FirebaseCoreExtension (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleDataTransport (~> 9.2) + - GoogleUtilities/Environment (~> 7.10) + - nanopb (< 2.30910.0, >= 2.30908.0) + - PromisesSwift (~> 2.1) + - GoogleAppMeasurement (10.11.0): + - GoogleAppMeasurement/AdIdSupport (= 10.11.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleAppMeasurement/AdIdSupport (10.11.0): + - GoogleAppMeasurement/WithoutAdIdSupport (= 10.11.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleAppMeasurement/WithoutAdIdSupport (10.11.0): + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleDataTransport (9.2.3): + - GoogleUtilities/Environment (~> 7.7) + - nanopb (< 2.30910.0, >= 2.30908.0) + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/AppDelegateSwizzler (7.11.1): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Environment (7.11.1): + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/Logger (7.11.1): + - GoogleUtilities/Environment + - GoogleUtilities/MethodSwizzler (7.11.1): + - GoogleUtilities/Logger + - GoogleUtilities/Network (7.11.1): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (7.11.1)" + - GoogleUtilities/Reachability (7.11.1): + - GoogleUtilities/Logger + - GoogleUtilities/UserDefaults (7.11.1): + - GoogleUtilities/Logger - KeychainSwift (20.0.0) - - Kingfisher (7.6.2) + - Kingfisher (7.8.1) + - nanopb (2.30909.0): + - nanopb/decode (= 2.30909.0) + - nanopb/encode (= 2.30909.0) + - nanopb/decode (2.30909.0) + - nanopb/encode (2.30909.0) + - PromisesObjC (2.2.0) + - PromisesSwift (2.2.0): + - PromisesObjC (= 2.2.0) - Sourcery (1.8.0): - Sourcery/CLI-Only (= 1.8.0) - Sourcery/CLI-Only (1.8.0) - SwiftGen (6.6.2) - - SwiftLint (0.49.1) + - SwiftLint (0.52.3) + - SwiftUIIntrospect (0.8.0) - SwiftyMocky (4.2.0): - Sourcery (= 1.8.0) - - Swinject (2.8.2) + - Swinject (2.8.3) DEPENDENCIES: - - Alamofire (= 5.6.4) - - Introspect (= 0.1.4) + - Alamofire (~> 5.7) + - FirebaseAnalytics (~> 10.11) + - FirebaseCrashlytics (~> 10.11) - KeychainSwift (~> 20.0) - - Kingfisher (~> 7.6.2) - - SwiftGen (~> 6.0) - - SwiftLint (= 0.49.1) + - Kingfisher (~> 7.8) + - SwiftGen (~> 6.6) + - SwiftLint (~> 0.5) + - SwiftUIIntrospect (~> 0.8) - SwiftyMocky (from `https://github.com/MakeAWishFoundation/SwiftyMocky.git`, tag `4.2.0`) - - Swinject (= 2.8.2) + - Swinject (= 2.8.3) SPEC REPOS: trunk: - Alamofire - - Introspect + - FirebaseAnalytics + - FirebaseCore + - FirebaseCoreExtension + - FirebaseCoreInternal + - FirebaseCrashlytics + - FirebaseInstallations + - FirebaseSessions + - GoogleAppMeasurement + - GoogleDataTransport + - GoogleUtilities - KeychainSwift - Kingfisher + - nanopb + - PromisesObjC + - PromisesSwift - Sourcery - SwiftGen - SwiftLint + - SwiftUIIntrospect - Swinject EXTERNAL SOURCES: @@ -44,16 +157,29 @@ CHECKOUT OPTIONS: :tag: 4.2.0 SPEC CHECKSUMS: - Alamofire: 4e95d97098eacb88856099c4fc79b526a299e48c - Introspect: b62c4dd2063072327c21d618ef2bedc3c87bc366 + Alamofire: 0123a34370cb170936ae79a8df46cc62b2edeb88 + FirebaseAnalytics: 6c6bf99e8854475bf1fa342028841be8ecd236da + FirebaseCore: 62fd4d549f5e3f3bd52b7998721c5fa0557fb355 + FirebaseCoreExtension: cacdad57fdb60e0b86dcbcac058ec78237946759 + FirebaseCoreInternal: 9e46c82a14a3b3a25be4e1e151ce6d21536b89c0 + FirebaseCrashlytics: 5927efd92f7fb052b0ab1e673d2f0d97274cd442 + FirebaseInstallations: 2a2c6859354cbec0a228a863d4daf6de7c74ced4 + FirebaseSessions: a62ba5c45284adb7714f4126cfbdb32b17c260bd + GoogleAppMeasurement: d3dabccdb336fc0ae44b633c8abaa26559893cd9 + GoogleDataTransport: f0308f5905a745f94fb91fea9c6cbaf3831cb1bd + GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749 KeychainSwift: 0ce6a4d13f7228054d1a71bb1b500448fb2ab837 - Kingfisher: 6c5449c6450c5239166510ba04afe374a98afc4f + Kingfisher: 63f677311d36a3473f6b978584f8a3845d023dc5 + nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 + PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef + PromisesSwift: cf9eb58666a43bbe007302226e510b16c1e10959 Sourcery: 6f5fe49b82b7e02e8c65560cbd52e1be67a1af2e SwiftGen: 1366a7f71aeef49954ca5a63ba4bef6b0f24138c - SwiftLint: 32ee33ded0636d0905ef6911b2b67bbaeeedafa5 + SwiftLint: 76ec9c62ad369cff2937474cb34c9af3fa270b7b + SwiftUIIntrospect: cde309fef1f6690dd7585100453f1985f3b91c77 SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 - Swinject: b4a11b31992e8668308dc594ba1cb9b3164a37ab + Swinject: 893c9a543000ac2f10ee4cbaf0933c6992c935d5 -PODFILE CHECKSUM: 581ff8dcee79309f445fdcb8360e69b153117532 +PODFILE CHECKSUM: 1639b311802f5d36686512914067b7221ff97a64 -COCOAPODS: 1.12.0 +COCOAPODS: 1.12.1 diff --git a/Profile/Profile.xcodeproj.xcworkspace/contents.xcworkspacedata b/Profile/Profile.xcodeproj.xcworkspace/contents.xcworkspacedata index 4159659ea..964f294a7 100644 --- a/Profile/Profile.xcodeproj.xcworkspace/contents.xcworkspacedata +++ b/Profile/Profile.xcodeproj.xcworkspace/contents.xcworkspacedata @@ -14,7 +14,7 @@ location = "group:../Discovery/Discovery.xcodeproj"> + location = "group:../OpenEdX.xcodeproj"> diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index 135f89a25..c92a7d9d7 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 */; }; + 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 */; }; 25B36FF48C1307888A3890DA /* Pods_App_Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BEA369C38362C1A91A012F70 /* Pods_App_Profile.framework */; }; @@ -70,6 +71,7 @@ 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 = ""; }; 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 = ""; }; 0796C8C829B7905300444B05 /* ProfileBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileBottomSheet.swift; sourceTree = ""; }; 0E5054C44435557666B6D885 /* Pods-App-Profile.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Profile.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Profile/Pods-App-Profile.debugstage.xcconfig"; sourceTree = ""; }; @@ -175,6 +177,7 @@ 0203DC3C29AE79EB0017BD05 /* EditProfile */, 0262149029AE5793008BD75A /* DeleteAccount */, 02F3BFE6292539850051930C /* ProfileRouter.swift */, + 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */, ); path = Presentation; sourceTree = ""; @@ -547,6 +550,7 @@ 0259104829C3A5F0004B5A55 /* VideoQualityView.swift in Sources */, 021D924628DC634300ACC565 /* ProfileView.swift in Sources */, 02F3BFE7292539850051930C /* ProfileRouter.swift in Sources */, + 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */, 0262149429AE57B1008BD75A /* DeleteAccountViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -728,7 +732,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Profile; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Profile; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -763,7 +767,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Profile; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Profile; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -860,7 +864,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Profile; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Profile; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -958,7 +962,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Profile; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Profile; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1050,7 +1054,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Profile; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Profile; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1141,7 +1145,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Profile; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Profile; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1159,12 +1163,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.ProfileTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1180,12 +1184,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.ProfileTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1201,12 +1205,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.ProfileTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1222,12 +1226,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.ProfileTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1243,12 +1247,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.ProfileTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1264,12 +1268,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.ProfileTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1364,7 +1368,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Profile; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Profile; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1383,12 +1387,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.ProfileTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -1477,7 +1481,7 @@ "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.raccoongang.Profile; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.Profile; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1495,12 +1499,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KHU94Q2JSS; + DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = stepanok.ProfileTests; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 3e7418c10..0d3d52798 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -143,6 +143,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { // Mark - For testing and SwiftUI preview #if DEBUG +// swiftlint:disable all class ProfileRepositoryMock: ProfileRepositoryProtocol { func getMyProfileOffline() throws -> Core.UserProfile { return UserProfile( @@ -212,4 +213,5 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { } public func saveSettings(_ settings: UserSettings) {} } +// swiftlint:enable all #endif diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 947e4d9a2..bef8c06c9 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -10,7 +10,8 @@ import Core public struct DeleteAccountView: View { - @ObservedObject private var viewModel: DeleteAccountViewModel + @ObservedObject + private var viewModel: DeleteAccountViewModel public init(viewModel: DeleteAccountViewModel) { self.viewModel = viewModel @@ -21,8 +22,10 @@ public struct DeleteAccountView: View { // MARK: - Page name VStack(alignment: .center) { - NavigationBar(title: ProfileLocalization.DeleteAccount.title, - leftButtonAction: { viewModel.router.back() }) + NavigationBar( + title: ProfileLocalization.DeleteAccount.title, + leftButtonAction: { viewModel.router.back() } + ) .frameLimit() @@ -149,7 +152,11 @@ public struct DeleteAccountView: View { struct DeleteAccountView_Previews: PreviewProvider { static var previews: some View { let router = ProfileRouterMock() - let vm = DeleteAccountViewModel(interactor: ProfileInteractor.mock, router: router, connectivity: Connectivity()) + let vm = DeleteAccountViewModel( + interactor: ProfileInteractor.mock, + router: router, + connectivity: Connectivity() + ) DeleteAccountView(viewModel: vm) } diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index 5aa63e83c..93ba44d04 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -16,9 +16,11 @@ public struct EditProfileView: View { private var oldAvatar: UIImage? private var profileDidEdit: ((UserProfile?, UIImage?)) -> Void - public init(viewModel: EditProfileViewModel, - avatar: UIImage?, - profileDidEdit: @escaping ((UserProfile?, UIImage?)) -> Void) { + public init( + viewModel: EditProfileViewModel, + avatar: UIImage?, + profileDidEdit: @escaping ((UserProfile?, UIImage?)) -> Void + ) { self.viewModel = viewModel self.profileDidEdit = profileDidEdit self.viewModel.inputImage = avatar @@ -31,22 +33,27 @@ public struct EditProfileView: View { // 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 { - await viewModel.saveProfileUpdates() + NavigationBar( + title: ProfileLocalization.editProfile, + leftButtonAction: { + viewModel.backButtonTapped() + if viewModel.profileChanges.isAvatarSaved { + self.profileDidEdit((viewModel.editedProfile, viewModel.inputImage)) + } else { + self.profileDidEdit((viewModel.editedProfile, oldAvatar)) } - } - }, rightButtonIsActive: $viewModel.isChanged) + }, + rightButtonType: .done, + rightButtonAction: { + if viewModel.isChanged { + Task { + viewModel.analytics.profileEditDoneClicked() + await viewModel.saveProfileUpdates() + } + } + }, + rightButtonIsActive: $viewModel.isChanged + ) // MARK: - Page Body ScrollView { @@ -82,8 +89,10 @@ public struct EditProfileView: View { .font(Theme.Fonts.labelLarge) Group { - PickerView(config: viewModel.yearsConfiguration, - router: viewModel.router) + PickerView( + config: viewModel.yearsConfiguration, + router: viewModel.router + ) if viewModel.isEditable { VStack(alignment: .leading) { PickerView(config: viewModel.countriesConfiguration, @@ -131,6 +140,7 @@ public struct EditProfileView: View { }) Button(ProfileLocalization.Edit.deleteAccount, action: { + viewModel.analytics.profileDeleteAccountClicked() viewModel.router.showDeleteProfileView() }) .font(Theme.Fonts.labelLarge) @@ -237,7 +247,8 @@ struct EditProfileView_Previews: PreviewProvider { viewModel: EditProfileViewModel( userModel: userModel, interactor: ProfileInteractor.mock, - router: ProfileRouterMock()), + router: ProfileRouterMock(), + analytics: ProfileAnalyticsMock()), avatar: nil, profileDidEdit: {_ in} ) diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift index ff5adb785..ab76a9d76 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift @@ -41,11 +41,14 @@ public class EditProfileViewModel: ObservableObject { ProfileLocalization.Edit.Fields.spokenLangugae ) - @Published public var profileChanges: Changes = .init(shortBiography: "", - profileType: .limited, - isAvatarChanged: false, - isAvatarDeleted: false, - isAvatarSaved: false) + @Published + public var profileChanges: Changes = .init( + shortBiography: "", + profileType: .limited, + isAvatarChanged: false, + isAvatarDeleted: false, + isAvatarSaved: false + ) @Published public var inputImage: UIImage? private(set) var isYongUser: Bool = false @@ -73,18 +76,23 @@ public class EditProfileViewModel: ObservableObject { } private let interactor: ProfileInteractorProtocol - public let router: ProfileRouter + let router: ProfileRouter + let analytics: ProfileAnalytics - public init(userModel: UserProfile, interactor: ProfileInteractorProtocol, router: ProfileRouter) { + public init(userModel: UserProfile, + interactor: ProfileInteractorProtocol, + router: ProfileRouter, + analytics: ProfileAnalytics) { self.userModel = userModel self.interactor = interactor self.router = router + self.analytics = analytics self.spokenLanguages = interactor.getSpokenLanguages() self.countries = interactor.getCountries() generateYears() } - public func resizeImage(image: UIImage, longSideSize: Double) { + func resizeImage(image: UIImage, longSideSize: Double) { let size = image.size let widthRatio = longSideSize / size.width @@ -107,7 +115,7 @@ public class EditProfileViewModel: ObservableObject { } @MainActor - public func deleteAvatar() async throws { + func deleteAvatar() async throws { isShowProgress = true do { if try await interactor.deleteProfilePicture() { @@ -124,7 +132,7 @@ public class EditProfileViewModel: ObservableObject { } } - public func checkChanges() { + func checkChanges() { withAnimation(.easeIn(duration: 0.1)) { self.isChanged = [spokenLanguageConfiguration.text.isEmpty ? false : spokenLanguageConfiguration.text != userModel.spokenLanguage, @@ -137,7 +145,7 @@ public class EditProfileViewModel: ObservableObject { } } - public func switchProfile() { + func switchProfile() { var yearOfBirth = 0 if yearsConfiguration.text != "" { yearOfBirth = Int(yearsConfiguration.text) ?? 0 @@ -151,7 +159,7 @@ public class EditProfileViewModel: ObservableObject { } } - public func checkProfileType() { + func checkProfileType() { if yearsConfiguration.text != "" { let yearOfBirth = yearsConfiguration.text if currentYear - (Int(yearOfBirth) ?? 0) < 13 { @@ -172,12 +180,6 @@ public class EditProfileViewModel: ObservableObject { } } } - if userModel.yearOfBirth == 0 { - withAnimation { - isYongUser = true - profileChanges.profileType = .limited - } - } if profileChanges.profileType == .full { isEditable = true } else { @@ -186,7 +188,7 @@ public class EditProfileViewModel: ObservableObject { } @MainActor - public func saveProfileUpdates() async { + func saveProfileUpdates() async { var parameters: [String: Any] = [:] if userModel.isFullProfile != profileChanges.profileType.boolValue { @@ -212,7 +214,7 @@ public class EditProfileViewModel: ObservableObject { } @MainActor - private func uploadData(parameters: [String: Any]) async { + func uploadData(parameters: [String: Any]) async { do { if profileChanges.isAvatarDeleted { try await deleteAvatar() @@ -254,7 +256,7 @@ public class EditProfileViewModel: ObservableObject { } } - public func backButtonTapped() { + func backButtonTapped() { if isChanged { router.presentAlert( alertTitle: ProfileLocalization.UnsavedDataAlert.title, @@ -274,10 +276,30 @@ public class EditProfileViewModel: ObservableObject { } } + func loadLocationsAndSpokenLanguages() { + if let yearOfBirth = userModel.yearOfBirth == 0 ? nil : userModel.yearOfBirth { + self.selectedYearOfBirth = PickerItem(key: "\(yearOfBirth)", value: "\(yearOfBirth)") + } + + if let index = countries.firstIndex(where: {$0.value == userModel.country}) { + countries[index].optionDefault = true + let selected = countries[index] + self.selectedCountry = PickerItem(key: selected.value, value: selected.name) + } + if let spokenLanguage = userModel.spokenLanguage { + if let spokenIndex = spokenLanguages.firstIndex(where: {$0.value == spokenLanguage }) { + let selected = spokenLanguages[spokenIndex] + self.selectedSpokeLanguage = PickerItem(key: selected.value, value: selected.name) + } + } + + generateFieldConfigurations() + } + private func generateYears() { let currentYear = Calendar.current.component(.year, from: Date()) years = [] - for i in currentYear-100...currentYear { + for i in stride(from: currentYear, to: currentYear - 100, by: -1) { years.append(PickerFields.Option(value: "\(i)", name: "\(i)", optionDefault: false)) } } @@ -317,25 +339,6 @@ public class EditProfileViewModel: ObservableObject { options: spokenLanguages), selectedItem: selectedSpokeLanguage) - profileChanges.shortBiography = userModel.shortBiography ?? "" - } - - public func loadLocationsAndSpokenLanguages() { - let yearOfBirth = userModel.yearOfBirth == 0 ? 2023 : userModel.yearOfBirth - self.selectedYearOfBirth = PickerItem(key: "\(yearOfBirth)", value: "\(yearOfBirth)") - - if let index = countries.firstIndex(where: {$0.value == userModel.country}) { - countries[index].optionDefault = true - let selected = countries[index] - self.selectedCountry = PickerItem(key: selected.value, value: selected.name) - } - if let spokenLanguage = userModel.spokenLanguage { - if let spokenIndex = spokenLanguages.firstIndex(where: {$0.value == spokenLanguage }) { - let selected = spokenLanguages[spokenIndex] - self.selectedSpokeLanguage = PickerItem(key: selected.value, value: selected.name) - } - } - - generateFieldConfigurations() + profileChanges.shortBiography = userModel.shortBiography } } diff --git a/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift b/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift index 08cd68df2..79f100c2c 100644 --- a/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift +++ b/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift @@ -38,9 +38,11 @@ struct ProfileBottomSheet: View { private var removePhoto: () -> Void @Binding private var showingBottomSheet: Bool - init(showingBottomSheet: Binding, - openGallery: @escaping () -> Void, - removePhoto: @escaping () -> Void) { + init( + showingBottomSheet: Binding, + openGallery: @escaping () -> Void, + removePhoto: @escaping () -> Void + ) { self._showingBottomSheet = showingBottomSheet self.openGallery = openGallery self.removePhoto = removePhoto diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index d9031b13a..ec3879bc5 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -26,9 +26,10 @@ public struct ProfileView: View { // MARK: - Page name VStack(alignment: .center) { NavigationBar(title: ProfileLocalization.title, - rightButtonType: .edit, - rightButtonAction: { + rightButtonType: .edit, + rightButtonAction: { if let userModel = viewModel.userModel { + viewModel.analytics.profileEditClicked() viewModel.router.showEditProfile( userModel: userModel, avatar: viewModel.updatedAvatar, @@ -43,7 +44,7 @@ public struct ProfileView: View { ) } }, rightButtonIsActive: .constant(viewModel.connectivity.isInternetAvaliable)) - + // MARK: - Page Body RefreshableScrollViewCompat(action: { @@ -90,8 +91,10 @@ public struct ProfileView: View { } } } - .cardStyle(bgColor: CoreAssets.textInputUnfocusedBackground.swiftUIColor, - strokeColor: .clear) + .cardStyle( + bgColor: CoreAssets.textInputUnfocusedBackground.swiftUIColor, + strokeColor: .clear + ) }.padding(.bottom, 16) } @@ -102,16 +105,19 @@ public struct ProfileView: View { .font(Theme.Fonts.labelLarge) VStack(alignment: .leading, spacing: 27) { HStack { - Button(action: { - viewModel.router.showSettings() - }, label: { + Button(action: { + viewModel.analytics.profileVideoSettingsClicked() + viewModel.router.showSettings() + }, label: { Text(ProfileLocalization.settingsVideo) Spacer() Image(systemName: "chevron.right") }) } - }.cardStyle(bgColor: CoreAssets.textInputUnfocusedBackground.swiftUIColor, - strokeColor: .clear) + }.cardStyle( + bgColor: CoreAssets.textInputUnfocusedBackground.swiftUIColor, + strokeColor: .clear + ) // MARK: - Support info Text(ProfileLocalization.supportInfo) @@ -119,40 +125,59 @@ public struct ProfileView: View { .font(Theme.Fonts.labelLarge) VStack(alignment: .leading, spacing: 24) { if let support = viewModel.contactSupport() { - HStack { - Link(destination: support, label: { + 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(CoreAssets.textSecondary.swiftUIColor) } + if let tos = viewModel.config.termsOfUse { - HStack { - Link(destination: tos, label: { + 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(CoreAssets.textSecondary.swiftUIColor) } + if let privacy = viewModel.config.privacyPolicy { - HStack { - Link(destination: privacy, label: { + 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: CoreAssets.textInputUnfocusedBackground.swiftUIColor, - strokeColor: .clear) + }.cardStyle( + bgColor: CoreAssets.textInputUnfocusedBackground.swiftUIColor, + strokeColor: .clear + ) // MARK: - Log out VStack { @@ -168,6 +193,7 @@ public struct ProfileView: View { }, okTapped: { Task { + viewModel.analytics.userLogout(force: false) await viewModel.logOut() } viewModel.router.dismiss(animated: true) @@ -228,6 +254,7 @@ struct ProfileView_Previews: PreviewProvider { let router = ProfileRouterMock() let vm = ProfileViewModel(interactor: ProfileInteractor.mock, router: router, + analytics: ProfileAnalyticsMock(), config: ConfigMock(), connectivity: Connectivity()) diff --git a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift index a489c7afe..8439adbc2 100644 --- a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift @@ -24,16 +24,19 @@ public class ProfileViewModel: ObservableObject { } private let interactor: ProfileInteractorProtocol - public let router: ProfileRouter - public let config: Config - public let connectivity: ConnectivityProtocol + let router: ProfileRouter + let analytics: ProfileAnalytics + let config: Config + let connectivity: ConnectivityProtocol public init(interactor: ProfileInteractorProtocol, router: ProfileRouter, + analytics: ProfileAnalytics, config: Config, connectivity: ConnectivityProtocol) { self.interactor = interactor self.router = router + self.analytics = analytics self.config = config self.connectivity = connectivity } diff --git a/Profile/Profile/Presentation/ProfileAnalytics.swift b/Profile/Profile/Presentation/ProfileAnalytics.swift new file mode 100644 index 000000000..58cc4b9d9 --- /dev/null +++ b/Profile/Profile/Presentation/ProfileAnalytics.swift @@ -0,0 +1,33 @@ +// +// ProfileAnalytics.swift +// Profile +// +// Created by  Stepanok Ivan on 29.06.2023. +// + +import Foundation + +//sourcery: AutoMockable +public protocol ProfileAnalytics { + func profileEditClicked() + func profileEditDoneClicked() + func profileDeleteAccountClicked() + func profileVideoSettingsClicked() + func privacyPolicyClicked() + func cookiePolicyClicked() + func emailSupportClicked() + func userLogout(force: Bool) +} + +#if DEBUG +class ProfileAnalyticsMock: ProfileAnalytics { + public func profileEditClicked() {} + public func profileEditDoneClicked() {} + public func profileDeleteAccountClicked() {} + public func profileVideoSettingsClicked() {} + public func privacyPolicyClicked() {} + public func cookiePolicyClicked() {} + public func emailSupportClicked() {} + public func userLogout(force: Bool) {} +} +#endif diff --git a/Profile/Profile/Presentation/ProfileRouter.swift b/Profile/Profile/Presentation/ProfileRouter.swift index 659b15205..38f0de7e4 100644 --- a/Profile/Profile/Presentation/ProfileRouter.swift +++ b/Profile/Profile/Presentation/ProfileRouter.swift @@ -12,8 +12,11 @@ import UIKit //sourcery: AutoMockable public protocol ProfileRouter: BaseRouter { - func showEditProfile(userModel: Core.UserProfile, avatar: UIImage?, - profileDidEdit: @escaping ((UserProfile?, UIImage?)) -> Void) + func showEditProfile( + userModel: Core.UserProfile, + avatar: UIImage?, + profileDidEdit: @escaping ((UserProfile?, UIImage?)) -> Void + ) func showSettings() @@ -29,8 +32,11 @@ public class ProfileRouterMock: BaseRouterMock, ProfileRouter { public override init() {} - public func showEditProfile(userModel: Core.UserProfile, avatar: UIImage?, - profileDidEdit: @escaping ((UserProfile?, UIImage?)) -> Void) {} + public func showEditProfile( + userModel: Core.UserProfile, + avatar: UIImage?, + profileDidEdit: @escaping ((UserProfile?, UIImage?)) -> Void + ) {} public func showSettings() {} diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index a9a85662a..84b6869af 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -11,11 +11,11 @@ import Kingfisher public struct SettingsView: View { - @ObservedObject private var viewModel: SettingsViewModel + @ObservedObject + private var viewModel: SettingsViewModel public init(viewModel: SettingsViewModel) { self.viewModel = viewModel - } public var body: some View { @@ -24,7 +24,7 @@ public struct SettingsView: View { // MARK: - Page name VStack(alignment: .center) { NavigationBar(title: ProfileLocalization.Settings.videoSettingsTitle, - leftButtonAction: { viewModel.router.back() }) + leftButtonAction: { viewModel.router.back() }) // MARK: - Page Body @@ -35,11 +35,12 @@ public struct SettingsView: View { .padding(.top, 200) .padding(.horizontal) } else { - // MARK: Wi-fi HStack { - SettingsCell(title: ProfileLocalization.Settings.wifiTitle, - description: ProfileLocalization.Settings.wifiDescription) + SettingsCell( + title: ProfileLocalization.Settings.wifiTitle, + description: ProfileLocalization.Settings.wifiDescription + ) Toggle(isOn: $viewModel.wifiOnly, label: {}) .toggleStyle(SwitchToggleStyle(tint: .accentColor)) .frame(width: 50) @@ -54,7 +55,7 @@ public struct SettingsView: View { SettingsCell(title: ProfileLocalization.Settings.videoQualityTitle, description: viewModel.selectedQuality.settingsDescription()) }) -// Spacer() + // Spacer() Image(systemName: "chevron.right") .padding(.trailing, 12) .frame(width: 10) @@ -93,8 +94,10 @@ public struct SettingsView: View { struct SettingsView_Previews: PreviewProvider { static var previews: some View { let router = ProfileRouterMock() - let vm = SettingsViewModel(interactor: ProfileInteractor.mock, - router: router) + let vm = SettingsViewModel( + interactor: ProfileInteractor.mock, + router: router + ) SettingsView(viewModel: vm) .preferredColorScheme(.light) diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index 8973f63cf..99e11ba38 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -43,7 +43,7 @@ public class SettingsViewModel: ObservableObject { private var userSettings: UserSettings private let interactor: ProfileInteractorProtocol - public let router: ProfileRouter + let router: ProfileRouter public init(interactor: ProfileInteractorProtocol, router: ProfileRouter) { self.interactor = interactor diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index 1e2a0905e..bcf071d6a 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -11,11 +11,11 @@ import Kingfisher public struct VideoQualityView: View { - @ObservedObject private var viewModel: SettingsViewModel + @ObservedObject + private var viewModel: SettingsViewModel public init(viewModel: SettingsViewModel) { self.viewModel = viewModel - } public var body: some View { @@ -24,7 +24,7 @@ public struct VideoQualityView: View { // MARK: - Page name VStack(alignment: .center) { NavigationBar(title: ProfileLocalization.Settings.videoQualityTitle, - leftButtonAction: { viewModel.router.back() }) + leftButtonAction: { viewModel.router.back() }) // MARK: - Page Body @@ -40,20 +40,22 @@ public struct VideoQualityView: View { Button(action: { viewModel.selectedQuality = quality }, label: { - HStack { - SettingsCell(title: quality.title(), - description: quality.description()) - Spacer() + HStack { + SettingsCell( + title: quality.title(), + description: quality.description() + ) + Spacer() CoreAssets.checkmark.swiftUIImage .renderingMode(.template) .foregroundColor(.accentColor) .opacity(quality == viewModel.selectedQuality ? 1 : 0) - - }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) + + }.foregroundColor(CoreAssets.textPrimary.swiftUIColor) }) Divider() } - + } }.frame(minWidth: 0, maxWidth: .infinity, diff --git a/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift index cefabff3f..47280627c 100644 --- a/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift @@ -18,6 +18,7 @@ final class EditProfileViewModelTests: XCTestCase { func testResizeVerticalImage() async throws { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userProfile = UserProfile( avatarUrl: "url", name: "Test", @@ -33,7 +34,12 @@ final class EditProfileViewModelTests: XCTestCase { Given(interactor, .getSpokenLanguages(willReturn: [])) Given(interactor, .getCountries(willReturn: [])) - let viewModel = EditProfileViewModel(userModel: userProfile, interactor: interactor, router: router) + let viewModel = EditProfileViewModel( + userModel: userProfile, + interactor: interactor, + router: router, + analytics: analytics + ) let imageVertical = UIGraphicsImageRenderer(size: CGSize(width: 600, height: 800)).image { rendererContext in UIColor.red.setFill() @@ -49,6 +55,7 @@ final class EditProfileViewModelTests: XCTestCase { func testResizeHorizontalImage() async throws { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userProfile = UserProfile( avatarUrl: "url", name: "Test", @@ -67,7 +74,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userProfile, interactor: interactor, - router: router + router: router, + analytics: analytics ) let imageHorizontal = UIGraphicsImageRenderer(size: CGSize(width: 800, height: 600)).image { rendererContext in @@ -84,6 +92,7 @@ final class EditProfileViewModelTests: XCTestCase { func testCheckChangesShortBiographyChanged() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userProfile = UserProfile( avatarUrl: "url", name: "Test", @@ -102,7 +111,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userProfile, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.profileChanges.shortBiography = "New bio" @@ -114,6 +124,7 @@ final class EditProfileViewModelTests: XCTestCase { func testCheckChangesSpokenLanguageChanged() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -132,7 +143,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.spokenLanguageConfiguration.text = "Changed" @@ -144,6 +156,7 @@ final class EditProfileViewModelTests: XCTestCase { func testCheckChangesBirthYearChanged() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -162,7 +175,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.yearsConfiguration.text = "Changed" @@ -174,6 +188,7 @@ final class EditProfileViewModelTests: XCTestCase { func testCheckChangesAvatarChanged() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -192,7 +207,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.profileChanges.isAvatarChanged = true @@ -204,6 +220,7 @@ final class EditProfileViewModelTests: XCTestCase { func testCheckChangesProfileTypeChanged() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -222,7 +239,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.profileChanges.profileType = .limited @@ -234,6 +252,7 @@ final class EditProfileViewModelTests: XCTestCase { func testCheckChangesCountryChanged() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -252,7 +271,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.countriesConfiguration.text = "Changed" @@ -264,6 +284,7 @@ final class EditProfileViewModelTests: XCTestCase { func testCheckProfileTypeNotYongUser() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -282,7 +303,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.profileChanges.profileType = viewModel.userModel.isFullProfile ? .full : .limited @@ -298,6 +320,7 @@ final class EditProfileViewModelTests: XCTestCase { func testCheckProfileTypeIsYongerUser() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -318,7 +341,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.yearsConfiguration.text = "\(yearOfBirth10Years - 1)" @@ -332,6 +356,7 @@ final class EditProfileViewModelTests: XCTestCase { func testCheckProfileTypeYearsConfigurationEmpty() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -350,7 +375,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.yearsConfiguration.text = "" @@ -365,6 +391,7 @@ final class EditProfileViewModelTests: XCTestCase { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -383,14 +410,15 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.yearsConfiguration.text = "" viewModel.checkProfileType() XCTAssertEqual(viewModel.profileChanges.profileType, .limited) - XCTAssertTrue(viewModel.isYongUser) + XCTAssertFalse(viewModel.isYongUser) XCTAssertFalse(viewModel.isEditable) XCTAssertTrue(viewModel.profileChanges.profileType == .limited) } @@ -398,6 +426,7 @@ final class EditProfileViewModelTests: XCTestCase { func testSaveProfileUpdates() async { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -416,7 +445,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.countriesConfiguration.text = "USA" @@ -444,6 +474,7 @@ final class EditProfileViewModelTests: XCTestCase { func testDeleteAvatarSuccess() async { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -462,7 +493,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.profileChanges.isAvatarDeleted = true @@ -485,6 +517,7 @@ final class EditProfileViewModelTests: XCTestCase { func testSaveProfileUpdatesNoInternetError() async { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -503,7 +536,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.countriesConfiguration.text = "USA" @@ -539,6 +573,7 @@ final class EditProfileViewModelTests: XCTestCase { func testSaveProfileUpdatesUnknownError() async { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -557,7 +592,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.countriesConfiguration.text = "USA" @@ -591,6 +627,7 @@ final class EditProfileViewModelTests: XCTestCase { func testBackButtonTapped() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -609,7 +646,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.profileChanges.isAvatarChanged = true @@ -627,6 +665,7 @@ final class EditProfileViewModelTests: XCTestCase { func testGenerateFieldConfigurationsFullProfile() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -645,7 +684,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.loadLocationsAndSpokenLanguages() @@ -657,6 +697,7 @@ final class EditProfileViewModelTests: XCTestCase { func testGenerateFieldConfigurationsLimitedProfile() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -675,7 +716,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.loadLocationsAndSpokenLanguages() @@ -686,6 +728,7 @@ final class EditProfileViewModelTests: XCTestCase { func testLoadLocationsAndSpokenLanguages() async throws { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() let userModel = UserProfile( avatarUrl: "url", name: "Test", @@ -709,7 +752,8 @@ final class EditProfileViewModelTests: XCTestCase { let viewModel = EditProfileViewModel( userModel: userModel, interactor: interactor, - router: router + router: router, + analytics: analytics ) viewModel.loadLocationsAndSpokenLanguages() diff --git a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift index 870229912..6c6502921 100644 --- a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift @@ -17,10 +17,12 @@ final class ProfileViewModelTests: XCTestCase { func testGetMyProfileSuccess() async throws { 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) @@ -49,10 +51,12 @@ final class ProfileViewModelTests: XCTestCase { func testGetMyProfileOfflineSuccess() async throws { 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) @@ -81,10 +85,12 @@ final class ProfileViewModelTests: XCTestCase { func testGetMyProfileNoInternetError() async throws { 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) @@ -105,10 +111,12 @@ final class ProfileViewModelTests: XCTestCase { func testGetMyProfileNoCacheError() async throws { 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) @@ -127,10 +135,12 @@ final class ProfileViewModelTests: XCTestCase { func testGetMyProfileUnknownError() async throws { 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) @@ -149,10 +159,12 @@ final class ProfileViewModelTests: XCTestCase { func testLogOutSuccess() async throws { 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) @@ -168,10 +180,12 @@ final class ProfileViewModelTests: XCTestCase { func testLogOutNoInternetError() async throws { 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) @@ -189,10 +203,12 @@ final class ProfileViewModelTests: XCTestCase { func testLogOutUnknownError() async throws { 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) diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index c41a29771..8d71c7ff2 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -121,17 +121,20 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } - open func registerUser(fields: [String: String]) throws { + open func registerUser(fields: [String: String]) throws -> User { addInvocation(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))) let perform = methodPerformValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))) as? ([String: String]) -> Void perform?(`fields`) + var __value: User do { - _ = try methodReturnValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() as Void + __value = try methodReturnValue(.m_registerUser__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() } catch MockError.notStubed { - // do nothing + onFatalFailure("Stub return value not specified for registerUser(fields: [String: String]). Use given") + Failure("Stub return value not specified for registerUser(fields: [String: String]). Use given") } catch { throw error } + return __value } open func validateRegistrationFields(fields: [String: String]) throws -> [String: String] { @@ -233,6 +236,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func getRegistrationFields(willReturn: [PickerFields]...) -> MethodStub { return Given(method: .m_getRegistrationFields, products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func registerUser(fields: Parameter<[String: String]>, willReturn: User...) -> MethodStub { + return Given(method: .m_registerUser__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func validateRegistrationFields(fields: Parameter<[String: String]>, willReturn: [String: String]...) -> MethodStub { return Given(method: .m_validateRegistrationFields__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -281,10 +287,10 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func registerUser(fields: Parameter<[String: String]>, willThrow: Error...) -> MethodStub { return Given(method: .m_registerUser__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func registerUser(fields: Parameter<[String: String]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func registerUser(fields: Parameter<[String: String]>, willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] let given: Given = { return Given(method: .m_registerUser__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + let stubber = given.stubThrows(for: (User).self) willProduce(stubber) return given } @@ -514,10 +520,10 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`) } - open func presentAlert(alertTitle: String, alertMessage: String, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void) { - addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) - let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) as? (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void - perform?(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`) + open func presentAlert(alertTitle: String, alertMessage: String, nextSectionName: String?, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, nextSectionTapped: @escaping () -> Void) { + addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) + let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) as? (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void + perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { @@ -544,7 +550,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showRegisterScreen case m_showForgotPasswordScreen case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) - case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>) + case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) case m_presentView__transitionStyle_transitionStylecontent_content(Parameter, Parameter<() -> any View>) @@ -590,14 +596,16 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) return Matcher.ComparisonResult(results) - case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped)): + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsNextsectionname, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped, let lhsNextsectiontapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsNextsectionname, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped, let rhsNextsectiontapped)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlertmessage, rhs: rhsAlertmessage, with: matcher), lhsAlertmessage, rhsAlertmessage, "alertMessage")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectionname, rhs: rhsNextsectionname, with: matcher), lhsNextsectionname, rhsNextsectionname, "nextSectionName")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsImage, rhs: rhsImage, with: matcher), lhsImage, rhsImage, "image")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOnclosetapped, rhs: rhsOnclosetapped, with: matcher), lhsOnclosetapped, rhsOnclosetapped, "onCloseTapped")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOktapped, rhs: rhsOktapped, with: matcher), lhsOktapped, rhsOktapped, "okTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): @@ -627,7 +635,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue - case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue case let .m_presentView__transitionStyle_transitionStylecontent_content(p0, p1): return p0.intValue + p1.intValue } @@ -644,7 +652,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" - case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped: return ".presentAlert(alertTitle:alertMessage:action:image:onCloseTapped:okTapped:)" + case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" case .m_presentView__transitionStyle_transitionStylecontent_content: return ".presentView(transitionStyle:content:)" } @@ -675,7 +683,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`))} + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} public static func presentView(transitionStyle: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStylecontent_content(`transitionStyle`, `content`))} } @@ -714,8 +722,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, perform: @escaping (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { - return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`), performs: perform) + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { + return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) @@ -994,6 +1002,286 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - ProfileAnalytics + +open class ProfileAnalyticsMock: ProfileAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func profileEditClicked() { + addInvocation(.m_profileEditClicked) + let perform = methodPerformValue(.m_profileEditClicked) as? () -> Void + perform?() + } + + open func profileEditDoneClicked() { + addInvocation(.m_profileEditDoneClicked) + let perform = methodPerformValue(.m_profileEditDoneClicked) as? () -> Void + perform?() + } + + open func profileDeleteAccountClicked() { + addInvocation(.m_profileDeleteAccountClicked) + let perform = methodPerformValue(.m_profileDeleteAccountClicked) as? () -> Void + perform?() + } + + open func profileVideoSettingsClicked() { + addInvocation(.m_profileVideoSettingsClicked) + let perform = methodPerformValue(.m_profileVideoSettingsClicked) as? () -> Void + perform?() + } + + open func privacyPolicyClicked() { + addInvocation(.m_privacyPolicyClicked) + let perform = methodPerformValue(.m_privacyPolicyClicked) as? () -> Void + perform?() + } + + open func cookiePolicyClicked() { + addInvocation(.m_cookiePolicyClicked) + let perform = methodPerformValue(.m_cookiePolicyClicked) as? () -> Void + perform?() + } + + open func emailSupportClicked() { + addInvocation(.m_emailSupportClicked) + let perform = methodPerformValue(.m_emailSupportClicked) as? () -> Void + perform?() + } + + open func userLogout(force: Bool) { + addInvocation(.m_userLogout__force_force(Parameter.value(`force`))) + let perform = methodPerformValue(.m_userLogout__force_force(Parameter.value(`force`))) as? (Bool) -> Void + perform?(`force`) + } + + + fileprivate enum MethodType { + case m_profileEditClicked + case m_profileEditDoneClicked + case m_profileDeleteAccountClicked + case m_profileVideoSettingsClicked + case m_privacyPolicyClicked + case m_cookiePolicyClicked + case m_emailSupportClicked + case m_userLogout__force_force(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_profileEditClicked, .m_profileEditClicked): return .match + + case (.m_profileEditDoneClicked, .m_profileEditDoneClicked): return .match + + case (.m_profileDeleteAccountClicked, .m_profileDeleteAccountClicked): return .match + + case (.m_profileVideoSettingsClicked, .m_profileVideoSettingsClicked): return .match + + case (.m_privacyPolicyClicked, .m_privacyPolicyClicked): return .match + + case (.m_cookiePolicyClicked, .m_cookiePolicyClicked): return .match + + case (.m_emailSupportClicked, .m_emailSupportClicked): return .match + + case (.m_userLogout__force_force(let lhsForce), .m_userLogout__force_force(let rhsForce)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_profileEditClicked: return 0 + case .m_profileEditDoneClicked: return 0 + case .m_profileDeleteAccountClicked: return 0 + case .m_profileVideoSettingsClicked: return 0 + case .m_privacyPolicyClicked: return 0 + case .m_cookiePolicyClicked: return 0 + case .m_emailSupportClicked: return 0 + case let .m_userLogout__force_force(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_profileEditClicked: return ".profileEditClicked()" + case .m_profileEditDoneClicked: return ".profileEditDoneClicked()" + case .m_profileDeleteAccountClicked: return ".profileDeleteAccountClicked()" + case .m_profileVideoSettingsClicked: return ".profileVideoSettingsClicked()" + case .m_privacyPolicyClicked: return ".privacyPolicyClicked()" + case .m_cookiePolicyClicked: return ".cookiePolicyClicked()" + case .m_emailSupportClicked: return ".emailSupportClicked()" + case .m_userLogout__force_force: return ".userLogout(force:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func profileEditClicked() -> Verify { return Verify(method: .m_profileEditClicked)} + public static func profileEditDoneClicked() -> Verify { return Verify(method: .m_profileEditDoneClicked)} + public static func profileDeleteAccountClicked() -> Verify { return Verify(method: .m_profileDeleteAccountClicked)} + public static func profileVideoSettingsClicked() -> Verify { return Verify(method: .m_profileVideoSettingsClicked)} + public static func privacyPolicyClicked() -> Verify { return Verify(method: .m_privacyPolicyClicked)} + public static func cookiePolicyClicked() -> Verify { return Verify(method: .m_cookiePolicyClicked)} + public static func emailSupportClicked() -> Verify { return Verify(method: .m_emailSupportClicked)} + public static func userLogout(force: Parameter) -> Verify { return Verify(method: .m_userLogout__force_force(`force`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func profileEditClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_profileEditClicked, performs: perform) + } + public static func profileEditDoneClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_profileEditDoneClicked, performs: perform) + } + public static func profileDeleteAccountClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_profileDeleteAccountClicked, performs: perform) + } + public static func profileVideoSettingsClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_profileVideoSettingsClicked, performs: perform) + } + public static func privacyPolicyClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_privacyPolicyClicked, performs: perform) + } + public static func cookiePolicyClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_cookiePolicyClicked, performs: perform) + } + public static func emailSupportClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_emailSupportClicked, performs: perform) + } + public static func userLogout(force: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_userLogout__force_force(`force`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - ProfileInteractorProtocol open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { @@ -1659,10 +1947,10 @@ open class ProfileRouterMock: ProfileRouter, Mock { perform?(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`) } - open func presentAlert(alertTitle: String, alertMessage: String, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void) { - addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) - let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`))) as? (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void - perform?(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`) + open func presentAlert(alertTitle: String, alertMessage: String, nextSectionName: String?, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, nextSectionTapped: @escaping () -> Void) { + addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) + let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) as? (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void + perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { @@ -1693,7 +1981,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case m_showRegisterScreen case m_showForgotPasswordScreen case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) - case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>) + case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) case m_presentView__transitionStyle_transitionStylecontent_content(Parameter, Parameter<() -> any View>) @@ -1755,14 +2043,16 @@ open class ProfileRouterMock: ProfileRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) return Matcher.ComparisonResult(results) - case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped)): + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsNextsectionname, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped, let lhsNextsectiontapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsNextsectionname, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped, let rhsNextsectiontapped)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlertmessage, rhs: rhsAlertmessage, with: matcher), lhsAlertmessage, rhsAlertmessage, "alertMessage")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectionname, rhs: rhsNextsectionname, with: matcher), lhsNextsectionname, rhsNextsectionname, "nextSectionName")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsImage, rhs: rhsImage, with: matcher), lhsImage, rhsImage, "image")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOnclosetapped, rhs: rhsOnclosetapped, with: matcher), lhsOnclosetapped, rhsOnclosetapped, "onCloseTapped")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOktapped, rhs: rhsOktapped, with: matcher), lhsOktapped, rhsOktapped, "okTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): @@ -1796,7 +2086,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue - case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue case let .m_presentView__transitionStyle_transitionStylecontent_content(p0, p1): return p0.intValue + p1.intValue } @@ -1817,7 +2107,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" - case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped: return ".presentAlert(alertTitle:alertMessage:action:image:onCloseTapped:okTapped:)" + case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" case .m_presentView__transitionStyle_transitionStylecontent_content: return ".presentView(transitionStyle:content:)" } @@ -1852,7 +2142,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`))} + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} public static func presentView(transitionStyle: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStylecontent_content(`transitionStyle`, `content`))} } @@ -1903,8 +2193,8 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } - public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, perform: @escaping (String, String, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { - return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessageaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTapped(`alertTitle`, `alertMessage`, `action`, `image`, `onCloseTapped`, `okTapped`), performs: perform) + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { + return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) diff --git a/README.md b/README.md index d89789884..9a9afef9b 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ Modern vision of the mobile application for the Open EdX platform from Raccoon G 2. Navigate to the project folder and run ``pod install``. -3. Open ``NewEdX.xcworkspace``. +3. Open ``OpenEdX.xcworkspace``. -4. Ensure that the ``NewEdXDev`` or ``NewEdXProd`` scheme is selected. +4. Ensure that the ``OpenEdXDev`` or ``OpenEdXProd`` scheme is selected. -5. Configure the [``Environment.swift`` file](https://github.com/raccoongang/new-edx-app-ios/blob/main/NewEdX/Environment.swift) with URLs and OAuth credentials for your Open edX instance. +5. Configure the [``Environment.swift`` file](https://github.com/raccoongang/new-edx-app-ios/blob/main/OpenEdX/Environment.swift) with URLs and OAuth credentials for your Open edX instance. 6. Click the **Run** button. @@ -26,6 +26,8 @@ You can find the plugin with the API and installation guide [here](https://githu 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 diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 000000000..4282947e2 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,6 @@ +# app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app +# apple_id("[[APPLE_ID]]") # Your Apple Developer Portal username + + +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 000000000..aa059c588 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,33 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +lane :linting do + swiftlint( + mode: :lint, + executable: "Pods/SwiftLint/swiftlint", + # output_file: "swiftlint.result.json", + config_file: ".swiftlint.yml", + raise_if_swiftlint_error: true, + ignore_exit_status: false + ) +end + +lane :unit_tests do + run_tests( + workspace: "OpenEdX.xcworkspace", + device: "iPhone 14", + scheme: "OpenEdXDev" + ) +end diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 000000000..3f9a38ebc --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,38 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +### linting + +```sh +[bundle exec] fastlane linting +``` + + + +### unit_tests + +```sh +[bundle exec] fastlane unit_tests +``` + + + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).