diff --git a/.gitignore b/.gitignore index 36e2b2501..582c86cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore ## User settings -xcuserdata/* +xcuserdata/ +*.xcuserdata/* /OpenEdX.xcodeproj/xcuserdata/ /OpenEdX.xcworkspace/xcuserdata/ /OpenEdX.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -25,6 +26,15 @@ DerivedData/ *.perspectivev3 !default.perspectivev3 +*.xcodeproj/* +**/xcuserdata/ +**/*.xcuserdata/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings + ## Obj-C/Swift specific *.hmap @@ -100,4 +110,4 @@ iOSInjectionProject/ xcode-frameworks vendor/ -.bundle/ \ No newline at end of file +.bundle/ diff --git a/Authorization/Authorization/Presentation/AuthorizationRouter.swift b/Authorization/Authorization/Presentation/AuthorizationRouter.swift index 925e0a358..2d41d7683 100644 --- a/Authorization/Authorization/Presentation/AuthorizationRouter.swift +++ b/Authorization/Authorization/Presentation/AuthorizationRouter.swift @@ -9,13 +9,14 @@ import Foundation import Core //sourcery: AutoMockable -public protocol AuthorizationRouter: BaseRouter {} +public protocol AuthorizationRouter: BaseRouter { + func showUpdateRequiredView(showAccountLink: Bool) +} // Mark - For testing and SwiftUI preview #if DEBUG public class AuthorizationRouterMock: BaseRouterMock, AuthorizationRouter { - public override init() {} - + public func showUpdateRequiredView(showAccountLink: Bool) {} } #endif diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 3bdab24ca..8ad4a9949 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -165,7 +165,8 @@ struct SignInView_Previews: PreviewProvider { static var previews: some View { let vm = SignInViewModel( interactor: AuthInteractor.mock, - router: AuthorizationRouterMock(), + router: AuthorizationRouterMock(), + config: ConfigMock(), analytics: AuthorizationAnalyticsMock(), validator: Validator() ) diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 6d8ebfdee..86b3f32ef 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -31,7 +31,7 @@ public class SignInViewModel: ObservableObject { } let router: AuthorizationRouter - + private let config: Config private let interactor: AuthInteractorProtocol private let analytics: AuthorizationAnalytics private let validator: Validator @@ -39,11 +39,13 @@ public class SignInViewModel: ObservableObject { public init( interactor: AuthInteractorProtocol, router: AuthorizationRouter, + config: Config, analytics: AuthorizationAnalytics, validator: Validator ) { self.interactor = interactor self.router = router + self.config = config self.analytics = analytics self.validator = validator } @@ -64,11 +66,13 @@ public class SignInViewModel: ObservableObject { let user = try await interactor.login(username: username, password: password) analytics.setUserID("\(user.id)") analytics.userLogin(method: .password) - router.showMainScreen() + router.showMainOrWhatsNewScreen() } catch let error { isShowProgress = false - if let validationError = error.validationError, - let value = validationError.data?["error_description"] as? String { + if error.isUpdateRequeiredError { + router.showUpdateRequiredView(showAccountLink: false) + } else if let validationError = error.validationError, + let value = validationError.data?["error_description"] as? String { errorMessage = value } else if case APIError.invalidGrant = error { errorMessage = CoreLocalization.Error.invalidCredentials diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index a2142684f..3cc1e16fd 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -71,6 +71,8 @@ public class SignUpViewModel: ObservableObject { isShowProgress = false if error.isInternetError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else if error.isUpdateRequeiredError { + router.showUpdateRequiredView(showAccountLink: false) } else { errorMessage = CoreLocalization.Error.unknownError } @@ -93,7 +95,7 @@ public class SignUpViewModel: ObservableObject { analytics.setUserID("\(user.id)") analytics.registrationSuccess() isShowProgress = false - router.showMainScreen() + router.showMainOrWhatsNewScreen() } catch let error { isShowProgress = false diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index ddb4ac259..2afc95ada 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT @@ -731,6 +731,12 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { + open func showUpdateRequiredView(showAccountLink: Bool) { + addInvocation(.m_showUpdateRequiredView__showAccountLink_showAccountLink(Parameter.value(`showAccountLink`))) + let perform = methodPerformValue(.m_showUpdateRequiredView__showAccountLink_showAccountLink(Parameter.value(`showAccountLink`))) as? (Bool) -> Void + perform?(`showAccountLink`) + } + open func backToRoot(animated: Bool) { addInvocation(.m_backToRoot__animated_animated(Parameter.value(`animated`))) let perform = methodPerformValue(.m_backToRoot__animated_animated(Parameter.value(`animated`))) as? (Bool) -> Void @@ -761,9 +767,9 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -811,12 +817,13 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { fileprivate enum MethodType { + case m_showUpdateRequiredView__showAccountLink_showAccountLink(Parameter) case m_backToRoot__animated_animated(Parameter) case m_back__animated_animated(Parameter) case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -827,6 +834,11 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { + case (.m_showUpdateRequiredView__showAccountLink_showAccountLink(let lhsShowaccountlink), .m_showUpdateRequiredView__showAccountLink_showAccountLink(let rhsShowaccountlink)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsShowaccountlink, rhs: rhsShowaccountlink, with: matcher), lhsShowaccountlink, rhsShowaccountlink, "showAccountLink")) + return Matcher.ComparisonResult(results) + case (.m_backToRoot__animated_animated(let lhsAnimated), .m_backToRoot__animated_animated(let rhsAnimated)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAnimated, rhs: rhsAnimated, with: matcher), lhsAnimated, rhsAnimated, "animated")) @@ -849,7 +861,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -896,12 +908,13 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { func intValue() -> Int { switch self { + case let .m_showUpdateRequiredView__showAccountLink_showAccountLink(p0): return p0.intValue case let .m_backToRoot__animated_animated(p0): return p0.intValue case let .m_back__animated_animated(p0): return p0.intValue case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -913,12 +926,13 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { } func assertionName() -> String { switch self { + case .m_showUpdateRequiredView__showAccountLink_showAccountLink: return ".showUpdateRequiredView(showAccountLink:)" case .m_backToRoot__animated_animated: return ".backToRoot(animated:)" case .m_back__animated_animated: return ".back(animated:)" case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -944,12 +958,13 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public struct Verify { fileprivate var method: MethodType + public static func showUpdateRequiredView(showAccountLink: Parameter) -> Verify { return Verify(method: .m_showUpdateRequiredView__showAccountLink_showAccountLink(`showAccountLink`))} public static func backToRoot(animated: Parameter) -> Verify { return Verify(method: .m_backToRoot__animated_animated(`animated`))} public static func back(animated: Parameter) -> Verify { return Verify(method: .m_back__animated_animated(`animated`))} public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -963,6 +978,9 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { fileprivate var method: MethodType var performs: Any + public static func showUpdateRequiredView(showAccountLink: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_showUpdateRequiredView__showAccountLink_showAccountLink(`showAccountLink`), performs: perform) + } public static func backToRoot(animated: Parameter, perform: @escaping (Bool) -> Void) -> Perform { return Perform(method: .m_backToRoot__animated_animated(`animated`), performs: perform) } @@ -978,8 +996,8 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) @@ -1151,9 +1169,9 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -1206,7 +1224,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -1239,7 +1257,7 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -1291,7 +1309,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -1308,7 +1326,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -1339,7 +1357,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -1368,8 +1386,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) diff --git a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift index d478f0f68..036da9d10 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift @@ -29,7 +29,8 @@ final class SignInViewModelTests: XCTestCase { let analytics = AuthorizationAnalyticsMock() let viewModel = SignInViewModel( interactor: interactor, - router: router, + router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -37,7 +38,7 @@ final class SignInViewModelTests: XCTestCase { await viewModel.login(username: "email", password: "") Verify(interactor, 0, .login(username: .any, password: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, AuthLocalization.Error.invalidEmailAddress) XCTAssertEqual(viewModel.isShowProgress, false) @@ -51,13 +52,14 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) await viewModel.login(username: "edxUser@edx.com", password: "") Verify(interactor, 0, .login(username: .any, password: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, AuthLocalization.Error.invalidPasswordLenght) XCTAssertEqual(viewModel.isShowProgress, false) @@ -71,6 +73,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -82,7 +85,7 @@ final class SignInViewModelTests: XCTestCase { Verify(interactor, 1, .login(username: .any, password: .any)) Verify(analytics, .userLogin(method: .any)) - Verify(router, 1, .showMainScreen()) + Verify(router, 1, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, nil) XCTAssertEqual(viewModel.isShowProgress, true) @@ -96,6 +99,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -109,7 +113,7 @@ final class SignInViewModelTests: XCTestCase { await viewModel.login(username: "edxUser@edx.com", password: "password123") Verify(interactor, 1, .login(username: .any, password: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, validationErrorMessage) XCTAssertEqual(viewModel.isShowProgress, false) @@ -123,6 +127,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -132,7 +137,7 @@ final class SignInViewModelTests: XCTestCase { await viewModel.login(username: "edxUser@edx.com", password: "password123") Verify(interactor, 1, .login(username: .any, password: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.invalidCredentials) XCTAssertEqual(viewModel.isShowProgress, false) @@ -146,6 +151,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -155,7 +161,7 @@ final class SignInViewModelTests: XCTestCase { await viewModel.login(username: "edxUser@edx.com", password: "password123") Verify(interactor, 1, .login(username: .any, password: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) XCTAssertEqual(viewModel.isShowProgress, false) @@ -169,6 +175,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -180,7 +187,7 @@ final class SignInViewModelTests: XCTestCase { await viewModel.login(username: "edxUser@edx.com", password: "password123") Verify(interactor, 1, .login(username: .any, password: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) XCTAssertEqual(viewModel.isShowProgress, false) @@ -194,6 +201,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -211,6 +219,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) diff --git a/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift index 8699b79fc..b59519b27 100644 --- a/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift @@ -128,7 +128,7 @@ final class SignUpViewModelTests: XCTestCase { Verify(interactor, 1, .validateRegistrationFields(fields: .any)) Verify(interactor, 1, .registerUser(fields: .any)) - Verify(router, 1, .showMainScreen()) + Verify(router, 1, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.isShowProgress, false) XCTAssertEqual(viewModel.showError, false) @@ -164,7 +164,7 @@ final class SignUpViewModelTests: XCTestCase { Verify(interactor, 1, .validateRegistrationFields(fields: .any)) Verify(interactor, 0, .registerUser(fields: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.isShowProgress, false) XCTAssertEqual(viewModel.showError, false) @@ -192,7 +192,7 @@ final class SignUpViewModelTests: XCTestCase { Verify(interactor, 1, .validateRegistrationFields(fields: .any)) Verify(interactor, 1, .registerUser(fields: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.isShowProgress, false) XCTAssertEqual(viewModel.showError, true) @@ -220,7 +220,7 @@ final class SignUpViewModelTests: XCTestCase { Verify(interactor, 1, .validateRegistrationFields(fields: .any)) Verify(interactor, 1, .registerUser(fields: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.isShowProgress, false) XCTAssertEqual(viewModel.showError, true) @@ -250,7 +250,7 @@ final class SignUpViewModelTests: XCTestCase { Verify(interactor, 1, .validateRegistrationFields(fields: .any)) Verify(interactor, 1, .registerUser(fields: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.isShowProgress, false) XCTAssertEqual(viewModel.showError, true) diff --git a/Authorization/Mockfile b/Authorization/Mockfile index da0f47ddb..5e0805e28 100644 --- a/Authorization/Mockfile +++ b/Authorization/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Core/Core.xcodeproj.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 72% rename from OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to Core/Core.xcodeproj.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist index 0c67376eb..18d981003 100644 --- a/OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ b/Core/Core.xcodeproj.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -1,5 +1,8 @@ - + + IDEDidComputeMac32BitWarning + + diff --git a/Core/Core.xcodeproj.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Core/Core.xcodeproj.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..7dde19a07 --- /dev/null +++ b/Core/Core.xcodeproj.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "youtubeplayerkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SvenTiigi/YouTubePlayerKit", + "state" : { + "revision" : "1fe4c8b07a61d50c2fd276e1d9c8087583c7638a", + "version" : "1.5.3" + } + } + ], + "version" : 2 +} diff --git a/Core/Core.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Core/Core.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Core/Core.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..7dde19a07 --- /dev/null +++ b/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "youtubeplayerkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SvenTiigi/YouTubePlayerKit", + "state" : { + "revision" : "1fe4c8b07a61d50c2fd276e1d9c8087583c7638a", + "version" : "1.5.3" + } + } + ], + "version" : 2 +} diff --git a/Core/Core/Assets.xcassets/warning_filled.imageset/Contents.json b/Core/Core/Assets.xcassets/warning_filled.imageset/Contents.json new file mode 100644 index 000000000..1f83277d1 --- /dev/null +++ b/Core/Core/Assets.xcassets/warning_filled.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "warning_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/warning_filled.imageset/warning_filled.svg b/Core/Core/Assets.xcassets/warning_filled.imageset/warning_filled.svg new file mode 100644 index 000000000..3ff3fdfec --- /dev/null +++ b/Core/Core/Assets.xcassets/warning_filled.imageset/warning_filled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Core/Core/Configuration/BaseRouter.swift b/Core/Core/Configuration/BaseRouter.swift index c86b90f62..034855d87 100644 --- a/Core/Core/Configuration/BaseRouter.swift +++ b/Core/Core/Configuration/BaseRouter.swift @@ -21,7 +21,7 @@ public protocol BaseRouter { func removeLastView(controllers: Int) - func showMainScreen() + func showMainOrWhatsNewScreen() func showLoginScreen() @@ -73,7 +73,7 @@ open class BaseRouterMock: BaseRouter { public func dismiss(animated: Bool) {} - public func showMainScreen() {} + public func showMainOrWhatsNewScreen() {} public func showLoginScreen() {} diff --git a/Core/Core/Configuration/Config.swift b/Core/Core/Configuration/Config.swift index 7f49fac57..8263f5c16 100644 --- a/Core/Core/Configuration/Config.swift +++ b/Core/Core/Configuration/Config.swift @@ -11,6 +11,7 @@ public class Config { public let baseURL: URL public let oAuthClientId: String + public let tokenType: TokenType = .jwt public lazy var termsOfUse: URL? = { URL(string: "\(baseURL.description)/tos") @@ -22,6 +23,12 @@ public class Config { public let feedbackEmail = "support@example.com" + private let appStoreId = "0000000000" + public var appStoreLink: String { + "itms-apps://itunes.apple.com/app/id\(appStoreId)?mt=8" + } + public let whatsNewEnabled: Bool = false + public init(baseURL: String, oAuthClientId: String) { guard let url = URL(string: baseURL) else { fatalError("Ivalid baseURL") @@ -31,6 +38,13 @@ public class Config { } } +public extension Config { + enum TokenType: String { + case jwt = "JWT" + case bearer = "BEARER" + } +} + // Mark - For testing and SwiftUI preview #if DEBUG public class ConfigMock: Config { diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift index 4ff71e963..3fe13dc7e 100644 --- a/Core/Core/Data/CoreStorage.swift +++ b/Core/Core/Data/CoreStorage.swift @@ -15,3 +15,14 @@ public protocol CoreStorage { var userSettings: UserSettings? {get set} func clear() } + +public struct CoreStorageMock: CoreStorage { + public var accessToken: String? = nil + public var refreshToken: String? = nil + public var cookiesDate: String? = nil + public var user: DataLayer.User? = nil + public var userSettings: UserSettings? = nil + public func clear() {} + + public init() {} +} diff --git a/Core/Core/Data/Model/UserSettings.swift b/Core/Core/Data/Model/UserSettings.swift index e709fca00..b44c30449 100644 --- a/Core/Core/Data/Model/UserSettings.swift +++ b/Core/Core/Data/Model/UserSettings.swift @@ -9,15 +9,15 @@ import Foundation public struct UserSettings: Codable { public var wifiOnly: Bool - public var downloadQuality: VideoQuality + public var streamingQuality: StreamingQuality - public init(wifiOnly: Bool, downloadQuality: VideoQuality) { + public init(wifiOnly: Bool, streamingQuality: StreamingQuality) { self.wifiOnly = wifiOnly - self.downloadQuality = downloadQuality + self.streamingQuality = streamingQuality } } -public enum VideoQuality: Codable { +public enum StreamingQuality: Codable { case auto case low case medium diff --git a/Core/Core/Data/Repository/AuthRepository.swift b/Core/Core/Data/Repository/AuthRepository.swift index 1ed62fd68..874159e4c 100644 --- a/Core/Core/Data/Repository/AuthRepository.swift +++ b/Core/Core/Data/Repository/AuthRepository.swift @@ -33,7 +33,8 @@ public class AuthRepository: AuthRepositoryProtocol { let endPoint = AuthEndpoint.getAccessToken( username: username, password: password, - clientId: config.oAuthClientId + clientId: config.oAuthClientId, + tokenType: config.tokenType.rawValue ) let authResponse = try await api.requestData(endPoint).mapResponse(DataLayer.AuthResponse.self) guard let accessToken = authResponse.accessToken, diff --git a/Core/Core/Domain/Model/UserProfile.swift b/Core/Core/Domain/Model/UserProfile.swift index 0d8ca7e2c..03b19990a 100644 --- a/Core/Core/Domain/Model/UserProfile.swift +++ b/Core/Core/Domain/Model/UserProfile.swift @@ -39,4 +39,16 @@ public struct UserProfile: Hashable { self.shortBiography = shortBiography self.isFullProfile = isFullProfile } + + public init() { + self.avatarUrl = "" + self.name = "" + self.username = "" + self.dateJoined = Date() + self.yearOfBirth = 0 + self.country = "" + self.spokenLanguage = "" + self.shortBiography = "" + self.isFullProfile = true + } } diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index 059b54cc2..362a3dc33 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -69,6 +69,7 @@ public enum DateStringStyle { case monthYear case lastPost case iso8601 + case shortWeekdayMonthDayYear } public extension Date { @@ -102,6 +103,8 @@ public extension Date { dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmDdYyyy case .iso8601: dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + case .shortWeekdayMonthDayYear: + dateFormatter.dateFormat = "EEE, MMM d, yyyy" } let date = dateFormatter.string(from: self) @@ -130,6 +133,8 @@ public extension Date { } case .iso8601: return date + case .shortWeekdayMonthDayYear: + return date } } } diff --git a/Core/Core/Extensions/Notification.swift b/Core/Core/Extensions/Notification.swift index fd12e5aab..1a745398a 100644 --- a/Core/Core/Extensions/Notification.swift +++ b/Core/Core/Extensions/Notification.swift @@ -10,4 +10,7 @@ import Foundation public extension Notification.Name { static let onCourseEnrolled = Notification.Name("onCourseEnrolled") static let onTokenRefreshFailed = Notification.Name("onTokenRefreshFailed") + static let onActualVersionReceived = Notification.Name("onActualVersionReceived") + static let onAppUpgradeAccountSettingsTapped = Notification.Name("onAppUpgradeAccountSettingsTapped") + static let onNewVersionAvaliable = Notification.Name("onNewVersionAvaliable") } diff --git a/Core/Core/Network/API.swift b/Core/Core/Network/API.swift index d891c7af5..1142d85ce 100644 --- a/Core/Core/Network/API.swift +++ b/Core/Core/Network/API.swift @@ -65,13 +65,25 @@ public final class API { if !route.path.isEmpty { url = url.appendingPathComponent(route.path) } - return try await session.request( + + let result = session.request( url, method: route.httpMethod, parameters: parameters, encoding: encoding, headers: route.headers - ).validateResponse().serializingData().value + ).validateResponse().serializingData() + + let latestVersion = await result.response.response?.headers["EDX-APP-LATEST-VERSION"] + + if await result.response.response?.statusCode != 426 { + if let latestVersion = latestVersion { + NotificationCenter.default.post(name: .onActualVersionReceived, object: latestVersion) + } + } + + return try await result.value + } private func callCookies( diff --git a/Core/Core/Network/Alamofire+Error.swift b/Core/Core/Network/Alamofire+Error.swift index 8984b36df..277a79fed 100644 --- a/Core/Core/Network/Alamofire+Error.swift +++ b/Core/Core/Network/Alamofire+Error.swift @@ -8,6 +8,10 @@ import Alamofire public extension Error { + var isUpdateRequeiredError: Bool { + self.asAFError?.responseCode == 426 + } + var isInternetError: Bool { guard let afError = self.asAFError, let urlError = afError.underlyingError as? URLError else { diff --git a/Core/Core/Network/AuthEndpoint.swift b/Core/Core/Network/AuthEndpoint.swift index c477c451c..d92d139c0 100644 --- a/Core/Core/Network/AuthEndpoint.swift +++ b/Core/Core/Network/AuthEndpoint.swift @@ -9,7 +9,7 @@ import Foundation import Alamofire enum AuthEndpoint: EndPointType { - case getAccessToken(username: String, password: String, clientId: String) + case getAccessToken(username: String, password: String, clientId: String, tokenType: String) case getUserInfo case getAuthCookies case getRegisterFields @@ -61,12 +61,14 @@ enum AuthEndpoint: EndPointType { var task: HTTPTask { switch self { - case let .getAccessToken(username, password, clientId): + case let .getAccessToken(username, password, clientId, tokenType): let params: [String: Encodable] = [ "grant_type": Constants.GrantTypePassword, "client_id": clientId, "username": username, - "password": password + "password": password, + "token_type": tokenType, + "asymmetric_jwt": true ] return .requestParameters(parameters: params, encoding: URLEncoding.httpBody) case .getUserInfo: diff --git a/Core/Core/Network/RequestInterceptor.swift b/Core/Core/Network/RequestInterceptor.swift index cecdf0570..c2b0b00fd 100644 --- a/Core/Core/Network/RequestInterceptor.swift +++ b/Core/Core/Network/RequestInterceptor.swift @@ -35,9 +35,26 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { // Set the Authorization header value using the access token. if let token = storage.accessToken { - urlRequest.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") + urlRequest.setValue("\(config.tokenType.rawValue) \(token)", forHTTPHeaderField: "Authorization") } + let userAgent: String = { + if let info = Bundle.main.infoDictionary { + let executable: AnyObject = info[kCFBundleExecutableKey as String] as AnyObject? ?? "Unknown" as AnyObject + let bundle: AnyObject = info[kCFBundleIdentifierKey as String] as AnyObject? ?? "Unknown" as AnyObject + let version: AnyObject = info["CFBundleShortVersionString"] as AnyObject? ?? "Unknown" as AnyObject + let os: AnyObject = ProcessInfo.processInfo.operatingSystemVersionString as AnyObject + var mutableUserAgent = NSMutableString(string: "\(executable)/\(bundle) (\(version); OS \(os))") as CFMutableString + let transform = NSString(string: "Any-Latin; Latin-ASCII; [:^ASCII:] Remove") as CFString + if CFStringTransform(mutableUserAgent, nil, transform, false) == true { + return mutableUserAgent as String + } + } + return "Alamofire" + }() + + urlRequest.setValue(userAgent, forHTTPHeaderField: "User-Agent") + completion(.success(urlRequest)) } @@ -84,49 +101,52 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { private func refreshToken( refreshToken: String, - completion: @escaping (_ succeeded: Bool) -> Void) { - guard !isRefreshing else { return } - - isRefreshing = true - - let url = config.baseURL.appendingPathComponent("/oauth2/access_token") - - let parameters = [ - "grant_type": Constants.GrantTypeRefreshToken, - "client_id": config.oAuthClientId, - "refresh_token": refreshToken - ] - AF.request( - url, - method: .post, - parameters: parameters, - encoding: URLEncoding.httpBody - ).response { [weak self] response in - guard let self = self else { return } - switch response.result { - case let .success(data): - do { - let json = try JSONSerialization.jsonObject( - with: data!, - options: .mutableContainers - ) as? [String: AnyObject] - guard let json, - let accessToken = json["access_token"] as? String, - let refreshToken = json["refresh_token"] as? String, - accessToken.count > 0, - refreshToken.count > 0 else { - return completion(false) - } - self.storage.accessToken = accessToken - self.storage.refreshToken = refreshToken - completion(true) - } catch { - completion(false) + completion: @escaping (_ succeeded: Bool) -> Void + ) { + guard !isRefreshing else { return } + + isRefreshing = true + + let url = config.baseURL.appendingPathComponent("/oauth2/access_token") + + let parameters: [String: Encodable] = [ + "grant_type": Constants.GrantTypeRefreshToken, + "client_id": config.oAuthClientId, + "refresh_token": refreshToken, + "token_type": config.tokenType.rawValue, + "asymmetric_jwt": true + ] + AF.request( + url, + method: .post, + parameters: parameters, + encoding: URLEncoding.httpBody + ).response { [weak self] response in + guard let self = self else { return } + switch response.result { + case let .success(data): + do { + let json = try JSONSerialization.jsonObject( + with: data!, + options: .mutableContainers + ) as? [String: AnyObject] + guard let json, + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String, + accessToken.count > 0, + refreshToken.count > 0 else { + return completion(false) } - case .failure: + self.storage.accessToken = accessToken + self.storage.refreshToken = refreshToken + completion(true) + } catch { completion(false) } - self.isRefreshing = false + case .failure: + completion(false) } + self.isRefreshing = false } + } } diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index ee82da098..61c33b049 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -108,6 +108,7 @@ public enum CoreAssets { public static let noCourseImage = ImageAsset(name: "noCourseImage") public static let notAvaliable = ImageAsset(name: "notAvaliable") public static let playVideo = ImageAsset(name: "playVideo") + public static let warningFilled = ImageAsset(name: "warning_filled") } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index 0197b0494..41a0fcb7d 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -54,6 +54,20 @@ public enum CoreLocalization { /// Section “ public static let section = CoreLocalization.tr("Localizable", "COURSEWARE.SECTION", fallback: "Section “") } + public enum CourseDates { + /// Completed + public static let completed = CoreLocalization.tr("Localizable", "COURSE_DATES.COMPLETED", fallback: "Completed") + /// Due next + public static let dueNext = CoreLocalization.tr("Localizable", "COURSE_DATES.DUE_NEXT", fallback: "Due next") + /// Past due + public static let pastDue = CoreLocalization.tr("Localizable", "COURSE_DATES.PAST_DUE", fallback: "Past due") + /// Today + public static let today = CoreLocalization.tr("Localizable", "COURSE_DATES.TODAY", fallback: "Today") + /// Unreleased + public static let unreleased = CoreLocalization.tr("Localizable", "COURSE_DATES.UNRELEASED", fallback: "Unreleased") + /// Verified Only + public static let verifiedOnly = CoreLocalization.tr("Localizable", "COURSE_DATES.VERIFIED_ONLY", fallback: "Verified Only") + } public enum Date { /// Ended public static let ended = CoreLocalization.tr("Localizable", "DATE.ENDED", fallback: "Ended") diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index f16f781dc..6c055ab25 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -68,3 +68,10 @@ "WEBVIEW.ALERT.OK" = "Ok"; "WEBVIEW.ALERT.CANCEL" = "Cancel"; + +"COURSE_DATES.TODAY" = "Today"; +"COURSE_DATES.COMPLETED" = "Completed"; +"COURSE_DATES.PAST_DUE" = "Past due"; +"COURSE_DATES.DUE_NEXT" = "Due next"; +"COURSE_DATES.UNRELEASED" = "Unreleased"; +"COURSE_DATES.VERIFIED_ONLY" = "Verified Only"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index e06937311..40ff490a3 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -68,3 +68,10 @@ "WEBVIEW.ALERT.OK" = "Так"; "WEBVIEW.ALERT.CANCEL" = "Скасувати"; + +"COURSE_DATES.TODAY" = "Today"; +"COURSE_DATES.COMPLETED" = "Completed"; +"COURSE_DATES.PAST_DUE" = "Past due"; +"COURSE_DATES.DUE_NEXT" = "Due next"; +"COURSE_DATES.UNRELEASED" = "Unreleased"; +"COURSE_DATES.VERIFIED_ONLY" = "Verified Only"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index c80d63032..16f57b0fb 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -64,6 +64,11 @@ 0766DFD0299AB29000EBEF6A /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0766DFCF299AB29000EBEF6A /* PlayerViewController.swift */; }; 197FB8EA8F92F00A8F383D82 /* Pods_App_Course.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E795BD160CDA7D9C120DE6 /* Pods_App_Course.framework */; }; B8F50317B6B830A0E520C954 /* Pods_App_Course_CourseTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50E59D2B81E12610964282C5 /* Pods_App_Course_CourseTests.framework */; }; + DB205BFB2AE81B1200136EC2 /* CourseDateViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */; }; + DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */; }; + DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */; }; + DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */; }; + DB7D6EB22ADFE9510036BB13 /* Data_CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -150,6 +155,11 @@ A47C63D9EB0D866F303D4588 /* Pods-App-Course.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Course/Pods-App-Course.releasestage.xcconfig"; sourceTree = ""; }; ADC2A1B8183A674705F5F7E2 /* Pods-App-Course.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course.debug.xcconfig"; path = "Target Support Files/Pods-App-Course/Pods-App-Course.debug.xcconfig"; sourceTree = ""; }; B196A14555D0E006995A5683 /* Pods-App-CourseDetails.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-CourseDetails.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-CourseDetails/Pods-App-CourseDetails.releaseprod.xcconfig"; sourceTree = ""; }; + DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDateViewModelTests.swift; sourceTree = ""; }; + DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseDatesView.swift; sourceTree = ""; }; + DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDatesViewModel.swift; sourceTree = ""; }; + DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDates.swift; sourceTree = ""; }; + DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDates.swift; sourceTree = ""; }; DBE05972CB5115D4535C6B8A /* Pods-App-Course-CourseTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course-CourseTests.debug.xcconfig"; path = "Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests.debug.xcconfig"; sourceTree = ""; }; E5E795BD160CDA7D9C120DE6 /* Pods_App_Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E6BDAE887ED8A46860B3F6D3 /* Pods-App-Course-CourseTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course-CourseTests.release.xcconfig"; path = "Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests.release.xcconfig"; sourceTree = ""; }; @@ -291,6 +301,7 @@ 027020FB28E7362100F54332 /* Data_CourseOutlineResponse.swift */, 022C64DB29ACFDEE000F532B /* Data_HandoutsResponse.swift */, 022C64DF29ADEA9B000F532B /* Data_UpdatesResponse.swift */, + DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */, ); path = Model; sourceTree = ""; @@ -319,6 +330,7 @@ 02EAE2CA28E1F0A700529644 /* Presentation */ = { isa = PBXGroup; children = ( + DB7D6EAA2ADFCAA00036BB13 /* Dates */, 070019A828F6F33600D5FC78 /* Container */, 070019A628F6F2CB00D5FC78 /* Details */, 070019A728F6F2D600D5FC78 /* Outline */, @@ -337,6 +349,7 @@ 02B6B3C228E1DCD100232911 /* CourseDetails.swift */, 0276D75C29DDA3F80004CDF8 /* ResumeBlock.swift */, 022C64E129ADEB83000F532B /* CourseUpdate.swift */, + DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */, ); path = Model; sourceTree = ""; @@ -428,6 +441,7 @@ children = ( 0262148E29AE17C4008BD75A /* HandoutsViewModelTests.swift */, 0295B1D8297E6DF8003B0C65 /* CourseUnitViewModelTests.swift */, + DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */, ); path = Unit; sourceTree = ""; @@ -462,6 +476,15 @@ path = ../Pods; sourceTree = ""; }; + DB7D6EAA2ADFCAA00036BB13 /* Dates */ = { + isa = PBXGroup; + children = ( + DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */, + DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */, + ); + path = Dates; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -664,6 +687,7 @@ 02F78AEB29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift in Sources */, 023812E7297AC8EB0087098F /* CourseDetailsViewModelTests.swift in Sources */, 023812F3297AC9ED0087098F /* CourseMock.generated.swift in Sources */, + DB205BFB2AE81B1200136EC2 /* CourseDateViewModelTests.swift in Sources */, 022EA8CB297AD63B0014A8F7 /* CourseContainerViewModelTests.swift in Sources */, 0262148F29AE17C4008BD75A /* HandoutsViewModelTests.swift in Sources */, ); @@ -685,6 +709,7 @@ 02B6B3BC28E1D14F00232911 /* CourseRepository.swift in Sources */, 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */, 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */, + DB7D6EB22ADFE9510036BB13 /* Data_CourseDates.swift in Sources */, 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */, 0766DFCE299AB26D00EBEF6A /* EncodedVideoPlayer.swift in Sources */, 0276D75B29DDA3890004CDF8 /* Data_ResumeBlock.swift in Sources */, @@ -700,8 +725,10 @@ 02A8076829474831007F53AB /* CourseVerticalView.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, + DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */, 02E685C028E4B629000AE015 /* CourseDetailsViewModel.swift in Sources */, 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */, + DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */, 02F066E829DC71750073E13B /* SubtittlesView.swift in Sources */, 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */, 02454CA62A26196C0043052A /* UnknownView.swift in Sources */, @@ -711,6 +738,7 @@ 02454CA82A2619890043052A /* DiscussionView.swift in Sources */, 0265B4B728E2141D00E6EAFD /* Strings.swift in Sources */, 02B6B3C128E1DBA100232911 /* Data_CourseDetailsResponse.swift in Sources */, + DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */, 0766DFCC299AA7A600EBEF6A /* YouTubeVideoPlayer.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02E685BE28E4B60A000AE015 /* CourseDetailsView.swift in Sources */, diff --git a/Course/Course.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Course/Course.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Course/Course.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Course/Course.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Course/Course.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Course/Course.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 68e833255..e07f75bb8 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -19,6 +19,8 @@ public protocol CourseRepositoryProtocol { func getUpdates(courseID: String) async throws -> [CourseUpdate] func resumeBlock(courseID: String) async throws -> ResumeBlock func getSubtitles(url: String, selectedLanguage: String) async throws -> String + func getCourseDates(courseID: String) async throws -> CourseDates + func getCourseDatesOffline(courseID: String) async throws -> CourseDates } public class CourseRepository: CourseRepositoryProtocol { @@ -112,6 +114,18 @@ public class CourseRepository: CourseRepositoryProtocol { } } + public func getCourseDates(courseID: String) async throws -> CourseDates { + let courseDates = try await api.requestData( + CourseEndpoint.getCourseDates(courseID: courseID) + ).mapResponse(DataLayer.CourseDates.self).domain + persistence.saveCourseDates(courseID: courseID, courseDates: courseDates) + return courseDates + } + + public func getCourseDatesOffline(courseID: String) async throws -> CourseDates { + return try persistence.loadCourseDates(courseID: courseID) + } + private func parseCourseStructure(course: DataLayer.CourseStructure) -> CourseStructure { let blocks = Array(course.dict.values) let courseBlock = blocks.first(where: {$0.type == BlockType.course.rawValue })! @@ -220,6 +234,10 @@ public class CourseRepository: CourseRepositoryProtocol { // swiftlint:disable all #if DEBUG class CourseRepositoryMock: CourseRepositoryProtocol { + func getCourseDatesOffline(courseID: String) async throws -> CourseDates { + throw NoCachedDataError() + } + func resumeBlock(courseID: String) async throws -> ResumeBlock { ResumeBlock(blockID: "123") } @@ -232,6 +250,14 @@ class CourseRepositoryMock: CourseRepositoryProtocol { return [CourseUpdate(id: 1, date: "Date", content: "content", status: "status")] } + func getCourseDates(courseID: String) async throws -> CourseDates { + do { + let courseDates = try courseDatesJSON.data(using: .utf8)!.mapResponse(DataLayer.CourseDates.self) + return courseDates.domain + } catch { + throw error + } + } func getCourseDetailsOffline(courseID: String) async throws -> CourseDetails { return CourseDetails( @@ -1034,6 +1060,373 @@ And there are various ways of describing it-- call it oral poetry or "is_self_paced": false } """ + + private let courseDatesJSON: String = """ + { + "dates_banner_info": { + "missed_deadlines": false, + "content_type_gating_enabled": true, + "missed_gated_content": false, + "verified_upgrade_link": null + }, + "course_date_blocks": [ + { + "assignment_type": null, + "complete": null, + "date": "2023-08-30T15:00:00Z", + "date_type": "course-start-date", + "description": "", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Course starts", + "extra_info": null, + "first_component_block_id": "" + }, + { + "assignment_type": "Problem Set", + "complete": true, + "date": "2023-09-14T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@ca19e125470846f2a36ad1225410e39a", + "link_text": "", + "title": "Problem Set 1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-14T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Problem Set 1.1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530a" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-21T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ecec", + "link_text": "", + "title": "Problem Set 2", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abd" + }, + { + "assignment_type": "Problem Set", + "complete": true, + "date": "2023-09-21T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ececc", + "link_text": "", + "title": "Problem Set 2.1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abdc" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-21T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": false, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ececcc", + "link_text": "", + "title": "Problem Set 2.2", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abdcc" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-28T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@bfe9eb02884a4812883ff9e543887968", + "link_text": "", + "title": "Problem Set 3", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@5e117d71433647eaa6de63434641c011" + }, + { + "assignment_type": "Midterm", + "complete": false, + "date": "2023-10-04T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": false, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@bb284b9c4ff04091951f77b50e3b72f4", + "link_text": "", + "title": "Midterm Exam (time limit removed due to grader issues)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@vertical+block@ec1c5d83de6244d38b1f3ff4d32b6e17" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-10-12T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@64f4d344ecdc48d2bef514882e6236ab", + "link_text": "", + "title": "Problem Set 4", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@eeb64a67e52e4f3e80656b9233204f25" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-10-19T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@79d22d4ab4f740158930fca4e80d67db", + "link_text": "", + "title": "Problem Set 5", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@3dde572871fc4b6ebdb47722a184a514" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-10-26T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@3d419098708e4bcd9209ffa31a4cb3dc", + "link_text": "", + "title": "Problem Set 6", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@9b2a0176bf6a4c21ad4a63c2fce2d0cb" + }, + { + "assignment_type": "Final Exam", + "complete": false, + "date": "2023-10-31T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": false, + "link": "", + "link_text": "", + "title": "Final Exam (time limit removed due to grader issues)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@vertical+block@e7b4f091d7ad457097d0bbda9d9af267" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@221a4c17dba341d6a970a0d80343253c", + "link_text": "", + "title": "1. Introduction to Python (TIME: 1:03:12)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@ad9387910b7e47069c452efebd7b36dd" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@35f82f6c3ecb4e9e913dc279a9b73a9f", + "link_text": "", + "title": "2. Core Elements of Programs (TIME: 54:14)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@8fb4fa767a204d41a6366c2bc53bea22" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@62f08cc899344863a1ab678aee506dec", + "link_text": "", + "title": "3. Simple Algorithms (TIME: 41:06)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@1f2b055948c9467492649b59e24e8fdc" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@38007cdb67c44b46b124cdbce33510b5", + "link_text": "", + "title": "4. Functions (TIME: 1:08:06)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@9dc4c11c46274b87964c7534b449d50a" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@01df98c1e74a459b8fb20d2d785622cd", + "link_text": "", + "title": "5. Tuples and Lists", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@3464df78190b43948ba0507ef4287290" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@8a590293a22e46dd9760ec917d122ec1", + "link_text": "", + "title": "6. Dictionaries", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@d2abc5b3db0d43ba90c5d3a25e95e2d5" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@78648402e8bf4738ade97101cc1ba263", + "link_text": "", + "title": "7. Testing and Debugging", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@dd0621fbfe594e789b187a1e4f8406eb" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@c81c3de20ec54c37a04a8b3d1806e82c", + "link_text": "", + "title": "8. Exceptions and Assertions", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@6038a1b2f8a340eb8cdb41c021d62234" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@37cb9a5012e443bbaa776a80afd9c87a", + "link_text": "", + "title": "9. Classes and Inheritance", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@b87e596b827142f09e9664fac3ab0be0" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@54cd6b1bbbbe40f294ac0b5664c03f1e", + "link_text": "", + "title": "10. An Extended Example", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@6bc79b1a29ac46a7857caa53a8e203d0" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@1334ab336b1b4458b5c2972c50e903b2", + "link_text": "", + "title": "11. Computational Complexity", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@be73e5a3ee7847d98805a257189b9fad" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@a7387dbd3728491c8f834e29a73e0cf4", + "link_text": "", + "title": "12. Searching and Sorting Algorithms", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@fa7e29b3b95b4a3b963d1c5dfdd4e8f8" + }, + { + "assignment_type": null, + "complete": null, + "date": "2023-11-01T23:30:00Z", + "date_type": "course-end-date", + "description": "After the course ends, the course content will be archived and no longer active.", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Course ends", + "extra_info": null, + "first_component_block_id": "" + }, + { + "assignment_type": null, + "complete": null, + "date": "2023-11-03T00:00:00Z", + "date_type": "certificate-available-date", + "description": "Day certificates will become available for passing verified learners.", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Certificate Available", + "extra_info": null, + "first_component_block_id": "" + }, + { + "assignment_type": null, + "complete": null, + "date": "2023-11-23T12:34:28Z", + "date_type": "course-expired-date", + "description": "You lose all access to this course, including your progress.", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Audit Access Expires", + "extra_info": null, + "first_component_block_id": "" + } + ], + "has_ended": false, + "learner_is_full_access": false, + "user_timezone": null + } + """ } #endif // swiftlint:enable all diff --git a/Course/Course/Data/Model/Data_CourseDates.swift b/Course/Course/Data/Model/Data_CourseDates.swift new file mode 100644 index 000000000..b78691020 --- /dev/null +++ b/Course/Course/Data/Model/Data_CourseDates.swift @@ -0,0 +1,90 @@ +// +// Data_CourseDates.swift +// Course +// +// Created by Muhammad Umer on 10/18/23. +// + +import Foundation +import Core + +public extension DataLayer { + struct CourseDates: Codable { + let datesBannerInfo: DatesBannerInfo? + let courseDateBlocks: [CourseDateBlock] + let hasEnded, learnerIsFullAccess: Bool + let userTimezone: String? + + enum CodingKeys: String, CodingKey { + case datesBannerInfo = "dates_banner_info" + case courseDateBlocks = "course_date_blocks" + case hasEnded = "has_ended" + case learnerIsFullAccess = "learner_is_full_access" + case userTimezone = "user_timezone" + } + } + + struct CourseDateBlock: Codable { + let assignmentType: String? + let complete: Bool? + let date, dateType, description: String + let learnerHasAccess: Bool + let link, title: String + let linkText: String? + let extraInfo: String? + let firstComponentBlockID: String + + enum CodingKeys: String, CodingKey { + case assignmentType = "assignment_type" + case complete, date + case dateType = "date_type" + case description + case learnerHasAccess = "learner_has_access" + case link + case linkText = "link_text" + case title + case extraInfo = "extra_info" + case firstComponentBlockID = "first_component_block_id" + } + } + + struct DatesBannerInfo: Codable { + let missedDeadlines, contentTypeGatingEnabled, missedGatedContent: Bool + let verifiedUpgradeLink: String? + + enum CodingKeys: String, CodingKey { + case missedDeadlines = "missed_deadlines" + case contentTypeGatingEnabled = "content_type_gating_enabled" + case missedGatedContent = "missed_gated_content" + case verifiedUpgradeLink = "verified_upgrade_link" + } + } +} + +public extension DataLayer.CourseDates { + var domain: CourseDates { + return CourseDates( + datesBannerInfo: DatesBannerInfo( + missedDeadlines: datesBannerInfo?.missedDeadlines ?? false, + contentTypeGatingEnabled: datesBannerInfo?.contentTypeGatingEnabled ?? false, + missedGatedContent: datesBannerInfo?.missedGatedContent ?? false, + verifiedUpgradeLink: datesBannerInfo?.verifiedUpgradeLink), + courseDateBlocks: courseDateBlocks.map { block in + CourseDateBlock( + assignmentType: block.assignmentType, + complete: block.complete, + date: Date(iso8601: block.date), + dateType: block.dateType, + description: block.description, + learnerHasAccess: block.learnerHasAccess, + link: block.link, + linkText: block.linkText ?? nil, + title: block.title, + extraInfo: block.extraInfo, + firstComponentBlockID: block.firstComponentBlockID) + }, + hasEnded: hasEnded, + learnerIsFullAccess: learnerIsFullAccess, + userTimezone: userTimezone) + } +} diff --git a/Course/Course/Data/Model/Data_CourseDetailsResponse.swift b/Course/Course/Data/Model/Data_CourseDetailsResponse.swift index 306ca6f96..1047727e8 100644 --- a/Course/Course/Data/Model/Data_CourseDetailsResponse.swift +++ b/Course/Course/Data/Model/Data_CourseDetailsResponse.swift @@ -22,7 +22,7 @@ public extension DataLayer { public let name: String public let number: String public let org: String - public let shortDescription: String + public let shortDescription: String? public let start: String? public let startDisplay: String? public let startType: String? diff --git a/Course/Course/Data/Network/CourseEndpoint.swift b/Course/Course/Data/Network/CourseEndpoint.swift index 7b3109a9c..63ef3b4e1 100644 --- a/Course/Course/Data/Network/CourseEndpoint.swift +++ b/Course/Course/Data/Network/CourseEndpoint.swift @@ -19,6 +19,7 @@ enum CourseEndpoint: EndPointType { case getUpdates(courseID: String) case resumeBlock(userName: String, courseID: String) case getSubtitles(url: String, selectedLanguage: String) + case getCourseDates(courseID: String) var path: String { switch self { @@ -40,6 +41,8 @@ enum CourseEndpoint: EndPointType { return "/api/mobile/v1/users/\(userName)/course_status_info/\(courseID)" case let .getSubtitles(url, _): return url + case .getCourseDates(courseID: let courseID): + return "/api/course_home/v1/dates/\(courseID)" } } @@ -63,6 +66,8 @@ enum CourseEndpoint: EndPointType { return .get case .getSubtitles: return .get + case .getCourseDates: + return .get } } @@ -112,11 +117,13 @@ enum CourseEndpoint: EndPointType { case .resumeBlock: return .requestParameters(encoding: JSONEncoding.default) case let .getSubtitles(_, subtitleLanguage): -// let languageCode = Locale.current.languageCode ?? "en" + // let languageCode = Locale.current.languageCode ?? "en" let params: [String: Any] = [ "lang": subtitleLanguage ] return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + case .getCourseDates: + return .requestParameters(encoding: JSONEncoding.default) } } } diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index f83d58906..33202dee9 100644 --- a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents +++ b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -19,6 +19,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift b/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift index b17874645..9efb9d435 100644 --- a/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift +++ b/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift @@ -17,9 +17,11 @@ public protocol CoursePersistenceProtocol { func saveCourseStructure(structure: DataLayer.CourseStructure) func saveSubtitles(url: String, subtitlesString: String) func loadSubtitles(url: String) -> String? + func saveCourseDates(courseID: String, courseDates: CourseDates) + func loadCourseDates(courseID: String) throws -> CourseDates } public final class CourseBundle { private init() {} } - \ No newline at end of file + diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index df58f05a9..872b22bde 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -21,6 +21,7 @@ public protocol CourseInteractorProtocol { func getUpdates(courseID: String) async throws -> [CourseUpdate] func resumeBlock(courseID: String) async throws -> ResumeBlock func getSubtitles(url: String, selectedLanguage: String) async throws -> [Subtitle] + func getCourseDates(courseID: String) async throws -> CourseDates } public class CourseInteractor: CourseInteractorProtocol { @@ -94,6 +95,10 @@ public class CourseInteractor: CourseInteractorProtocol { return parseSubtitles(from: result) } + public func getCourseDates(courseID: String) async throws -> CourseDates { + return try await repository.getCourseDates(courseID: courseID) + } + private func filterChapter(chapter: CourseChapter) -> CourseChapter { var newChilds = [CourseSequential]() for sequential in chapter.childs { diff --git a/Course/Course/Domain/Model/CourseDates.swift b/Course/Course/Domain/Model/CourseDates.swift new file mode 100644 index 000000000..0909610d4 --- /dev/null +++ b/Course/Course/Domain/Model/CourseDates.swift @@ -0,0 +1,204 @@ +// +// CourseDates.swift +// Course +// +// Created by Muhammad Umer on 10/18/23. +// + +import Foundation +import Core + +public struct CourseDates { + let datesBannerInfo: DatesBannerInfo + let courseDateBlocks: [CourseDateBlock] + let hasEnded, learnerIsFullAccess: Bool + let userTimezone: String? + + var sortedDateToCourseDateBlockDict: [Date: [CourseDateBlock]] { + var dateToCourseDateBlockDict: [Date: [CourseDateBlock]] = [:] + var hasToday = false + let today = Date.today + + let calendar = Calendar.current + let todayComponents = calendar.dateComponents([.year, .month, .day], from: .today) + + for block in courseDateBlocks { + let date = block.date + let dateComponents = calendar.dateComponents([.year, .month, .day], from: date) + + if dateComponents == todayComponents { + hasToday = true + } + + dateToCourseDateBlockDict[date, default: []].append(block) + } + + if !hasToday { + let todayBlock = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: today, + dateType: "", + description: "", + learnerHasAccess: true, + link: "", linkText: nil, + title: CoreLocalization.CourseDates.today, + extraInfo: nil, + firstComponentBlockID: "uniqueIDForToday") + dateToCourseDateBlockDict[today] = [todayBlock] + } + + return dateToCourseDateBlockDict + } +} + +extension Date { + static var today: Date { + return Calendar.current.startOfDay(for: Date()) + } + + static func compare(_ fromDate: Date, to toDate: Date) -> ComparisonResult { + if fromDate > toDate { + return .orderedDescending + } else if fromDate < toDate { + return .orderedAscending + } + return .orderedSame + } + + var isInPast: Bool { + return Date.compare(self, to: .today) == .orderedAscending + } + + var isToday: Bool { + let calendar = Calendar.current + let selfComponents = calendar.dateComponents([.year, .month, .day], from: self) + let todayComponents = calendar.dateComponents([.year, .month, .day], from: .today) + return selfComponents == todayComponents + } + + var isInFuture: Bool { + return Date.compare(self, to: .today) == .orderedDescending + } +} + +public struct CourseDateBlock: Identifiable { + public let id: UUID = UUID() + + let assignmentType: String? + let complete: Bool? + let date: Date + let dateType, description: String + let learnerHasAccess: Bool + let link: String + let linkText: String? + let title: String + let extraInfo: String? + let firstComponentBlockID: String + + var formattedDate: String { + return date.dateToString(style: .shortWeekdayMonthDayYear) + } + + var isInPast: Bool { + return date.isInPast + } + + var isToday: Bool { + if dateType.isEmpty { + return true + } else { + return date.isToday + } + } + + var isInFuture: Bool { + return date.isInFuture + } + + var isAssignment: Bool { + return BlockStatus.status(of: dateType) == .assignment + } + + var isVerifiedOnly: Bool { + return !learnerHasAccess + } + + var isComplete: Bool { + return complete ?? false + } + + var isLearnerAssignment: Bool { + return learnerHasAccess && isAssignment + } + + var isPastDue: Bool { + return !isComplete && (date < .today) + } + + var isUnreleased: Bool { + return link.isEmpty + } + + var canShowLink: Bool { + return !isUnreleased && isLearnerAssignment + } + + var isAvailable: Bool { + return learnerHasAccess && (!isUnreleased || !isLearnerAssignment) + } + + var blockStatus: BlockStatus { + if isComplete { + return .completed + } + + if !learnerHasAccess { + return .verifiedOnly + } + + if isAssignment { + if isInPast { + return isUnreleased ? .unreleased : .pastDue + } else if isToday || isInFuture { + return isUnreleased ? .unreleased : .dueNext + } + } + + return BlockStatus.status(of: dateType) + } +} + +public struct DatesBannerInfo { + let missedDeadlines, contentTypeGatingEnabled, missedGatedContent: Bool + let verifiedUpgradeLink: String? +} + +public enum BlockStatus { + case completed + case pastDue + case dueNext + case unreleased + case verifiedOnly + case assignment + case verifiedUpgradeDeadline + case courseExpiredDate + case verificationDeadlineDate + case certificateAvailbleDate + case courseStartDate + case courseEndDate + case event + + static func status(of type: String) -> BlockStatus { + switch type { + case "assignment-due-date": return .assignment + case "verified-upgrade-deadline": return .verifiedUpgradeDeadline + case "course-expired-date": return .courseExpiredDate + case "verification-deadline-date": return .verificationDeadlineDate + case "certificate-available-date": return .certificateAvailbleDate + case "course-start-date": return .courseStartDate + case "course-end-date": return .courseEndDate + default: return .event + } + } +} diff --git a/Course/Course/Domain/Model/CourseDetails.swift b/Course/Course/Domain/Model/CourseDetails.swift index 0edb58854..6769aff53 100644 --- a/Course/Course/Domain/Model/CourseDetails.swift +++ b/Course/Course/Domain/Model/CourseDetails.swift @@ -11,7 +11,7 @@ public struct CourseDetails { public let courseID: String public let org: String public let courseTitle: String - public let courseDescription: String + public let courseDescription: String? public let courseStart: Date? public let courseEnd: Date? public let enrollmentStart: Date? @@ -24,7 +24,7 @@ public struct CourseDetails { public init(courseID: String, org: String, courseTitle: String, - courseDescription: String, + courseDescription: String?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index c10cb9643..4cfe2f2ce 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -15,6 +15,7 @@ public struct CourseContainerView: View { enum CourseTab { case course case videos + case dates case discussion case handounds } @@ -74,6 +75,15 @@ public struct CourseContainerView: View { } .tag(CourseTab.videos) + CourseDatesView(courseID: courseID, + viewModel: Container.shared.resolve(CourseDatesViewModel.self, + argument: courseID)!) + .tabItem { + Image(systemName: "calendar").renderingMode(.template) + Text(CourseLocalization.CourseContainer.dates) + } + .tag(CourseTab.dates) + DiscussionTopicsView(courseID: courseID, viewModel: Container.shared.resolve(DiscussionTopicsViewModel.self, argument: title)!, @@ -122,6 +132,8 @@ public struct CourseContainerView: View { return DiscussionLocalization.title case .handounds: return CourseLocalization.CourseContainer.handouts + case .dates: + return CourseLocalization.CourseContainer.dates } } } diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 938c187a0..a3f90b8e9 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -165,6 +165,8 @@ public class CourseContainerViewModel: BaseCourseViewModel { analytics.courseOutlineCourseTabClicked(courseId: courseId, courseName: courseName) case .videos: analytics.courseOutlineVideosTabClicked(courseId: courseId, courseName: courseName) + case .dates: + analytics.courseOutlineDatesTabClicked(courseId: courseId, courseName: courseName) case .discussion: analytics.courseOutlineDiscussionTabClicked(courseId: courseId, courseName: courseName) case .handounds: diff --git a/Course/Course/Presentation/CourseAnalytics.swift b/Course/Course/Presentation/CourseAnalytics.swift index 6ad6e0389..7396438b4 100644 --- a/Course/Course/Presentation/CourseAnalytics.swift +++ b/Course/Course/Presentation/CourseAnalytics.swift @@ -22,6 +22,7 @@ public protocol CourseAnalytics { func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) func courseOutlineCourseTabClicked(courseId: String, courseName: String) func courseOutlineVideosTabClicked(courseId: String, courseName: String) + func courseOutlineDatesTabClicked(courseId: String, courseName: String) func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) } @@ -46,6 +47,7 @@ class CourseAnalyticsMock: CourseAnalytics { public func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) {} public func courseOutlineCourseTabClicked(courseId: String, courseName: String) {} public func courseOutlineVideosTabClicked(courseId: String, courseName: String) {} + public func courseOutlineDatesTabClicked(courseId: String, courseName: String) {} public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) {} public func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) {} } diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift new file mode 100644 index 000000000..f3a7d9d59 --- /dev/null +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -0,0 +1,305 @@ +// +// CourseDatesView.swift +// Discussion +// +// Created by Muhammad Umer on 10/17/23. +// + +import Foundation +import SwiftUI +import Core + +public struct CourseDatesView: View { + + private let courseID: String + + @StateObject + private var viewModel: CourseDatesViewModel + + public init( + courseID: String, + viewModel: CourseDatesViewModel + ) { + self.courseID = courseID + self._viewModel = StateObject(wrappedValue: viewModel) + } + + public var body: some View { + ZStack { + VStack(alignment: .center) { + if viewModel.isShowProgress { + HStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + } + } else if let courseDates = viewModel.courseDates, !courseDates.courseDateBlocks.isEmpty { + CourseDateListView(viewModel: viewModel, courseDates: courseDates) + .padding(.top, 10) + } + } + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } + } + } + .onFirstAppear { + Task { + await viewModel.getCourseDates(courseID: courseID) + } + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct Line: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + return path + } +} + +struct TimeLineView: View { + let block: CourseDateBlock + let date: Date + let firstDate: Date? + let lastDate: Date? + let allHaveSameStatus: Bool + + var body: some View { + ZStack(alignment: .top) { + VStack { + Line() + .stroke(style: StrokeStyle(lineWidth: 1)) + .frame(maxHeight: lastDate == date ? 10 : .infinity, alignment: .top) + .padding(.top, firstDate == date && lastDate != date ? 10 : 0) + + if lastDate == date { + Spacer() + } + } + + Circle() + .frame(width: date.isToday ? 12 : 8, height: date.isToday ? 12 : 8) + .foregroundColor({ + if date.isToday { + return Theme.Colors.warning + } else if date.isInPast { + switch block.blockStatus { + case .completed: return allHaveSameStatus ? Color.white : Color.gray + case .courseStartDate: return Color.white + case .verifiedOnly: return Color.black + case .pastDue: return Color.gray + default: return Color.gray + } + } else if date.isInFuture { + return Color.black + } else { + return Color.white + } + }()) + .overlay(Circle().stroke(Color.black, lineWidth: 1)) + .padding(.top, 5) + } + .frame(width: 16) + } +} + +struct CourseDateListView: View { + @ObservedObject var viewModel: CourseDatesViewModel + var courseDates: CourseDates + + var body: some View { + VStack { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(viewModel.sortedDates, id: \.self) { date in + let blocks = courseDates.sortedDateToCourseDateBlockDict[date]! + let block = blocks[0] + + HStack(alignment: .center) { + let ignoredStatuses: [BlockStatus] = [.courseStartDate, .courseEndDate] + let allHaveSameStatus = blocks + .filter { !ignoredStatuses.contains($0.blockStatus) } + .allSatisfy { $0.blockStatus == block.blockStatus } + + TimeLineView(block: block, date: date, + firstDate: viewModel.sortedDates.first, + lastDate: viewModel.sortedDates.last, + allHaveSameStatus: allHaveSameStatus) + + BlockStatusView(block: block, + allHaveSameStatus: allHaveSameStatus, + blocks: blocks) + + Spacer() + } + } + } + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +struct BlockStatusView: View { + let block: CourseDateBlock + let allHaveSameStatus: Bool + let blocks: [CourseDateBlock] + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(block.formattedDate) + .font(Theme.Fonts.bodyLarge) + .bold() + + if block.isToday { + Text(CoreLocalization.CourseDates.today) + .font(Theme.Fonts.bodySmall) + .foregroundColor(Color.black) + .padding(EdgeInsets(top: 2, leading: 6, bottom: 2, trailing: 8)) + .background(Theme.Colors.warning) + .cornerRadius(5) + } + + if allHaveSameStatus { + let lockImageText = block.isVerifiedOnly ? Text(Image(systemName: "lock.fill")) : Text("") + Text("\(lockImageText) \(block.blockStatus.title)") + .font(Theme.Fonts.bodySmall) + .foregroundColor(block.blockStatus.foregroundColor) + .padding(EdgeInsets(top: 2, leading: 6, bottom: 2, trailing: 8)) + .background(block.blockStatus.backgroundColor) + .cornerRadius(5) + } + } + + ForEach(blocks) { block in + styleBlock(block: block, allHaveSameStatus: allHaveSameStatus) + } + .padding(.top, 0.2) + } + .padding(.vertical, 0) + .padding(.leading, 5) + .padding(.bottom, 10) + } + + func styleBlock(block: CourseDateBlock, allHaveSameStatus: Bool) -> some View { + var attributedString = AttributedString("") + + if let prefix = block.assignmentType, !prefix.isEmpty { + attributedString += AttributedString("\(prefix): ") + } + + attributedString += styleTitle(block: block) + + if !allHaveSameStatus { + attributedString.appendSpaces(2) + attributedString += applyStyle( + string: block.blockStatus.title, + forgroundColor: block.blockStatus.foregroundColor, + backgroundColor: block.blockStatus.backgroundColor) + } + + return Text(attributedString) + .font(Theme.Fonts.bodyMedium) + .foregroundColor({ + if block.isAssignment { + return block.isAvailable ? Color.black : Color.gray.opacity(0.6) + } else { + return Color.black + } + }()) + .onTapGesture { + + } + } + + func styleTitle(block: CourseDateBlock) -> AttributedString { + var attributedString = AttributedString(block.title) + attributedString.font = Theme.Fonts.bodyMedium + if block.canShowLink && !block.firstComponentBlockID.isEmpty { + attributedString.underlineStyle = .single + } + return attributedString + } + + func applyStyle(string: String, forgroundColor: Color, backgroundColor: Color) -> AttributedString { + var attributedString = AttributedString(string) + attributedString.font = Theme.Fonts.bodySmall + attributedString.foregroundColor = forgroundColor + attributedString.backgroundColor = backgroundColor + return attributedString + } +} + +fileprivate extension BlockStatus { + var title: String { + switch self { + case .completed: return CoreLocalization.CourseDates.completed + case .pastDue: return CoreLocalization.CourseDates.pastDue + case .dueNext: return CoreLocalization.CourseDates.dueNext + case .unreleased: return CoreLocalization.CourseDates.unreleased + case .verifiedOnly: return CoreLocalization.CourseDates.verifiedOnly + default: return "" + } + } + + var foregroundColor: Color { + switch self { + case .completed: return Color.white + case .verifiedOnly: return Color.white + case .pastDue: return Color.black + case .dueNext: return Color.white + default: return Color.white.opacity(0) + } + } + + var backgroundColor: Color { + switch self { + case .completed: return Color.black.opacity(0.5) + case .verifiedOnly: return Color.black.opacity(0.5) + case .pastDue: return Color.gray.opacity(0.4) + case .dueNext: return Color.black.opacity(0.5) + default: return Color.white.opacity(0) + } + } +} + +fileprivate extension AttributedString { + mutating func appendSpaces(_ count: Int = 1) { + self += AttributedString(String(repeating: " ", count: count)) + } +} + +#if DEBUG +struct CourseDatesView_Previews: PreviewProvider { + static var previews: some View { + let viewModel = CourseDatesViewModel( + interactor: CourseInteractor(repository: CourseRepositoryMock()), + router: CourseRouterMock(), + cssInjector: CSSInjectorMock(), + connectivity: Connectivity(), + courseID: "") + + CourseDatesView( + courseID: "", + viewModel: viewModel) + } +} +#endif diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift new file mode 100644 index 000000000..e60d413d9 --- /dev/null +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -0,0 +1,72 @@ +// +// CourseDatesViewModel.swift +// Course +// +// Created by Muhammad Umer on 10/18/23. +// + +import Foundation +import Core +import SwiftUI + +public class CourseDatesViewModel: ObservableObject { + + @Published private(set) var isShowProgress = false + @Published var showError: Bool = false + @Published var courseDates: CourseDates? + + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + + private let interactor: CourseInteractorProtocol + let cssInjector: CSSInjector + let router: CourseRouter + let connectivity: ConnectivityProtocol + + public init( + interactor: CourseInteractorProtocol, + router: CourseRouter, + cssInjector: CSSInjector, + connectivity: ConnectivityProtocol, + courseID: String + ) { + self.interactor = interactor + self.router = router + self.cssInjector = cssInjector + self.connectivity = connectivity + } + + var sortedDates: [Date] { + courseDates?.sortedDateToCourseDateBlockDict.keys.sorted() ?? [] + } + + func blocks(for date: Date) -> [CourseDateBlock] { + courseDates?.sortedDateToCourseDateBlockDict[date] ?? [] + } + + @MainActor + func getCourseDates(courseID: String) async { + isShowProgress = true + do { + courseDates = try await interactor.getCourseDates(courseID: courseID) + if courseDates?.courseDateBlocks == nil { + isShowProgress = false + errorMessage = CoreLocalization.Error.unknownError + return + } + isShowProgress = false + } catch let error { + isShowProgress = false + if error.isInternetError || error is NoCachedDataError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } +} diff --git a/Course/Course/Presentation/Details/CourseDetailsView.swift b/Course/Course/Presentation/Details/CourseDetailsView.swift index 2b846d526..5323efc64 100644 --- a/Course/Course/Presentation/Details/CourseDetailsView.swift +++ b/Course/Course/Presentation/Details/CourseDetailsView.swift @@ -246,7 +246,7 @@ private struct CourseTitleView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - Text(courseDetails.courseDescription) + Text(courseDetails.courseDescription ?? "") .font(Theme.Fonts.labelSmall) .padding(.horizontal, 26) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 484fdda9f..c74f0e7b0 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -50,10 +50,10 @@ public struct CourseUnitView: View { let data = Array(viewModel.verticals[viewModel.verticalIndex].childs.enumerated()) ForEach(data, id: \.offset) { index, block in VStack(spacing: 0) { - if index >= viewModel.index - 1 && index <= viewModel.index + 1 { switch LessonType.from(block) { // MARK: YouTube case let .youtube(url, blockID): + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { if viewModel.connectivity.isInternetAvaliable { YouTubeView( name: block.displayName, @@ -71,8 +71,12 @@ public struct CourseUnitView: View { } else { NoInternetView(playerStateSubject: playerStateSubject) } + } else { + EmptyView() + } // MARK: Encoded Video case let .video(encodedUrl, blockID): + if index == viewModel.index { let url = viewModel.urlForVideoFileOrFallback( blockId: blockID, url: encodedUrl @@ -94,23 +98,33 @@ public struct CourseUnitView: View { } else { NoInternetView(playerStateSubject: playerStateSubject) } + } // MARK: Web case .web(let url): + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { if viewModel.connectivity.isInternetAvaliable { WebView(url: url, viewModel: viewModel) } else { NoInternetView(playerStateSubject: playerStateSubject) } + } else { + EmptyView() + } // MARK: Unknown case .unknown(let url): + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { if viewModel.connectivity.isInternetAvaliable { UnknownView(url: url, viewModel: viewModel) Spacer() } else { NoInternetView(playerStateSubject: playerStateSubject) } + } else { + EmptyView() + } // MARK: Discussion case let .discussion(blockID, blockKey, title): + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { if viewModel.connectivity.isInternetAvaliable { VStack { if showDiscussion { @@ -131,10 +145,11 @@ public struct CourseUnitView: View { } else { NoInternetView(playerStateSubject: playerStateSubject) } + } else { + EmptyView() } - } else { - EmptyView() - } + } + } .frame( width: isHorizontal ? reader.size.width - 16 : reader.size.width, diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index 233469250..4740fb013 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -60,6 +60,7 @@ public struct EncodedVideoPlayer: View { PlayerViewController( videoURL: viewModel.url, controller: viewModel.controller, + bitrate: viewModel.getVideoResolution(), progress: { progress in if progress >= 0.8 { if !isViewedOnce { @@ -91,6 +92,7 @@ public struct EncodedVideoPlayer: View { preferredTimescale: 10000 ) ) + viewModel.controller.player?.play() pauseScrolling() currentTime = (date.secondsSinceMidnight() + 1) }) @@ -108,6 +110,7 @@ public struct EncodedVideoPlayer: View { preferredTimescale: 10000 ) ) + viewModel.controller.player?.play() pauseScrolling() currentTime = (date.secondsSinceMidnight() + 1) }) @@ -115,6 +118,9 @@ public struct EncodedVideoPlayer: View { } } }.padding(.horizontal, isHorizontal ? 0 : 8) + .onDisappear { + viewModel.controller.player?.allowsExternalPlayback = false + } } private func pauseScrolling() { @@ -136,7 +142,8 @@ struct EncodedVideoPlayer_Previews: PreviewProvider { languages: [], playerStateSubject: CurrentValueSubject(nil), interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), + router: CourseRouterMock(), + appStorage: CoreStorageMock(), connectivity: Connectivity() ), isOnScreen: true diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift index b75a57384..6163c8f93 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift @@ -24,6 +24,7 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { playerStateSubject: CurrentValueSubject, interactor: CourseInteractorProtocol, router: CourseRouter, + appStorage: CoreStorage, connectivity: ConnectivityProtocol ) { self.url = url @@ -32,7 +33,8 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { courseID: courseID, languages: languages, interactor: interactor, - router: router, + router: router, + appStorage: appStorage, connectivity: connectivity) playerStateSubject.sink(receiveValue: { [weak self] state in @@ -46,4 +48,19 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { } }).store(in: &subscription) } + + func getVideoResolution() -> CGSize { + switch appStorage.userSettings?.streamingQuality { + case .auto: + return CGSize(width: 1280, height: 720) + case .low: + return CGSize(width: 640, height: 360) + case .medium: + return CGSize(width: 854, height: 480) + case .high: + return CGSize(width: 1280, height: 720) + case .none: + return CGSize(width: 1280, height: 720) + } + } } diff --git a/Course/Course/Presentation/Video/PlayerViewController.swift b/Course/Course/Presentation/Video/PlayerViewController.swift index 01a27e640..593fee127 100644 --- a/Course/Course/Presentation/Video/PlayerViewController.swift +++ b/Course/Course/Presentation/Video/PlayerViewController.swift @@ -11,17 +11,21 @@ import _AVKit_SwiftUI struct PlayerViewController: UIViewControllerRepresentable { var videoURL: URL? + var videoResolution: CGSize var controller: AVPlayerViewController var progress: ((Float) -> Void) var seconds: ((Double) -> Void) init( - videoURL: URL?, controller: AVPlayerViewController, + videoURL: URL?, + controller: AVPlayerViewController, + bitrate: CGSize, progress: @escaping ((Float) -> Void), seconds: @escaping ((Double) -> Void) ) { self.videoURL = videoURL self.controller = controller + self.videoResolution = bitrate self.progress = progress self.seconds = seconds } @@ -73,8 +77,10 @@ struct PlayerViewController: UIViewControllerRepresentable { if asset?.url.absoluteString != videoURL?.absoluteString { if playerController.player == nil { playerController.player = AVPlayer() + playerController.player?.allowsExternalPlayback = true } playerController.player?.replaceCurrentItem(with: AVPlayerItem(url: videoURL!)) + playerController.player?.currentItem?.preferredMaximumResolution = videoResolution 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 e7dca9735..200dee93c 100644 --- a/Course/Course/Presentation/Video/SubtittlesView.swift +++ b/Course/Course/Presentation/Video/SubtittlesView.swift @@ -120,7 +120,8 @@ struct SubtittlesView_Previews: PreviewProvider { blockID: "", courseID: "", languages: [], interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), + router: CourseRouterMock(), + appStorage: CoreStorageMock(), connectivity: Connectivity() ), scrollTo: {_ in } ) diff --git a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift index cddcdba6c..b68ba5a7c 100644 --- a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift @@ -17,6 +17,7 @@ public class VideoPlayerViewModel: ObservableObject { private let interactor: CourseInteractorProtocol public let connectivity: ConnectivityProtocol public let router: CourseRouter + public let appStorage: CoreStorage private var subtitlesDownloaded: Bool = false @Published var subtitles: [Subtitle] = [] @@ -37,6 +38,7 @@ public class VideoPlayerViewModel: ObservableObject { languages: [SubtitleUrl], interactor: CourseInteractorProtocol, router: CourseRouter, + appStorage: CoreStorage, connectivity: ConnectivityProtocol ) { self.blockID = blockID @@ -44,6 +46,7 @@ public class VideoPlayerViewModel: ObservableObject { self.languages = languages self.interactor = interactor self.router = router + self.appStorage = appStorage self.connectivity = connectivity self.prepareLanguages() } diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index 9d12b3183..08a868665 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -55,20 +55,24 @@ public struct YouTubeVideoPlayer: View { Spacer() } } - SubtittlesView( - languages: viewModel.languages, - currentTime: $viewModel.currentTime, - viewModel: viewModel, scrollTo: { date in - viewModel.youtubePlayer.seek(to: date.secondsSinceMidnight(), allowSeekAhead: true) - viewModel.pauseScrolling() - viewModel.currentTime = date.secondsSinceMidnight() + 1 + ZStack { + SubtittlesView( + languages: viewModel.languages, + currentTime: $viewModel.currentTime, + viewModel: viewModel, scrollTo: { date in + viewModel.youtubePlayer.seek(to: date.secondsSinceMidnight(), allowSeekAhead: true) + viewModel.youtubePlayer.play() + viewModel.pauseScrolling() + viewModel.currentTime = date.secondsSinceMidnight() + 1 + } + ) + if viewModel.isLoading { + ProgressBar(size: 40, lineWidth: 8) } - ) + } } } - if viewModel.isLoading { - ProgressBar(size: 40, lineWidth: 8) - } + } } } @@ -84,7 +88,8 @@ struct YouTubeVideoPlayer_Previews: PreviewProvider { languages: [], playerStateSubject: CurrentValueSubject(nil), interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), + router: CourseRouterMock(), + appStorage: CoreStorageMock(), connectivity: Connectivity()), isOnScreen: true) } diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift index 06bc69f75..9caf58001 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift @@ -32,6 +32,7 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { playerStateSubject: CurrentValueSubject, interactor: CourseInteractorProtocol, router: CourseRouter, + appStorage: CoreStorage, connectivity: ConnectivityProtocol ) { self.url = url @@ -61,6 +62,7 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { languages: languages, interactor: interactor, router: router, + appStorage: appStorage, connectivity: connectivity ) @@ -80,7 +82,7 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { playerStateSubject.sink(receiveValue: { [weak self] state in switch state { case .pause: - self?.youtubePlayer.pause() + self?.youtubePlayer.stop() case .kill, .none: break } diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 27e677cf7..2eaafb3bf 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -49,6 +49,8 @@ public enum CourseLocalization { public enum CourseContainer { /// Course public static let course = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.COURSE", fallback: "Course") + /// Dates + public static let dates = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.DATES", fallback: "Dates") /// Discussion public static let discussion = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.DISCUSSION", fallback: "Discussion") /// Handouts diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index ccee17ddb..3152f86c7 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -37,6 +37,7 @@ "COURSE_CONTAINER.COURSE" = "Course"; "COURSE_CONTAINER.VIDEOS" = "Videos"; +"COURSE_CONTAINER.DATES" = "Dates"; "COURSE_CONTAINER.DISCUSSION" = "Discussion"; "COURSE_CONTAINER.HANDOUTS" = "Handouts"; "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING" = "Handouts In developing"; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index 5ec8fd4b1..302297084 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -36,6 +36,7 @@ "COURSE_CONTAINER.COURSE" = "Курс"; "COURSE_CONTAINER.VIDEOS" = "Всі відео"; +//"COURSE_CONTAINER.DATES" = "Dates"; "COURSE_CONTAINER.DISCUSSION" = "Дискусії"; "COURSE_CONTAINER.HANDOUTS" = "Матеріали"; "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING" = "Матеріали в процесі розробки"; diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index a4cf6b418..8c909304b 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT @@ -490,9 +490,9 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -545,7 +545,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -578,7 +578,7 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -630,7 +630,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -647,7 +647,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -678,7 +678,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -707,8 +707,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) @@ -1124,6 +1124,12 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { perform?(`courseId`, `courseName`) } + open func courseOutlineDatesTabClicked(courseId: String, courseName: String) { + addInvocation(.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_courseOutlineDatesTabClicked__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 @@ -1151,6 +1157,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) @@ -1247,6 +1254,12 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) return Matcher.ComparisonResult(results) + case (.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineDatesTabClicked__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")) @@ -1277,6 +1290,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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_courseOutlineDatesTabClicked__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 } @@ -1296,6 +1310,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDatesTabClicked(courseId:courseName:)" case .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDiscussionTabClicked(courseId:courseName:)" case .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineHandoutsTabClicked(courseId:courseName:)" } @@ -1329,6 +1344,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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 courseOutlineDatesTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDatesTabClicked__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`))} } @@ -1376,6 +1392,9 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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 courseOutlineDatesTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_courseOutlineDatesTabClicked__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) } @@ -1671,6 +1690,22 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { return __value } + open func getCourseDates(courseID: String) throws -> CourseDates { + addInvocation(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: CourseDates + do { + __value = try methodReturnValue(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getCourseDates(courseID: String). Use given") + Failure("Stub return value not specified for getCourseDates(courseID: String). Use given") + } catch { + throw error + } + return __value + } + fileprivate enum MethodType { case m_getCourseDetails__courseID_courseID(Parameter) @@ -1684,6 +1719,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case m_getUpdates__courseID_courseID(Parameter) case m_resumeBlock__courseID_courseID(Parameter) case m_getSubtitles__url_urlselectedLanguage_selectedLanguage(Parameter, Parameter) + case m_getCourseDates__courseID_courseID(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1743,6 +1779,11 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { 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) + + case (.m_getCourseDates__courseID_courseID(let lhsCourseid), .m_getCourseDates__courseID_courseID(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + return Matcher.ComparisonResult(results) default: return .none } } @@ -1760,6 +1801,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { 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_urlselectedLanguage_selectedLanguage(p0, p1): return p0.intValue + p1.intValue + case let .m_getCourseDates__courseID_courseID(p0): return p0.intValue } } func assertionName() -> String { @@ -1775,6 +1817,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case .m_getUpdates__courseID_courseID: return ".getUpdates(courseID:)" case .m_resumeBlock__courseID_courseID: return ".resumeBlock(courseID:)" case .m_getSubtitles__url_urlselectedLanguage_selectedLanguage: return ".getSubtitles(url:selectedLanguage:)" + case .m_getCourseDates__courseID_courseID: return ".getCourseDates(courseID:)" } } } @@ -1818,6 +1861,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { 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 getCourseDates(courseID: Parameter, willReturn: CourseDates...) -> MethodStub { + return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func getCourseVideoBlocks(fullStructure: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [CourseStructure] = [] let given: Given = { return Given(method: .m_getCourseVideoBlocks__fullStructure_fullStructure(`fullStructure`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -1925,6 +1971,16 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { willProduce(stubber) return given } + public static func getCourseDates(courseID: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getCourseDates(courseID: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (CourseDates).self) + willProduce(stubber) + return given + } } public struct Verify { @@ -1941,6 +1997,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { 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, selectedLanguage: Parameter) -> Verify { return Verify(method: .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(`url`, `selectedLanguage`))} + public static func getCourseDates(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseDates__courseID_courseID(`courseID`))} } public struct Perform { @@ -1980,6 +2037,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { 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) } + public static func getCourseDates(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getCourseDates__courseID_courseID(`courseID`), performs: perform) + } } public func given(_ method: Given) { diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift new file mode 100644 index 000000000..690b93325 --- /dev/null +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -0,0 +1,473 @@ +// +// CourseDateViewModelTests.swift +// CourseTests +// +// Created by Muhammad Umer on 10/24/23. +// + +import XCTest +import Alamofire +import SwiftyMocky +@testable import Core +@testable import Course + +final class CourseDateViewModelTests: XCTestCase { + func testGetCourseDatesSuccess() async throws { + let interactor = CourseInteractorProtocolMock() + let router = CourseRouterMock() + let cssInjector = CSSInjectorMock() + let connectivity = ConnectivityProtocolMock() + + let courseDates = CourseDates( + datesBannerInfo: + DatesBannerInfo( + missedDeadlines: false, + contentTypeGatingEnabled: false, + missedGatedContent: false, + verifiedUpgradeLink: ""), + courseDateBlocks: [], + hasEnded: false, + learnerIsFullAccess: false, + userTimezone: nil) + + Given(interactor, .getCourseDates(courseID: .any, willReturn: courseDates)) + + let viewModel = CourseDatesViewModel( + interactor: interactor, + router: router, + cssInjector: cssInjector, + connectivity: connectivity, + courseID: "1") + + await viewModel.getCourseDates(courseID: "1") + + Verify(interactor, .getCourseDates(courseID: .any)) + + XCTAssert((viewModel.courseDates != nil)) + XCTAssertFalse(viewModel.isShowProgress) + XCTAssertNil(viewModel.errorMessage) + XCTAssertFalse(viewModel.showError) + } + + func testGetCourseDatesUnknownError() async throws { + let interactor = CourseInteractorProtocolMock() + let router = CourseRouterMock() + let cssInjector = CSSInjectorMock() + let connectivity = ConnectivityProtocolMock() + + Given(interactor, .getCourseDates(courseID: .any, willThrow: NSError())) + + let viewModel = CourseDatesViewModel( + interactor: interactor, + router: router, + cssInjector: cssInjector, + connectivity: connectivity, + courseID: "1") + + await viewModel.getCourseDates(courseID: "1") + + Verify(interactor, .getCourseDates(courseID: .any)) + + XCTAssertTrue(viewModel.showError) + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError, "Error view should be shown on unknown error.") + } + + func testNoInternetConnectionError() async throws { + let interactor = CourseInteractorProtocolMock() + let router = CourseRouterMock() + let cssInjector = CSSInjectorMock() + let connectivity = ConnectivityProtocolMock() + + let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) + + Given(interactor, .getCourseDates(courseID: .any, willThrow: noInternetError)) + + let viewModel = CourseDatesViewModel( + interactor: interactor, + router: router, + cssInjector: cssInjector, + connectivity: connectivity, + courseID: "1") + + await viewModel.getCourseDates(courseID: "1") + + Verify(interactor, .getCourseDates(courseID: .any)) + + XCTAssertTrue(viewModel.showError) + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection, "Error message should be set to 'slow or no internet connection'.") + } + + func testSortedDateTodayToCourseDateBlockDict() { + let block1 = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today.addingTimeInterval(86400), + dateType: "event", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockID1" + ) + + let block2 = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today, + dateType: "event", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockID1" + ) + + let courseDates = CourseDates( + datesBannerInfo: DatesBannerInfo( + missedDeadlines: false, + contentTypeGatingEnabled: false, + missedGatedContent: false, + verifiedUpgradeLink: nil + ), + courseDateBlocks: [block1, block2], + hasEnded: false, + learnerIsFullAccess: true, + userTimezone: nil + ) + + let sortedDict = courseDates.sortedDateToCourseDateBlockDict + + XCTAssertEqual(sortedDict.keys.sorted().first, Date.today) + } + + func testMultipleBlocksForSameDate() { + let block1 = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today, + dateType: "event", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockID1" + ) + + let block2 = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today, + dateType: "event", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockID1" + ) + + let courseDates = CourseDates( + datesBannerInfo: DatesBannerInfo( + missedDeadlines: false, + contentTypeGatingEnabled: false, + missedGatedContent: false, + verifiedUpgradeLink: nil + ), + courseDateBlocks: [block1, block2], + hasEnded: false, + learnerIsFullAccess: true, + userTimezone: nil + ) + + let sortedDict = courseDates.sortedDateToCourseDateBlockDict + XCTAssertEqual(sortedDict[block1.date]?.count, 2, "There should be two blocks for the given date.") + } + + func testBlockStatusForAssignmentType() { + let block = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestAssignment", + extraInfo: nil, + firstComponentBlockID: "blockID3" + ) + + XCTAssertEqual(block.blockStatus, .dueNext) + } + + func testBadgeLogicForToday() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today, + dateType: "", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: CoreLocalization.CourseDates.today, + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.title, "Today", "Block title for 'today' should be 'Today'") + } + + func testBadgeLogicForCompleted() { + let block = CourseDateBlock( + assignmentType: nil, + complete: true, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + XCTAssertEqual(block.blockStatus, .completed, "Block status for a completed assignment should be 'completed'") + } + + func testBadgeLogicForVerifiedOnly() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, .verifiedOnly, "Block status for a block without learner access should be 'verifiedOnly'") + } + + func testBadgeLogicForPastDue() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(-86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, .pastDue, "Block status for a past due assignment should be 'pastDue'") + } + + func testLinkForAvailableAssignment() { + let availableAssignment = CourseDateBlock( + assignmentType: nil, + complete: true, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + XCTAssertTrue(availableAssignment.canShowLink, "Available assignments should be hyperlinked.") + } + + func testIsAssignment() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertTrue(block.isAssignment) + } + + func testIsCourseStartDate() { + let block = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today.addingTimeInterval(-86400), + dateType: "course-start-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, BlockStatus.courseStartDate) + } + + func testIsCourseEndDate() { + let block = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today.addingTimeInterval(86400), + dateType: "course-end-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, BlockStatus.courseEndDate) + } + + func testVerifiedOnly() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertTrue(block.isVerifiedOnly, "Block should be identified as 'verified only' when the learner has no access.") + } + + func testIsCompleted() { + let block = CourseDateBlock( + assignmentType: nil, + complete: true, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertTrue(block.isComplete, "Block should be marked as completed.") + } + + func testBadgeLogicForUnreleasedAssignment() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, .unreleased, "Block status should be set to 'unreleased' for unreleased assignments.") + } + + func testNoLinkForUnavailableAssignment() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, .verifiedOnly) + XCTAssertFalse(block.canShowLink, "Block should not show a link if the assignment is unavailable.") + } + + func testNoLinkAvailableForUnreleasedAssignment() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, .unreleased) + XCTAssertFalse(block.canShowLink, "Block should not show a link if the assignment is unreleased.") + } + + func testTodayProperty() { + let today = Date.today + let currentDay = Calendar.current.startOfDay(for: Date()) + XCTAssertTrue(today.isToday, "The today property should return true for isToday.") + XCTAssertEqual(today, currentDay, "The today property should equal the start of the current day.") + } + + func testDateIsInPastProperty() { + let pastDate = Date().addingTimeInterval(-100000) + XCTAssertTrue(pastDate.isInPast, "The past date should return true for isInPast.") + XCTAssertFalse(pastDate.isToday, "The past date should return false for isInPast.") + } + + func testDateIsInFutureProperty() { + let futureDate = Date().addingTimeInterval(100000) + XCTAssertTrue(futureDate.isInFuture, "The future date should return false for isInFuture.") + XCTAssertFalse(futureDate.isToday, "The future date should return false for isInFuture.") + } + + func testBlockStatusMapping() { + XCTAssertEqual(BlockStatus.status(of: "course-start-date"), .courseStartDate, "Incorrect mapping for 'course-start-date'") + XCTAssertEqual(BlockStatus.status(of: "course-end-date"), .courseEndDate, "Incorrect mapping for 'course-end-date'") + XCTAssertEqual(BlockStatus.status(of: "certificate-available-date"), .certificateAvailbleDate, "Incorrect mapping for 'certificate-available-date'") + XCTAssertEqual(BlockStatus.status(of: "verification-deadline-date"), .verificationDeadlineDate, "Incorrect mapping for 'verification-deadline-date'") + XCTAssertEqual(BlockStatus.status(of: "verified-upgrade-deadline"), .verifiedUpgradeDeadline, "Incorrect mapping for 'verified-upgrade-deadline'") + XCTAssertEqual(BlockStatus.status(of: "assignment-due-date"), .assignment, "Incorrect mapping for 'assignment-due-date'") + XCTAssertEqual(BlockStatus.status(of: ""), .event, "Incorrect mapping for 'event'") + } +} diff --git a/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift b/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift index 83295daf7..2a6b2f722 100644 --- a/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift @@ -37,7 +37,8 @@ final class VideoPlayerViewModelTests: XCTestCase { courseID: "", languages: [], interactor: interactor, - router: router, + router: router, + appStorage: CoreStorageMock(), connectivity: connectivity) await viewModel.getSubtitles(subtitlesUrl: "url") @@ -64,6 +65,7 @@ final class VideoPlayerViewModelTests: XCTestCase { languages: [], interactor: interactor, router: router, + appStorage: CoreStorageMock(), connectivity: connectivity) await viewModel.getSubtitles(subtitlesUrl: "url") @@ -85,6 +87,7 @@ final class VideoPlayerViewModelTests: XCTestCase { languages: [], interactor: interactor, router: router, + appStorage: CoreStorageMock(), connectivity: connectivity) viewModel.languages = [ @@ -112,6 +115,7 @@ final class VideoPlayerViewModelTests: XCTestCase { languages: [], interactor: interactor, router: router, + appStorage: CoreStorageMock(), connectivity: connectivity) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willProduce: {_ in})) @@ -131,6 +135,7 @@ final class VideoPlayerViewModelTests: XCTestCase { languages: [], interactor: interactor, router: router, + appStorage: CoreStorageMock(), connectivity: connectivity) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willThrow: NSError())) @@ -155,6 +160,7 @@ final class VideoPlayerViewModelTests: XCTestCase { languages: [], interactor: interactor, router: router, + appStorage: CoreStorageMock(), connectivity: connectivity) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willThrow: noInternetError)) diff --git a/Course/Mockfile b/Course/Mockfile index 504794e7d..58cd4b263 100644 --- a/Course/Mockfile +++ b/Course/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 27aebe250..08f998ce9 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT @@ -490,9 +490,9 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -545,7 +545,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -578,7 +578,7 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -630,7 +630,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -647,7 +647,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -678,7 +678,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -707,8 +707,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) diff --git a/Dashboard/Mockfile b/Dashboard/Mockfile index 053c2899f..f747b41e0 100644 --- a/Dashboard/Mockfile +++ b/Dashboard/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index 05974ad4f..88b0be486 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -14,6 +14,9 @@ 0283347928D49A8700C828FC /* DiscoveryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0283347828D49A8700C828FC /* DiscoveryViewModel.swift */; }; 0284DBFC28D4856A00830893 /* DiscoveryEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0284DBFB28D4856A00830893 /* DiscoveryEndpoint.swift */; }; 0284DC0328D4922900830893 /* DiscoveryRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0284DC0228D4922900830893 /* DiscoveryRepository.swift */; }; + 029242E72AE6978400A940EC /* UpdateRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029242E62AE6978400A940EC /* UpdateRequiredView.swift */; }; + 029242E92AE6A3AB00A940EC /* UpdateRecommendedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029242E82AE6A3AB00A940EC /* UpdateRecommendedView.swift */; }; + 029242EB2AE6AB7B00A940EC /* UpdateNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029242EA2AE6AB7B00A940EC /* UpdateNotificationView.swift */; }; 029737402949FB070051696B /* DiscoveryCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 0297373E2949FB070051696B /* DiscoveryCoreModel.xcdatamodeld */; }; 029737422949FB3B0051696B /* DiscoveryPersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029737412949FB3B0051696B /* DiscoveryPersistenceProtocol.swift */; }; 02EF39D128D867690058F6BD /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 02EF39D028D867690058F6BD /* swiftgen.yml */; }; @@ -49,6 +52,9 @@ 0283347828D49A8700C828FC /* DiscoveryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryViewModel.swift; sourceTree = ""; }; 0284DBFB28D4856A00830893 /* DiscoveryEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryEndpoint.swift; sourceTree = ""; }; 0284DC0228D4922900830893 /* DiscoveryRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryRepository.swift; sourceTree = ""; }; + 029242E62AE6978400A940EC /* UpdateRequiredView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateRequiredView.swift; sourceTree = ""; }; + 029242E82AE6A3AB00A940EC /* UpdateRecommendedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateRecommendedView.swift; sourceTree = ""; }; + 029242EA2AE6AB7B00A940EC /* UpdateNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateNotificationView.swift; sourceTree = ""; }; 0297373F2949FB070051696B /* DiscoveryCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DiscoveryCoreModel.xcdatamodel; sourceTree = ""; }; 029737412949FB3B0051696B /* DiscoveryPersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPersistenceProtocol.swift; sourceTree = ""; }; 02ED50C729A649C9008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; @@ -151,6 +157,16 @@ path = Domain; sourceTree = ""; }; + 029242E52AE6976E00A940EC /* UpdateViews */ = { + isa = PBXGroup; + children = ( + 029242E62AE6978400A940EC /* UpdateRequiredView.swift */, + 029242E82AE6A3AB00A940EC /* UpdateRecommendedView.swift */, + 029242EA2AE6AB7B00A940EC /* UpdateNotificationView.swift */, + ); + path = UpdateViews; + sourceTree = ""; + }; 02EF39CB28D866C50058F6BD /* SwiftGen */ = { isa = PBXGroup; children = ( @@ -162,6 +178,7 @@ 070019A228F6EF2700D5FC78 /* Presentation */ = { isa = PBXGroup; children = ( + 029242E52AE6976E00A940EC /* UpdateViews */, 072787B328D34D91002E9142 /* DiscoveryView.swift */, 0283347828D49A8700C828FC /* DiscoveryViewModel.swift */, CFC849422996A5150055E497 /* SearchView.swift */, @@ -459,15 +476,18 @@ buildActionMask = 2147483647; files = ( CFC849452996A52A0055E497 /* SearchViewModel.swift in Sources */, + 029242E92AE6A3AB00A940EC /* UpdateRecommendedView.swift in Sources */, CFC849432996A5150055E497 /* SearchView.swift in Sources */, 0284DBFC28D4856A00830893 /* DiscoveryEndpoint.swift in Sources */, 029737402949FB070051696B /* DiscoveryCoreModel.xcdatamodeld in Sources */, + 029242E72AE6978400A940EC /* UpdateRequiredView.swift in Sources */, 0283347728D499BC00C828FC /* DiscoveryInteractor.swift in Sources */, 02F3BFDF29252F2F0051930C /* DiscoveryRouter.swift in Sources */, 0283347928D49A8700C828FC /* DiscoveryViewModel.swift in Sources */, 072787B428D34D91002E9142 /* DiscoveryView.swift in Sources */, 029737422949FB3B0051696B /* DiscoveryPersistenceProtocol.swift in Sources */, 0284DC0328D4922900830893 /* DiscoveryRepository.swift in Sources */, + 029242EB2AE6AB7B00A940EC /* UpdateNotificationView.swift in Sources */, 02EF39DC28D86BEF0058F6BD /* Strings.swift in Sources */, 02F1752F2A4DA3B60019CD70 /* DiscoveryAnalytics.swift in Sources */, ); diff --git a/Discovery/Discovery.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Discovery/Discovery.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Discovery/Discovery.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Discovery/Discovery.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Discovery/Discovery.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Discovery/Discovery.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Discovery/Discovery/Presentation/DiscoveryRouter.swift b/Discovery/Discovery/Presentation/DiscoveryRouter.swift index 8a0b68e14..61fc564d5 100644 --- a/Discovery/Discovery/Presentation/DiscoveryRouter.swift +++ b/Discovery/Discovery/Presentation/DiscoveryRouter.swift @@ -12,6 +12,8 @@ public protocol DiscoveryRouter: BaseRouter { func showCourseDetais(courseID: String, title: String) func showDiscoverySearch() + func showUpdateRequiredView(showAccountLink: Bool) + func showUpdateRecomendedView() } // Mark - For testing and SwiftUI preview @@ -22,6 +24,7 @@ public class DiscoveryRouterMock: BaseRouterMock, DiscoveryRouter { public func showCourseDetais(courseID: String, title: String) {} public func showDiscoverySearch() {} - + public func showUpdateRequiredView(showAccountLink: Bool) {} + public func showUpdateRecomendedView() {} } #endif diff --git a/Discovery/Discovery/Presentation/DiscoveryView.swift b/Discovery/Discovery/Presentation/DiscoveryView.swift index 34e936b7b..a90b41a9b 100644 --- a/Discovery/Discovery/Presentation/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/DiscoveryView.swift @@ -12,7 +12,6 @@ public struct DiscoveryView: View { @StateObject private var viewModel: DiscoveryViewModel - private let router: DiscoveryRouter @State private var isRefreshing: Bool = false private let discoveryNew: some View = VStack(alignment: .leading) { @@ -26,9 +25,8 @@ public struct DiscoveryView: View { .accessibilityElement(children: .ignore) .accessibilityLabel(DiscoveryLocalization.Header.title1 + DiscoveryLocalization.Header.title2) - public init(viewModel: DiscoveryViewModel, router: DiscoveryRouter) { + public init(viewModel: DiscoveryViewModel) { self._viewModel = StateObject(wrappedValue: { viewModel }()) - self.router = router } public var body: some View { @@ -47,7 +45,7 @@ public struct DiscoveryView: View { Spacer() } .onTapGesture { - router.showDiscoverySearch() + viewModel.router.showDiscoverySearch() viewModel.discoverySearchBarClicked() } .frame(minHeight: 48) @@ -62,7 +60,7 @@ public struct DiscoveryView: View { .fill(Theme.Colors.textInputUnfocusedStroke) ) .onTapGesture { - router.showDiscoverySearch() + viewModel.router.showDiscoverySearch() viewModel.discoverySearchBarClicked() } .padding(.horizontal, 24) @@ -77,7 +75,7 @@ public struct DiscoveryView: View { Task { await viewModel.discovery(page: 1, withProgress: false) } - }) { + }) { LazyVStack(spacing: 0) { HStack { discoveryNew @@ -102,7 +100,7 @@ public struct DiscoveryView: View { courseID: course.courseID, courseName: course.name ) - router.showCourseDetais( + viewModel.router.showCourseDetais( courseID: course.courseID, title: course.name ) @@ -151,6 +149,7 @@ public struct DiscoveryView: View { Task { await viewModel.discovery(page: 1) } + viewModel.setupNotifications() } .background(Theme.Colors.background.ignoresSafeArea()) } @@ -159,15 +158,18 @@ 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(router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: DiscoveryInteractor.mock, + connectivity: Connectivity(), analytics: DiscoveryAnalyticsMock()) let router = DiscoveryRouterMock() - DiscoveryView(viewModel: vm, router: router) + DiscoveryView(viewModel: vm) .preferredColorScheme(.light) .previewDisplayName("DiscoveryView Light") - DiscoveryView(viewModel: vm, router: router) + DiscoveryView(viewModel: vm) .preferredColorScheme(.dark) .previewDisplayName("DiscoveryView Dark") } diff --git a/Discovery/Discovery/Presentation/DiscoveryViewModel.swift b/Discovery/Discovery/Presentation/DiscoveryViewModel.swift index 37514275b..18391bcc5 100644 --- a/Discovery/Discovery/Presentation/DiscoveryViewModel.swift +++ b/Discovery/Discovery/Presentation/DiscoveryViewModel.swift @@ -5,15 +5,17 @@ // Created by  Stepanok Ivan on 16.09.2022. // -import Foundation +import Combine import Core import SwiftUI public class DiscoveryViewModel: ObservableObject { - public var nextPage = 1 - public var totalPages = 1 - public private(set) var fetchInProgress = false + var nextPage = 1 + var totalPages = 1 + private(set) var fetchInProgress = false + private var cancellables = Set() + private var updateShowedOnce: Bool = false @Published var courses: [CourseItem] = [] @Published var showError: Bool = false @@ -26,15 +28,21 @@ public class DiscoveryViewModel: ObservableObject { } } + let router: DiscoveryRouter + let config: Config let connectivity: ConnectivityProtocol private let interactor: DiscoveryInteractorProtocol private let analytics: DiscoveryAnalytics public init( + router: DiscoveryRouter, + config: Config, interactor: DiscoveryInteractorProtocol, connectivity: ConnectivityProtocol, analytics: DiscoveryAnalytics ) { + self.router = router + self.config = config self.interactor = interactor self.connectivity = connectivity self.analytics = analytics @@ -55,6 +63,29 @@ public class DiscoveryViewModel: ObservableObject { } } + func setupNotifications() { + NotificationCenter.default.publisher(for: .onActualVersionReceived) + .sink { [weak self] notification in + if let latestVersion = notification.object as? String { + if let info = Bundle.main.infoDictionary { + guard let currentVersion = info["CFBundleShortVersionString"] as? String, + let self else { return } + switch self.compareVersions(currentVersion, latestVersion) { + case .orderedAscending: + if self.updateShowedOnce == false { + DispatchQueue.main.async { + self.router.showUpdateRecomendedView() + } + self.updateShowedOnce = true + } + default: + return + } + } + } + }.store(in: &cancellables) + } + @MainActor func discovery(page: Int, withProgress: Bool = true) async { fetchInProgress = withProgress @@ -82,6 +113,8 @@ public class DiscoveryViewModel: ObservableObject { fetchInProgress = false if error.isInternetError || error is NoCachedDataError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else if error.isUpdateRequeiredError { + self.router.showUpdateRequiredView(showAccountLink: true) } else { errorMessage = CoreLocalization.Error.unknownError } @@ -95,4 +128,30 @@ public class DiscoveryViewModel: ObservableObject { func discoverySearchBarClicked() { analytics.discoverySearchBarClicked() } + + private func compareVersions(_ version1: String, _ version2: String) -> ComparisonResult { + let components1 = version1.components(separatedBy: ".").prefix(2) + let components2 = version2.components(separatedBy: ".").prefix(2) + + guard let major1 = Int(components1.first ?? ""), + let minor1 = Int(components1.last ?? ""), + let major2 = Int(components2.first ?? ""), + let minor2 = Int(components2.last ?? "") else { + return .orderedSame + } + + if major1 < major2 { + return .orderedAscending + } else if major1 > major2 { + return .orderedDescending + } else { + if minor1 < minor2 { + return .orderedAscending + } else if minor1 > minor2 { + return .orderedDescending + } else { + return .orderedSame + } + } + } } diff --git a/Discovery/Discovery/Presentation/UpdateViews/UpdateNotificationView.swift b/Discovery/Discovery/Presentation/UpdateViews/UpdateNotificationView.swift new file mode 100644 index 000000000..83098abf7 --- /dev/null +++ b/Discovery/Discovery/Presentation/UpdateViews/UpdateNotificationView.swift @@ -0,0 +1,60 @@ +// +// UpdateNotificationView.swift +// Discovery +// +// Created by  Stepanok Ivan on 23.10.2023. +// + +import SwiftUI +import Core + +public struct UpdateNotificationView: View { + + private let config: Config + + public init(config: Config) { + self.config = config + } + + public var body: some View { + ZStack { + VStack { + Spacer() + HStack(spacing: 10) { + Image(systemName: "arrow.up.circle") + .resizable() + .frame(width: 36, + height: 36) + .foregroundColor(.white) + VStack(alignment: .leading) { + Text(DiscoveryLocalization.updateNeededTitle) + .font(Theme.Fonts.titleMedium) + Text(DiscoveryLocalization.updateNewAvaliable) + .font(Theme.Fonts.bodySmall) + }.foregroundColor(.white) + Spacer() + } + .padding(16) + .background(Theme.Colors.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: Color.black.opacity(0.4), radius: 12, x: 0, y: 0) + .padding(24) + + } + }.onTapGesture { + openAppStore() + } + } + private func openAppStore() { + guard let appStoreURL = URL(string: config.appStoreLink) else { return } + UIApplication.shared.open(appStoreURL) + } +} + +#if DEBUG +struct UpdateNotificationView_Previews: PreviewProvider { + static var previews: some View { + UpdateNotificationView(config: ConfigMock()) + } +} +#endif diff --git a/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift b/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift new file mode 100644 index 000000000..5059707d7 --- /dev/null +++ b/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift @@ -0,0 +1,85 @@ +// +// UpdateRecommendedView.swift +// Discovery +// +// Created by  Stepanok Ivan on 23.10.2023. +// + +import SwiftUI +import Core + +public struct UpdateRecommendedView: View { + + @Environment (\.isHorizontal) private var isHorizontal + private let router: DiscoveryRouter + private let config: Config + + public init(router: DiscoveryRouter, config: Config) { + self.router = router + self.config = config + } + + public var body: some View { + ZStack { + Color.black.opacity(0.5) + .ignoresSafeArea() + .onTapGesture { + router.dismiss(animated: true) + NotificationCenter.default.post(name: .onNewVersionAvaliable, object: nil) + } + VStack(spacing: 10) { + Image(systemName: "arrow.up.circle") + .resizable() + .frame(width: isHorizontal ? 50 : 110, + height: isHorizontal ? 50 : 110) + .foregroundColor(Theme.Colors.accentColor) + .padding(.bottom, isHorizontal ? 0 : 20) + Text(DiscoveryLocalization.updateNeededTitle) + .font(Theme.Fonts.titleMedium) + Text(DiscoveryLocalization.updateNeededDescription) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.avatarStroke) + .multilineTextAlignment(.center) + + HStack(spacing: 28) { + Button(action: { + router.dismiss(animated: true) + NotificationCenter.default.post(name: .onNewVersionAvaliable, object: nil) + }, label: { + HStack { + Text(DiscoveryLocalization.updateNeededNotNow) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentColor) + }.padding(8) + }) + + StyledButton(DiscoveryLocalization.updateButton, action: { + openAppStore() + }).fixedSize() + }.padding(.top, isHorizontal ? 0 : 44) + + }.padding(isHorizontal ? 40 : 40) + .background(Theme.Colors.background) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .frame(maxWidth: 400, maxHeight: 400) + .padding(24) + .shadow(color: Color.black.opacity(0.4), radius: 12, x: 0, y: 0) + }.navigationTitle(DiscoveryLocalization.updateDeprecatedApp) + } + + private func openAppStore() { + guard let appStoreURL = URL(string: config.appStoreLink) else { return } + UIApplication.shared.open(appStoreURL) + } +} + +#if DEBUG +struct UpdateRecommendedView_Previews: PreviewProvider { + static var previews: some View { + UpdateRecommendedView( + router: DiscoveryRouterMock(), + config: ConfigMock() + ) + } +} +#endif diff --git a/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift b/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift new file mode 100644 index 000000000..9f121b944 --- /dev/null +++ b/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift @@ -0,0 +1,76 @@ +// +// UpdateRequiredView.swift +// Discovery +// +// Created by  Stepanok Ivan on 23.10.2023. +// + +import SwiftUI +import Core + +public struct UpdateRequiredView: View { + + @Environment (\.isHorizontal) private var isHorizontal + private let router: DiscoveryRouter + private let config: Config + private let showAccountLink: Bool + + public init(router: DiscoveryRouter, config: Config, showAccountLink: Bool = true) { + self.router = router + self.config = config + self.showAccountLink = showAccountLink + } + + public var body: some View { + ZStack { + VStack(spacing: 10) { + CoreAssets.warningFilled.swiftUIImage + .resizable() + .frame(width: isHorizontal ? 50 : 110, + height: isHorizontal ? 50 : 110) + Text(DiscoveryLocalization.updateRequiredTitle) + .font(Theme.Fonts.titleMedium) + Text(DiscoveryLocalization.updateRequiredDescription) + .font(Theme.Fonts.titleSmall) + .multilineTextAlignment(.center) + + HStack(spacing: 28) { + if showAccountLink { + Button(action: { + NotificationCenter.default.post(name: .onAppUpgradeAccountSettingsTapped, object: "block") + router.back(animated: false) + }, label: { + HStack { + Text(DiscoveryLocalization.updateAccountSettings) + .font(Theme.Fonts.labelLarge) + }.padding(8) + }) + } + StyledButton(DiscoveryLocalization.updateButton, action: { + openAppStore() + }).fixedSize() + }.padding(.top, isHorizontal ? 10 : 44) + + }.padding(40) + .frame(maxWidth: 400) + }.navigationTitle(DiscoveryLocalization.updateDeprecatedApp) + .navigationBarBackButtonHidden() + } + + private func openAppStore() { + guard let appStoreURL = URL(string: config.appStoreLink) else { return } + UIApplication.shared.open(appStoreURL) + } +} + +#if DEBUG +struct UpdateRequiredView_Previews: PreviewProvider { + static var previews: some View { + UpdateRequiredView( + router: DiscoveryRouterMock(), + config: ConfigMock() + ) + .loadFonts() + } +} +#endif diff --git a/Discovery/Discovery/SwiftGen/Strings.swift b/Discovery/Discovery/SwiftGen/Strings.swift index b3abc48df..c53a55352 100644 --- a/Discovery/Discovery/SwiftGen/Strings.swift +++ b/Discovery/Discovery/SwiftGen/Strings.swift @@ -21,6 +21,26 @@ public enum DiscoveryLocalization { /// /// Created by  Stepanok Ivan on 19.09.2022. public static let title = DiscoveryLocalization.tr("Localizable", "TITLE", fallback: "Discover") + /// Account Settings + public static let updateAccountSettings = DiscoveryLocalization.tr("Localizable", "UPDATE_ACCOUNT_SETTINGS", fallback: "Account Settings") + /// Update + public static let updateButton = DiscoveryLocalization.tr("Localizable", "UPDATE_BUTTON", fallback: "Update") + /// Deprecated App Version + public static let updateDeprecatedApp = DiscoveryLocalization.tr("Localizable", "UPDATE_DEPRECATED_APP", fallback: "Deprecated App Version") + /// We recommend that you update to the latest version. Upgrade now to receive the latest features and fixes. + public static let updateNeededDescription = DiscoveryLocalization.tr("Localizable", "UPDATE_NEEDED_DESCRIPTION", fallback: "We recommend that you update to the latest version. Upgrade now to receive the latest features and fixes.") + /// Not Now + public static let updateNeededNotNow = DiscoveryLocalization.tr("Localizable", "UPDATE_NEEDED_NOT_NOW", fallback: "Not Now") + /// App Update + public static let updateNeededTitle = DiscoveryLocalization.tr("Localizable", "UPDATE_NEEDED_TITLE", fallback: "App Update") + /// New update available! Upgrade now to receive the latest features and fixes + public static let updateNewAvaliable = DiscoveryLocalization.tr("Localizable", "UPDATE_NEW_AVALIABLE", fallback: "New update available! Upgrade now to receive the latest features and fixes") + /// This version of the OpenEdX app is out-of-date. To continue learning and get the latest features and fixes, please upgrade to the latest version. + public static let updateRequiredDescription = DiscoveryLocalization.tr("Localizable", "UPDATE_REQUIRED_DESCRIPTION", fallback: "This version of the OpenEdX app is out-of-date. To continue learning and get the latest features and fixes, please upgrade to the latest version.") + /// App Update Required + public static let updateRequiredTitle = DiscoveryLocalization.tr("Localizable", "UPDATE_REQUIRED_TITLE", fallback: "App Update Required") + /// Why do I need to update? + public static let updateWhyNeed = DiscoveryLocalization.tr("Localizable", "UPDATE_WHY_NEED", fallback: "Why do I need to update?") public enum Header { /// Discover new public static let title1 = DiscoveryLocalization.tr("Localizable", "HEADER.TITLE_1", fallback: "Discover new") diff --git a/Discovery/Discovery/en.lproj/Localizable.strings b/Discovery/Discovery/en.lproj/Localizable.strings index d5502912c..074eb6cbd 100644 --- a/Discovery/Discovery/en.lproj/Localizable.strings +++ b/Discovery/Discovery/en.lproj/Localizable.strings @@ -13,3 +13,16 @@ "SEARCH.TITLE" = "Search results"; "SEARCH.EMPTY_DESCRIPTION" = "Start typing to find the course"; + +"UPDATE_REQUIRED_TITLE" = "App Update Required"; +"UPDATE_REQUIRED_DESCRIPTION" = "This version of the OpenEdX app is out-of-date. To continue learning and get the latest features and fixes, please upgrade to the latest version."; +"UPDATE_WHY_NEED" = "Why do I need to update?"; +"UPDATE_DEPRECATED_APP" = "Deprecated App Version"; +"UPDATE_BUTTON" = "Update"; +"UPDATE_ACCOUNT_SETTINGS" = "Account Settings"; + +"UPDATE_NEEDED_TITLE" = "App Update"; +"UPDATE_NEEDED_DESCRIPTION" = "We recommend that you update to the latest version. Upgrade now to receive the latest features and fixes."; +"UPDATE_NEEDED_NOT_NOW" = "Not Now"; + +"UPDATE_NEW_AVALIABLE" = "New update available! Upgrade now to receive the latest features and fixes"; diff --git a/Discovery/Discovery/uk.lproj/Localizable.strings b/Discovery/Discovery/uk.lproj/Localizable.strings index 8f5218a53..fd43d1635 100644 --- a/Discovery/Discovery/uk.lproj/Localizable.strings +++ b/Discovery/Discovery/uk.lproj/Localizable.strings @@ -13,3 +13,16 @@ "SEARCH.TITLE" = "Результати пошуку"; "SEARCH.EMPTY_DESCRIPTION" = "Почніть вводити текст, щоб знайти курс"; + +"UPDATE_REQUIRED_TITLE" = "Потрібне оновлення додатка"; +"UPDATE_REQUIRED_DESCRIPTION" = "Ця версія додатка OpenEdX застаріла. Щоб продовжити навчання та отримати останні функції та виправлення, оновіться до останньої версії."; +"UPDATE_WHY_NEED" = "Чому я маю оновити програму?"; +"UPDATE_DEPRECATED_APP" = "Застаріла версія додатка"; +"UPDATE_BUTTON" = "Оновити"; +"UPDATE_ACCOUNT_SETTINGS" = "Налаштування"; + +"UPDATE_NEEDED_TITLE" = "Оновлення додатку"; +"UPDATE_NEEDED_DESCRIPTION" = "Ми рекомендуємо вам оновити додаток до останньої версії. Оновіть зараз, щоб отримати нові функції та виправлення."; +"UPDATE_NEEDED_NOT_NOW" = "Не зараз"; + +"UPDATE_NEW_AVALIABLE" = "Доступне нове оновлення! Оновіть зараз, щоб отримати найновіші функції та виправлення"; diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 1eb44a322..d49e7424f 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT @@ -490,9 +490,9 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -545,7 +545,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -578,7 +578,7 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -630,7 +630,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -647,7 +647,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -678,7 +678,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -707,8 +707,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) diff --git a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift index a31924505..01ed44919 100644 --- a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift @@ -26,7 +26,11 @@ final class DiscoveryViewModelTests: XCTestCase { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() let analytics = DiscoveryAnalyticsMock() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: interactor, + connectivity: connectivity, + analytics: analytics) let items = [ CourseItem(name: "Test", @@ -71,8 +75,11 @@ final class DiscoveryViewModelTests: XCTestCase { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() let analytics = DiscoveryAnalyticsMock() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) - + let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: interactor, + connectivity: connectivity, + analytics: analytics) let items = [ CourseItem(name: "Test", org: "org", @@ -115,8 +122,11 @@ final class DiscoveryViewModelTests: XCTestCase { let interactor = DiscoveryInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DiscoveryAnalyticsMock() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) - + let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: interactor, + connectivity: connectivity, + analytics: analytics) let items = [ CourseItem(name: "Test", org: "org", @@ -161,7 +171,11 @@ final class DiscoveryViewModelTests: XCTestCase { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() let analytics = DiscoveryAnalyticsMock() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: interactor, + connectivity: connectivity, + analytics: analytics) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -180,7 +194,11 @@ final class DiscoveryViewModelTests: XCTestCase { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() let analytics = DiscoveryAnalyticsMock() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: interactor, + connectivity: connectivity, + analytics: analytics) let noInternetError = AFError.sessionInvalidated(error: NSError()) diff --git a/Discovery/Mockfile b/Discovery/Mockfile index 3940f8cf2..638dccd32 100644 --- a/Discovery/Mockfile +++ b/Discovery/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/Discussion/Discussion.xcodeproj.xcworkspace/contents.xcworkspacedata b/Discussion/Discussion.xcodeproj.xcworkspace/contents.xcworkspacedata index 85b36c90c..d64d30457 100644 --- a/Discussion/Discussion.xcodeproj.xcworkspace/contents.xcworkspacedata +++ b/Discussion/Discussion.xcodeproj.xcworkspace/contents.xcworkspacedata @@ -28,4 +28,7 @@ + + diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 424aa9aaf..775ffc794 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT @@ -490,9 +490,9 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -545,7 +545,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -578,7 +578,7 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -630,7 +630,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -647,7 +647,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -678,7 +678,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -707,8 +707,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) @@ -2033,9 +2033,9 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -2094,7 +2094,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -2165,7 +2165,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -2223,7 +2223,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -2246,7 +2246,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -2283,7 +2283,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -2330,8 +2330,8 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) diff --git a/Discussion/Mockfile b/Discussion/Mockfile index 4b981a270..dc4c39594 100644 --- a/Discussion/Mockfile +++ b/Discussion/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/MockTemplate.swifttemplate b/MockTemplate.swifttemplate new file mode 100644 index 000000000..95d0d857a --- /dev/null +++ b/MockTemplate.swifttemplate @@ -0,0 +1,2133 @@ +<%_ +let mockTypeName = "Mock" +func swiftLintRules(_ arguments: [String: Any]) -> [String] { + return stringArray(fromArguments: arguments, forKey: "excludedSwiftLintRules").map { rule in + return "//swiftlint:disable \(rule)" + } +} + +func projectImports(_ arguments: [String: Any]) -> [String] { + return imports(arguments) + testableImports(arguments) +} + +func imports(_ arguments: [String: Any]) -> [String] { + return stringArray(fromArguments: arguments, forKey: "import") + .map { return "import \($0)" } +} + +func testableImports(_ arguments: [String: Any]) -> [String] { + return stringArray(fromArguments: arguments, forKey: "testable") + .map { return "@testable import \($0)" } +} + +/// [Internal] Get value from dictionary +/// - Parameters: +/// - fromArguments: dictionary +/// - forKey: dictionary key +/// - Returns: array of strings, if key not found, returns empty array. +/// - Note: If sourcery arguments containts only one element, then single value is stored, otherwise array of elements. This method always gets array of elements. +func stringArray(fromArguments arguments: [String: Any], forKey key: String) -> [String] { + + if let argument = arguments[key] as? String { + return [argument] + } else if let manyArguments = arguments[key] as? [String] { + return manyArguments + } else { + return [] + } +} +_%> +// Generated with SwiftyMocky 4.2.0 +// Required Sourcery: 1.8.0 + +<%_ for rule in swiftLintRules(argument) { -%> + <%_ %><%= rule %> +<%_ } -%> + +import SwiftyMocky +import XCTest +<%# ================================================== IMPORTS -%><%_ -%> + <%_ for projectImport in projectImports(argument) { -%> + <%_ %><%= projectImport %> + <%_ } -%> + <%# ============================ IMPORTS InAPP (aggregated argument) -%><%_ -%> + <%_ if let swiftyMockyArgs = argument["swiftyMocky"] as? [String: Any] { -%> + <%_ for projectImport in projectImports(swiftyMockyArgs) { -%> + <%_ %><%= projectImport %> + <%_ } -%> + <%_ } -%> +<%_ +class Current { + static var selfType: String = "Self" + static var accessModifier: String = "open" +} +// Collision management +func areThereCollisions(between methods: [MethodWrapper]) -> Bool { + let givenSet = Set(methods.map({ $0.givenConstructorName(prefix: "") })) + guard givenSet.count == methods.count else { return true } // there would be conflicts in Given + let verifySet = Set(methods.map({ $0.verificationProxyConstructorName(prefix: "") })) + guard verifySet.count == methods.count else { return true } // there would be conflicts in Verify + return false +} + +// herlpers +func uniques(methods: [SourceryRuntime.Method]) -> [SourceryRuntime.Method] { + func returnTypeStripped(_ method: SourceryRuntime.Method) -> String { + let returnTypeRaw = "\(method.returnTypeName)" + var stripped: String = { + guard let range = returnTypeRaw.range(of: "where") else { return returnTypeRaw } + var stripped = returnTypeRaw + stripped.removeSubrange((range.lowerBound)...) + return stripped + }() + stripped = stripped.trimmingCharacters(in: CharacterSet(charactersIn: " ")) + return stripped + } + + func areSameParams(_ p1: SourceryRuntime.MethodParameter, _ p2: SourceryRuntime.MethodParameter) -> Bool { + guard p1.argumentLabel == p2.argumentLabel else { return false } + guard p1.name == p2.name else { return false } + guard p1.argumentLabel == p2.argumentLabel else { return false } + guard p1.typeName.name == p2.typeName.name else { return false } + guard p1.actualTypeName?.name == p2.actualTypeName?.name else { return false } + return true + } + + func areSameMethods(_ m1: SourceryRuntime.Method, _ m2: SourceryRuntime.Method) -> Bool { + guard m1.name != m2.name else { return m1.returnTypeName == m2.returnTypeName } + guard m1.selectorName == m2.selectorName else { return false } + guard m1.parameters.count == m2.parameters.count else { return false } + + let p1 = m1.parameters + let p2 = m2.parameters + + for i in 0.. [SourceryRuntime.Method] in + guard !result.contains(where: { areSameMethods($0,element) }) else { return result } + return result + [element] + }) +} + +func uniquesWithoutGenericConstraints(methods: [SourceryRuntime.Method]) -> [SourceryRuntime.Method] { + func returnTypeStripped(_ method: SourceryRuntime.Method) -> String { + let returnTypeRaw = "\(method.returnTypeName)" + var stripped: String = { + guard let range = returnTypeRaw.range(of: "where") else { return returnTypeRaw } + var stripped = returnTypeRaw + stripped.removeSubrange((range.lowerBound)...) + return stripped + }() + stripped = stripped.trimmingCharacters(in: CharacterSet(charactersIn: " ")) + return stripped + } + + func areSameParams(_ p1: SourceryRuntime.MethodParameter, _ p2: SourceryRuntime.MethodParameter) -> Bool { + guard p1.argumentLabel == p2.argumentLabel else { return false } + guard p1.name == p2.name else { return false } + guard p1.argumentLabel == p2.argumentLabel else { return false } + guard p1.typeName.name == p2.typeName.name else { return false } + guard p1.actualTypeName?.name == p2.actualTypeName?.name else { return false } + return true + } + + func areSameMethods(_ m1: SourceryRuntime.Method, _ m2: SourceryRuntime.Method) -> Bool { + guard m1.name != m2.name else { return returnTypeStripped(m1) == returnTypeStripped(m2) } + guard m1.selectorName == m2.selectorName else { return false } + guard m1.parameters.count == m2.parameters.count else { return false } + + let p1 = m1.parameters + let p2 = m2.parameters + + for i in 0.. [SourceryRuntime.Method] in + guard !result.contains(where: { areSameMethods($0,element) }) else { return result } + return result + [element] + }) +} + +func uniques(variables: [SourceryRuntime.Variable]) -> [SourceryRuntime.Variable] { + return variables.reduce([], { (result, element) -> [SourceryRuntime.Variable] in + guard !result.contains(where: { $0.name == element.name }) else { return result } + return result + [element] + }) +} + +func wrapMethod(_ method: SourceryRuntime.Method) -> MethodWrapper { + return MethodWrapper(method) +} + +func wrapSubscript(_ wrapped: SourceryRuntime.Subscript) -> SubscriptWrapper { + return SubscriptWrapper(wrapped) +} + +func justWrap(_ variable: SourceryRuntime.Variable) -> VariableWrapper { return wrapProperty(variable) } +func wrapProperty(_ variable: SourceryRuntime.Variable, _ scope: String = "") -> VariableWrapper { + return VariableWrapper(variable, scope: scope) +} + +func stubProperty(_ variable: SourceryRuntime.Variable, _ scope: String) -> String { + let wrapper = VariableWrapper(variable, scope: scope) + return "\(wrapper.prototype)\n\t\(wrapper.privatePrototype)" +} + +func propertyTypes(_ variable: SourceryRuntime.Variable) -> String { + let wrapper = VariableWrapper(variable, scope: "scope") + return "\(wrapper.propertyGet())" + (wrapper.readonly ? "" : "\n\t\t\(wrapper.propertySet())") +} + +func propertyMethodTypes(_ variable: SourceryRuntime.Variable) -> String { + let wrapper = VariableWrapper(variable, scope: "") + return "\(wrapper.propertyCaseGet())" + (wrapper.readonly ? "" : "\n\t\t\(wrapper.propertyCaseSet())") +} + +func propertyMethodTypesIntValue(_ variable: SourceryRuntime.Variable) -> String { + let wrapper = VariableWrapper(variable, scope: "") + return "\(wrapper.propertyCaseGetIntValue())" + (wrapper.readonly ? "" : "\n\t\t\t\(wrapper.propertyCaseSetIntValue())") +} + +func propertyRegister(_ variable: SourceryRuntime.Variable) { + let wrapper = VariableWrapper(variable, scope: "") + MethodWrapper.register(wrapper.propertyCaseGetName,wrapper.propertyCaseGetName,wrapper.propertyCaseGetName) + guard !wrapper.readonly else { return } + MethodWrapper.register(wrapper.propertyCaseSetName,wrapper.propertyCaseSetName,wrapper.propertyCaseGetName) +} +class Helpers { + static func split(_ string: String, byFirstOccurenceOf word: String) -> (String, String) { + guard let wordRange = string.range(of: word) else { return (string, "") } + let selfRange = string.range(of: string)! + let before = String(string[selfRange.lowerBound.. [String]? { + if let types = annotated.annotations["associatedtype"] as? [String] { + return types.reversed() + } else if let type = annotated.annotations["associatedtype"] as? String { + return [type] + } else { + return nil + } + } + static func extractWhereClause(from annotated: SourceryRuntime.Annotated) -> String? { + if let constraints = annotated.annotations["where"] as? [String] { + return " where \(constraints.reversed().joined(separator: ", "))" + } else if let constraint = annotated.annotations["where"] as? String { + return " where \(constraint)" + } else { + return nil + } + } + /// Extract all typealiases from "annotations" + static func extractTypealiases(from annotated: SourceryRuntime.Annotated) -> [String] { + if let types = annotated.annotations["typealias"] as? [String] { + return types.reversed() + } else if let type = annotated.annotations["typealias"] as? String { + return [type] + } else { + return [] + } + } + static func extractGenericsList(_ associatedTypes: [String]?) -> [String] { + return associatedTypes?.flatMap { + split($0, byFirstOccurenceOf: " where ").0.replacingOccurrences(of: " ", with: "").split(separator: ":").map(String.init).first + }.map { "\($0)" } ?? [] + } + static func extractGenericTypesModifier(_ associatedTypes: [String]?) -> String { + let all = extractGenericsList(associatedTypes) + guard !all.isEmpty else { return "" } + return "<\(all.joined(separator: ","))>" + } + static func extractGenericTypesConstraints(_ associatedTypes: [String]?) -> String { + guard let all = associatedTypes else { return "" } + let constraints = all.flatMap { t -> String? in + let splitted = split(t, byFirstOccurenceOf: " where ") + let constraint = splitted.0.replacingOccurrences(of: " ", with: "").split(separator: ":").map(String.init) + guard constraint.count == 2 else { return nil } + let adopts = constraint[1].split(separator: ",").map(String.init) + var mapped = adopts.map { "\(constraint[0]): \($0)" } + if !splitted.1.isEmpty { + mapped.append(splitted.1) + } + return mapped.joined(separator: ", ") + } + .joined(separator: ", ") + guard !constraints.isEmpty else { return "" } + return " where \(constraints)" + } + static func extractAttributes( + from attributes: [String: [SourceryRuntime.Attribute]], + filterOutStartingWith disallowedPrefixes: [String] = [] + ) -> String { + return attributes + .reduce([SourceryRuntime.Attribute]()) { $0 + $1.1 } + .map { $0.description } + .filter { !["private", "internal", "public", "open", "optional"].contains($0) } + .filter { element in + !disallowedPrefixes.contains(where: element.hasPrefix) + } + .sorted() + .joined(separator: " ") + } +} +class ParameterWrapper { + let parameter: MethodParameter + + var isVariadic = false + + var wrappedForCall: String { + let typeString = "\(type.actualTypeName ?? type)" + let isEscaping = typeString.contains("@escaping") + let isOptional = (type.actualTypeName ?? type).isOptional + if parameter.isClosure && !isEscaping && !isOptional { + return "\(nestedType).any" + } else { + return "\(nestedType).value(\(escapedName))" + } + } + var nestedType: String { + return "\(TypeWrapper(type, isVariadic).nestedParameter)" + } + var justType: String { + return "\(TypeWrapper(type, isVariadic).replacingSelf())" + } + var justPerformType: String { + return "\(TypeWrapper(type, isVariadic).replacingSelfRespectingVariadic())".replacingOccurrences(of: "!", with: "?") + } + var genericType: String { + return isVariadic ? "Parameter<[GenericAttribute]>" : "Parameter" + } + var typeErasedType: String { + return isVariadic ? "Parameter<[TypeErasedAttribute]>" : "Parameter" + } + var type: SourceryRuntime.TypeName { + return parameter.typeName + } + var name: String { + return parameter.name + } + var escapedName: String { + return "`\(parameter.name)`" + } + var comparator: String { + return "guard Parameter.compare(lhs: lhs\(parameter.name.capitalized), rhs: rhs\(parameter.name.capitalized), with: matcher) else { return false }" + } + func comparatorResult() -> String { + let lhsName = "lhs\(parameter.name.capitalized)" + let rhsName = "rhs\(parameter.name.capitalized)" + return "results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: \(lhsName), rhs: \(rhsName), with: matcher), \(lhsName), \(rhsName), \"\(labelAndName())\"))" + } + + init(_ parameter: SourceryRuntime.MethodParameter, _ variadics: [String] = []) { + self.parameter = parameter + self.isVariadic = !variadics.isEmpty && variadics.contains(parameter.name) + } + + func isGeneric(_ types: [String]) -> Bool { + return TypeWrapper(type).isGeneric(types) + } + + func wrappedForProxy(_ generics: [String], _ availability: Bool = false) -> String { + if isGeneric(generics) { + return "\(escapedName).wrapAsGeneric()" + } + if (availability) { + return "\(escapedName).typeErasedAttribute()" + } + return "\(escapedName)" + } + func wrappedForCalls(_ generics: [String], _ availability: Bool = false) -> String { + if isGeneric(generics) { + return "\(wrappedForCall).wrapAsGeneric()" + } + if (availability) { + return "\(wrappedForCall).typeErasedAttribute()" + } + return "\(wrappedForCall)" + } + + func asMethodArgument() -> String { + if parameter.argumentLabel != parameter.name { + return "\(parameter.argumentLabel ?? "_") \(parameter.name): \(parameter.typeName)" + } else { + return "\(parameter.name): \(parameter.typeName)" + } + } + func labelAndName() -> String { + let label = parameter.argumentLabel ?? "_" + return label != parameter.name ? "\(label) \(parameter.name)" : label + } + func sanitizedForEnumCaseName() -> String { + if let label = parameter.argumentLabel, label != parameter.name { + return "\(label)_\(parameter.name)".replacingOccurrences(of: "`", with: "") + } else { + return "\(parameter.name)".replacingOccurrences(of: "`", with: "") + } + } +} +class TypeWrapper { + let type: SourceryRuntime.TypeName + let isVariadic: Bool + + var vPref: String { return isVariadic ? "[" : "" } + var vSuff: String { return isVariadic ? "]" : "" } + + var unwrapped: String { + return type.unwrappedTypeName + } + var unwrappedReplacingSelf: String { + return replacingSelf(unwrap: true) + } + var stripped: String { + if type.isImplicitlyUnwrappedOptional { + return "\(vPref)\(unwrappedReplacingSelf)?\(vSuff)" + } else if type.isOptional { + return "\(vPref)\(unwrappedReplacingSelf)?\(vSuff)" + } else { + return "\(vPref)\(unwrappedReplacingSelf)\(vSuff)" + } + } + var nestedParameter: String { + if type.isImplicitlyUnwrappedOptional { + return "Parameter<\(vPref)\(unwrappedReplacingSelf)?\(vSuff)>" + } else if type.isOptional { + return "Parameter<\(vPref)\(unwrappedReplacingSelf)?\(vSuff)>" + } else { + return "Parameter<\(vPref)\(unwrappedReplacingSelf)\(vSuff)>" + } + } + var isSelfType: Bool { + return unwrapped == "Self" + } + func isSelfTypeRecursive() -> Bool { + if let tuple = type.tuple { + for element in tuple.elements { + guard !TypeWrapper(element.typeName).isSelfTypeRecursive() else { return true } + } + } else if let array = type.array { + return TypeWrapper(array.elementTypeName).isSelfTypeRecursive() + } else if let dictionary = type.dictionary { + guard !TypeWrapper(dictionary.valueTypeName).isSelfTypeRecursive() else { return true } + guard !TypeWrapper(dictionary.keyTypeName).isSelfTypeRecursive() else { return true } + } else if let closure = type.closure { + guard !TypeWrapper(closure.actualReturnTypeName).isSelfTypeRecursive() else { return true } + for parameter in closure.parameters { + guard !TypeWrapper(parameter.typeName).isSelfTypeRecursive() else { return true } + } + } + + return isSelfType + } + + init(_ type: SourceryRuntime.TypeName, _ isVariadic: Bool = false) { + self.type = type + self.isVariadic = isVariadic + } + + func isGeneric(_ types: [String]) -> Bool { + guard !type.isVoid else { return false } + + return isGeneric(name: unwrapped, generics: types) + } + + private func isGeneric(name: String, generics: [String]) -> Bool { + let name = "(\(name.replacingOccurrences(of: " ", with: "")))" + let modifiers = "[\\?\\!]*" + return generics.contains(where: { generic in + let wrapped = "([\\(]\(generic)\(modifiers)[\\)\\.])" + let constraint = "([<,]\(generic)\(modifiers)[>,\\.])" + let arrays = "([\\[:]\(generic)\(modifiers)[\\],\\.:])" + let tuples = "([\\(,]\(generic)\(modifiers)[,\\.\\)])" + let closures = "((\\-\\>)\(generic)\(modifiers)[,\\.\\)])" + let pattern = "\(wrapped)|\(constraint)|\(arrays)|\(tuples)|\(closures)" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return false } + return regex.firstMatch(in: name, options: [], range: NSRange(location: 0, length: (name as NSString).length)) != nil + }) + } + + func replacingSelf(unwrap: Bool = false) -> String { + guard isSelfTypeRecursive() else { + return unwrap ? self.unwrapped : "\(type)" + } + + if isSelfType { + let optionality: String = { + if type.isImplicitlyUnwrappedOptional { + return "!" + } else if type.isOptional { + return "?" + } else { + return "" + } + }() + return unwrap ? Current.selfType : Current.selfType + optionality + } else if let tuple = type.tuple { + let inner = tuple.elements.map({ TypeWrapper($0.typeName).replacingSelf() }).joined(separator: ",") + let value = "(\(inner))" + return value + } else if let array = type.array { + let value = "[\(TypeWrapper(array.elementTypeName).replacingSelf())]" + return value + } else if let dictionary = type.dictionary { + let value = "[" + + "\(TypeWrapper(dictionary.valueTypeName).replacingSelf())" + + ":" + + "\(TypeWrapper(dictionary.keyTypeName).replacingSelf())" + + "]" + return value + } else if let closure = type.closure { + let returnType = TypeWrapper(closure.actualReturnTypeName).replacingSelf() + let inner = closure.parameters + .map { TypeWrapper($0.typeName).replacingSelf() } + .joined(separator: ",") + let throwing = closure.throws ? "throws " : "" + let value = "(\(inner)) \(throwing)-> \(returnType)" + return value + } else { + return (unwrap ? self.unwrapped : "\(type)") + } + } + + func replacingSelfRespectingVariadic() -> String { + return "\(vPref)\(replacingSelf())\(vSuff)" + } +} +func replacingSelf(_ value: String) -> String { + return value + // TODO: proper regex here + // default < case > + .replacingOccurrences(of: "", with: "<\(Current.selfType)>") + .replacingOccurrences(of: "", with: " \(Current.selfType)>") + .replacingOccurrences(of: ",Self>", with: ",\(Current.selfType)>") + // (Self) -> Case + .replacingOccurrences(of: "(Self)", with: "(\(Current.selfType))") + .replacingOccurrences(of: "(Self ", with: "(\(Current.selfType) ") + .replacingOccurrences(of: "(Self.", with: "(\(Current.selfType).") + .replacingOccurrences(of: "(Self,", with: "(\(Current.selfType),") + .replacingOccurrences(of: "(Self?", with: "(\(Current.selfType)?") + .replacingOccurrences(of: " Self)", with: " \(Current.selfType))") + .replacingOccurrences(of: ",Self)", with: ",\(Current.selfType))") + // literals + .replacingOccurrences(of: "[Self]", with: "[\(Current.selfType)]") + // right + .replacingOccurrences(of: "[Self ", with: "[\(Current.selfType) ") + .replacingOccurrences(of: "[Self.", with: "[\(Current.selfType).") + .replacingOccurrences(of: "[Self,", with: "[\(Current.selfType),") + .replacingOccurrences(of: "[Self:", with: "[\(Current.selfType):") + .replacingOccurrences(of: "[Self?", with: "[\(Current.selfType)?") + // left + .replacingOccurrences(of: " Self]", with: " \(Current.selfType)]") + .replacingOccurrences(of: ",Self]", with: ",\(Current.selfType)]") + .replacingOccurrences(of: ":Self]", with: ":\(Current.selfType)]") + // unknown + .replacingOccurrences(of: " Self ", with: " \(Current.selfType) ") + .replacingOccurrences(of: " Self.", with: " \(Current.selfType).") + .replacingOccurrences(of: " Self,", with: " \(Current.selfType),") + .replacingOccurrences(of: " Self:", with: " \(Current.selfType):") + .replacingOccurrences(of: " Self?", with: " \(Current.selfType)?") + .replacingOccurrences(of: ",Self ", with: ",\(Current.selfType) ") + .replacingOccurrences(of: ",Self,", with: ",\(Current.selfType),") + .replacingOccurrences(of: ",Self?", with: ",\(Current.selfType)?") +} + +class MethodWrapper { + private var noStubDefinedMessage: String { + let methodName = method.name.condenseWhitespace() + .replacingOccurrences(of: "( ", with: "(") + .replacingOccurrences(of: " )", with: ")") + return "Stub return value not specified for \(methodName). Use given" + } + private static var registered: [String: Int] = [:] + private static var suffixes: [String: Int] = [:] + private static var suffixesWithoutReturnType: [String: Int] = [:] + + let method: SourceryRuntime.Method + var accessModifier: String { + guard !method.isStatic else { return "public static" } + guard !returnsGenericConstrainedToSelf else { return "public" } + guard !parametersContainsSelf else { return "public" } + return Current.accessModifier + } + var hasAvailability: Bool { method.attributes["available"]?.isEmpty == false } + var isAsync: Bool { + self.method.annotations["async"] != nil + } + + private var registrationName: String { + var rawName = (method.isStatic ? "sm*\(method.selectorName)" : "m*\(method.selectorName)") + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: "(", with: "__") + .replacingOccurrences(of: ")", with: "") + + var parametersNames = method.parameters.map { "\($0.name)" } + + while let range = rawName.range(of: ":"), let name = parametersNames.first { + parametersNames.removeFirst() + rawName.replaceSubrange(range, with: "_\(name)") + } + + let trimSet = CharacterSet(charactersIn: "_") + + return rawName + .replacingOccurrences(of: ":", with: "") + .replacingOccurrences(of: "m*", with: "m_") + .replacingOccurrences(of: "___", with: "__").trimmingCharacters(in: trimSet) + } + private var uniqueName: String { + var rawName = (method.isStatic ? "sm_\(method.selectorName)" : "m_\(method.selectorName)") + var parametersNames = method.parameters.map { "\($0.name)_of_\($0.typeName.name)" } + + while let range = rawName.range(of: ":"), let name = parametersNames.first { + parametersNames.removeFirst() + rawName.replaceSubrange(range, with: "_\(name)") + } + + return rawName.trimmingCharacters(in: CharacterSet(charactersIn: "_")) + } + private var uniqueNameWithReturnType: String { + let returnTypeRaw = "\(method.returnTypeName)" + var returnTypeStripped: String = { + guard let range = returnTypeRaw.range(of: "where") else { return returnTypeRaw } + var stripped = returnTypeRaw + stripped.removeSubrange((range.lowerBound)...) + return stripped + }() + returnTypeStripped = returnTypeStripped.trimmingCharacters(in: CharacterSet(charactersIn: " ")) + return "\(uniqueName)->\(returnTypeStripped)" + } + private var nameSuffix: String { + guard let count = MethodWrapper.registered[registrationName] else { return "" } + guard count > 1 else { return "" } + guard let index = MethodWrapper.suffixes[uniqueNameWithReturnType] else { return "" } + return "_\(index)" + } + private var methodAttributes: String { + return Helpers.extractAttributes(from: self.method.attributes, filterOutStartingWith: ["mutating", "@inlinable"]) + } + private var methodAttributesNonObjc: String { + return Helpers.extractAttributes(from: self.method.attributes, filterOutStartingWith: ["mutating", "@inlinable", "@objc"]) + } + + var prototype: String { + return "\(registrationName)\(nameSuffix)".replacingOccurrences(of: "`", with: "") + } + var parameters: [ParameterWrapper] { + return filteredParameters.map { ParameterWrapper($0, self.getVariadicParametersNames()) } + } + var filteredParameters: [MethodParameter] { + return method.parameters.filter { $0.name != "" } + } + var functionPrototype: String { + let throwing: String = { + if method.throws { + return "throws " + } else if method.rethrows { + return "rethrows " + } else { + return "" + } + }() + + let staticModifier: String = "\(accessModifier) " + let params = replacingSelf(parametersForStubSignature()) + var attributes = self.methodAttributes + attributes = attributes.isEmpty ? "" : "\(attributes)\n\t" + var asyncModifier = self.isAsync ? "async " : "" + + if method.isInitializer { + return "\(attributes)public required \(method.name) \(asyncModifier)\(throwing)" + } else if method.returnTypeName.isVoid { + let wherePartIfNeeded: String = { + if method.returnTypeName.name.hasPrefix("Void") { + let range = method.returnTypeName.name.range(of: "Void")! + return "\(method.returnTypeName.name[range.upperBound...])" + } else { + return !method.returnTypeName.name.isEmpty ? "\(method.returnTypeName.name) " : "" + } + }() + return "\(attributes)\(staticModifier)func \(method.shortName)\(params) \(asyncModifier)\(throwing)\(wherePartIfNeeded)" + } else if returnsGenericConstrainedToSelf { + return "\(attributes)\(staticModifier)func \(method.shortName)\(params) \(asyncModifier)\(throwing)-> \(returnTypeReplacingSelf) " + } else { + return "\(attributes)\(staticModifier)func \(method.shortName)\(params) \(asyncModifier)\(throwing)-> \(method.returnTypeName.name) " + } + } + var invocation: String { + guard !method.isInitializer else { return "" } + if filteredParameters.isEmpty { + return "addInvocation(.\(prototype))" + } else { + return "addInvocation(.\(prototype)(\(parametersForMethodCall())))" + } + } + var givenValue: String { + guard !method.isInitializer else { return "" } + guard method.throws || !method.returnTypeName.isVoid else { return "" } + + let methodType = filteredParameters.isEmpty ? ".\(prototype)" : ".\(prototype)(\(parametersForMethodCall()))" + let returnType: String = returnsSelf ? "__Self__" : "\(TypeWrapper(method.returnTypeName).stripped)" + + if method.returnTypeName.isVoid { + return """ + \n\t\tdo { + \t\t _ = try methodReturnValue(\(methodType)).casted() as Void + \t\t}\(" ") + """ + } else { + let defaultValue = method.returnTypeName.isOptional ? " = nil" : "" + return """ + \n\t\tvar __value: \(returnType)\(defaultValue) + \t\tdo { + \t\t __value = try methodReturnValue(\(methodType)).casted() + \t\t}\(" ") + """ + } + } + var throwValue: String { + guard !method.isInitializer else { return "" } + guard method.throws || !method.returnTypeName.isVoid else { return "" } + let safeFailure = method.isStatic ? "" : "\t\t\tonFatalFailure(\"\(noStubDefinedMessage)\")\n" + // For Void and Returning optionals - we allow not stubbed case to happen, as we are still able to return + let noStubHandling = method.returnTypeName.isVoid || method.returnTypeName.isOptional ? "\t\t\t// do nothing" : "\(safeFailure)\t\t\tFailure(\"\(noStubDefinedMessage)\")" + guard method.throws else { + return """ + catch { + \(noStubHandling) + \t\t} + """ + } + + return """ + catch MockError.notStubed { + \(noStubHandling) + \t\t} catch { + \t\t throw error + \t\t} + """ + } + var returnValue: String { + guard !method.isInitializer else { return "" } + guard !method.returnTypeName.isVoid else { return "" } + + return "\n\t\treturn __value" + } + var equalCase: String { + guard !method.isInitializer else { return "" } + + if filteredParameters.isEmpty { + return "case (.\(prototype), .\(prototype)):" + } else { + let lhsParams = filteredParameters.map { "let lhs\($0.name.capitalized)" }.joined(separator: ", ") + let rhsParams = filteredParameters.map { "let rhs\($0.name.capitalized)" }.joined(separator: ", ") + return "case (.\(prototype)(\(lhsParams)), .\(prototype)(\(rhsParams))):" + } + } + func equalCases() -> String { + var results = self.equalCase + + guard !parameters.isEmpty else { + results += " return .match" + return results + } + + results += "\n\t\t\t\tvar results: [Matcher.ParameterComparisonResult] = []\n" + results += parameters.map { "\t\t\t\t\($0.comparatorResult())" }.joined(separator: "\n") + results += "\n\t\t\t\treturn Matcher.ComparisonResult(results)" + return results + } + var intValueCase: String { + if filteredParameters.isEmpty { + return "case .\(prototype): return 0" + } else { + let params = filteredParameters.enumerated().map { offset, _ in + return "p\(offset)" + } + let definitions = params.joined(separator: ", ") + let paramsSum = params.map({ "\($0).intValue" }).joined(separator: " + ") + return "case let .\(prototype)(\(definitions)): return \(paramsSum)" + } + } + var assertionName: String { + return "case .\(prototype): return \".\(method.selectorName)\(method.parameters.isEmpty ? "()" : "")\"" + } + + var returnsSelf: Bool { + guard !returnsGenericConstrainedToSelf else { return true } + return !method.returnTypeName.isVoid && TypeWrapper(method.returnTypeName).isSelfType + } + var returnsGenericConstrainedToSelf: Bool { + let defaultReturnType = "\(method.returnTypeName.name) " + return defaultReturnType != returnTypeReplacingSelf + } + var returnTypeReplacingSelf: String { + return replacingSelf("\(method.returnTypeName.name) ") + } + var parametersContainsSelf: Bool { + return replacingSelf(parametersForStubSignature()) != parametersForStubSignature() + } + + var replaceSelf: String { + return Current.selfType + } + + init(_ method: SourceryRuntime.Method) { + self.method = method + } + + public static func clear() -> String { + MethodWrapper.registered = [:] + MethodWrapper.suffixes = [:] + MethodWrapper.suffixesWithoutReturnType = [:] + return "" + } + + func register() { + MethodWrapper.register(registrationName,uniqueName,uniqueNameWithReturnType) + } + + static func register(_ name: String, _ uniqueName: String, _ uniqueNameWithReturnType: String) { + if let count = MethodWrapper.registered[name] { + MethodWrapper.registered[name] = count + 1 + MethodWrapper.suffixes[uniqueNameWithReturnType] = count + 1 + } else { + MethodWrapper.registered[name] = 1 + MethodWrapper.suffixes[uniqueNameWithReturnType] = 1 + } + + if let count = MethodWrapper.suffixesWithoutReturnType[uniqueName] { + MethodWrapper.suffixesWithoutReturnType[uniqueName] = count + 1 + } else { + MethodWrapper.suffixesWithoutReturnType[uniqueName] = 1 + } + } + + func returnTypeMatters() -> Bool { + let count = MethodWrapper.suffixesWithoutReturnType[uniqueName] ?? 0 + return count > 1 + } + + func wrappedInMethodType() -> Bool { + return !method.isInitializer + } + + func returningParameter(_ multiple: Bool, _ front: Bool) -> String { + guard returnTypeMatters() else { return "" } + let returning: String = "returning: \(returnTypeStripped(method, type: true))" + guard multiple else { return returning } + + return front ? ", \(returning)" : "\(returning), " + } + + // Stub + func stubBody() -> String { + let body: String = { + if method.isInitializer || !returnsSelf { + return invocation + performCall() + givenValue + throwValue + returnValue + } else { + return wrappedStubPrefix() + + "\t\t" + invocation + + performCall() + + givenValue + + throwValue + + returnValue + + wrappedStubPostfix() + } + }() + return replacingSelf(body) + } + + func wrappedStubPrefix() -> String { + guard !method.isInitializer, returnsSelf else { + return "" + } + + let throwing: String = { + if method.throws { + return "throws " + } else if method.rethrows { + return "rethrows " + } else { + return "" + } + }() + + return "func _wrapped<__Self__>() \(throwing)-> __Self__ {\n" + } + + func wrappedStubPostfix() -> String { + guard !method.isInitializer, returnsSelf else { + return "" + } + + let throwing: String = (method.throws || method.rethrows) ? "try ": "" + + return "\n\t\t}" + + "\n\t\treturn \(throwing)_wrapped()" + } + + // Method Type + func methodTypeDeclarationWithParameters() -> String { + if filteredParameters.isEmpty { + return "case \(prototype)" + } else { + return "case \(prototype)(\(parametersForMethodTypeDeclaration(availability: hasAvailability)))" + } + } + + // Given + func containsEmptyArgumentLabels() -> Bool { + return parameters.contains(where: { $0.parameter.argumentLabel == nil }) + } + + func givenReturnTypeString() -> String { + let returnTypeString: String = { + guard !returnsGenericConstrainedToSelf else { return returnTypeReplacingSelf } + guard !returnsSelf else { return replaceSelf } + return TypeWrapper(method.returnTypeName).stripped + }() + return returnTypeString + } + + func givenConstructorName(prefix: String = "") -> String { + let returnTypeString = givenReturnTypeString() + let (annotation, _, _) = methodInfo() + let clauseConstraints = whereClauseExpression() + + if filteredParameters.isEmpty { + return "\(annotation)public static func \(method.shortName)(willReturn: \(returnTypeString)...) -> \(prefix)MethodStub" + clauseConstraints + } else { + return "\(annotation)public static func \(method.shortName)(\(parametersForProxySignature()), willReturn: \(returnTypeString)...) -> \(prefix)MethodStub" + clauseConstraints + } + } + + func givenConstructorNameThrows(prefix: String = "") -> String { + let (annotation, _, _) = methodInfo() + let clauseConstraints = whereClauseExpression() + + let genericsArray = getGenericsConstraints(getGenericsAmongParameters(), filterSingle: false) + let generics = genericsArray.isEmpty ? "" : "<\(genericsArray.joined(separator: ", "))>" + + if filteredParameters.isEmpty { + return "\(annotation)public static func \(method.callName)\(generics)(willThrow: Error...) -> \(prefix)MethodStub" + clauseConstraints + } else { + return "\(annotation)public static func \(method.callName)\(generics)(\(parametersForProxySignature()), willThrow: Error...) -> \(prefix)MethodStub" + clauseConstraints + } + } + + func givenConstructor(prefix: String = "") -> String { + if filteredParameters.isEmpty { + return "return \(prefix)Given(method: .\(prototype), products: willReturn.map({ StubProduct.return($0 as Any) }))" + } else { + return "return \(prefix)Given(method: .\(prototype)(\(parametersForProxyInit())), products: willReturn.map({ StubProduct.return($0 as Any) }))" + } + } + + func givenConstructorThrows(prefix: String = "") -> String { + if filteredParameters.isEmpty { + return "return \(prefix)Given(method: .\(prototype), products: willThrow.map({ StubProduct.throw($0) }))" + } else { + return "return \(prefix)Given(method: .\(prototype)(\(parametersForProxyInit())), products: willThrow.map({ StubProduct.throw($0) }))" + } + } + + // Given willProduce + func givenProduceConstructorName(prefix: String = "") -> String { + let returnTypeString = givenReturnTypeString() + let (annotation, _, _) = methodInfo() + let produceClosure = "(Stubber<\(returnTypeString)>) -> Void" + let clauseConstraints = whereClauseExpression() + + if filteredParameters.isEmpty { + return "\(annotation)public static func \(method.shortName)(willProduce: \(produceClosure)) -> \(prefix)MethodStub" + clauseConstraints + } else { + return "\(annotation)public static func \(method.shortName)(\(parametersForProxySignature()), willProduce: \(produceClosure)) -> \(prefix)MethodStub" + clauseConstraints + } + } + + func givenProduceConstructorNameThrows(prefix: String = "") -> String { + let returnTypeString = givenReturnTypeString() + let (annotation, _, _) = methodInfo() + let produceClosure = "(StubberThrows<\(returnTypeString)>) -> Void" + let clauseConstraints = whereClauseExpression() + + if filteredParameters.isEmpty { + return "\(annotation)public static func \(method.shortName)(willProduce: \(produceClosure)) -> \(prefix)MethodStub" + clauseConstraints + } else { + return "\(annotation)public static func \(method.shortName)(\(parametersForProxySignature()), willProduce: \(produceClosure)) -> \(prefix)MethodStub" + clauseConstraints + } + } + + func givenProduceConstructor(prefix: String = "") -> String { + let returnTypeString = givenReturnTypeString() + return """ + let willReturn: [\(returnTypeString)] = [] + \t\t\tlet given: \(prefix)Given = { \(givenConstructor(prefix: prefix)) }() + \t\t\tlet stubber = given.stub(for: (\(returnTypeString)).self) + \t\t\twillProduce(stubber) + \t\t\treturn given + """ + } + + func givenProduceConstructorThrows(prefix: String = "") -> String { + let returnTypeString = givenReturnTypeString() + return """ + let willThrow: [Error] = [] + \t\t\tlet given: \(prefix)Given = { \(givenConstructorThrows(prefix: prefix)) }() + \t\t\tlet stubber = given.stubThrows(for: (\(returnTypeString)).self) + \t\t\twillProduce(stubber) + \t\t\treturn given + """ + } + + // Verify + func verificationProxyConstructorName(prefix: String = "") -> String { + let (annotation, methodName, genericConstrains) = methodInfo() + + if filteredParameters.isEmpty { + return "\(annotation)public static func \(methodName)(\(returningParameter(false,true))) -> \(prefix)Verify\(genericConstrains)" + } else { + return "\(annotation)public static func \(methodName)(\(parametersForProxySignature())\(returningParameter(true,true))) -> \(prefix)Verify\(genericConstrains)" + } + } + + func verificationProxyConstructor(prefix: String = "") -> String { + if filteredParameters.isEmpty { + return "return \(prefix)Verify(method: .\(prototype))" + } else { + return "return \(prefix)Verify(method: .\(prototype)(\(parametersForProxyInit())))" + } + } + + // Perform + func performProxyConstructorName(prefix: String = "") -> String { + let body: String = { + let (annotation, methodName, genericConstrains) = methodInfo() + + if filteredParameters.isEmpty { + return "\(annotation)public static func \(methodName)(\(returningParameter(true,false))perform: @escaping \(performProxyClosureType())) -> \(prefix)Perform\(genericConstrains)" + } else { + return "\(annotation)public static func \(methodName)(\(parametersForProxySignature()), \(returningParameter(true,false))perform: @escaping \(performProxyClosureType())) -> \(prefix)Perform\(genericConstrains)" + } + }() + return replacingSelf(body) + } + + func performProxyConstructor(prefix: String = "") -> String { + if filteredParameters.isEmpty { + return "return \(prefix)Perform(method: .\(prototype), performs: perform)" + } else { + return "return \(prefix)Perform(method: .\(prototype)(\(parametersForProxyInit())), performs: perform)" + } + } + + func performProxyClosureType() -> String { + if filteredParameters.isEmpty { + return "() -> Void" + } else { + let parameters = self.parameters + .map { "\($0.justPerformType)" } + .joined(separator: ", ") + return "(\(parameters)) -> Void" + } + } + + func performProxyClosureCall() -> String { + if filteredParameters.isEmpty { + return "perform?()" + } else { + let parameters = filteredParameters + .map { p in + let wrapped = ParameterWrapper(p, self.getVariadicParametersNames()) + let isAutolosure = wrapped.justType.hasPrefix("@autoclosure") + return "\(p.inout ? "&" : "")`\(p.name)`\(isAutolosure ? "()" : "")" + } + .joined(separator: ", ") + return "perform?(\(parameters))" + } + } + + func performCall() -> String { + guard !method.isInitializer else { return "" } + let type = performProxyClosureType() + var proxy = filteredParameters.isEmpty ? "\(prototype)" : "\(prototype)(\(parametersForMethodCall()))" + + let cast = "let perform = methodPerformValue(.\(proxy)) as? \(type)" + let call = performProxyClosureCall() + + return "\n\t\t\(cast)\n\t\t\(call)" + } + + // Helpers + private func parametersForMethodCall() -> String { + let generics = getGenericsWithoutConstraints() + return parameters.map { $0.wrappedForCalls(generics, hasAvailability) }.joined(separator: ", ") + } + + private func parametersForMethodTypeDeclaration(availability: Bool) -> String { + let generics = getGenericsWithoutConstraints() + return parameters.map { param in + if param.isGeneric(generics) { return param.genericType } + if availability { return param.typeErasedType } + return replacingSelf(param.nestedType) + }.joined(separator: ", ") + } + + private func parametersForProxySignature() -> String { + return parameters.map { p in + return "\(p.labelAndName()): \(replacingSelf(p.nestedType))" + }.joined(separator: ", ") + } + + private func parametersForStubSignature() -> String { + func replacing(first: String, in full: String, with other: String) -> String { + guard let range = full.range(of: first) else { return full } + return full.replacingCharacters(in: range, with: other) + } + let prefix = method.shortName + let full = method.name + let range = full.range(of: prefix)! + var unrefined = "\(full[range.upperBound...])" + parameters.map { p -> (String,String) in + return ("\(p.type)","\(p.justType)") + }.forEach { + unrefined = replacing(first: $0, in: unrefined, with: $1) + } + return unrefined + } + + private func parametersForProxyInit() -> String { + let generics = getGenericsWithoutConstraints() + return parameters.map { "\($0.wrappedForProxy(generics, hasAvailability))" }.joined(separator: ", ") + } + + private func isGeneric() -> Bool { + return method.shortName.contains("<") && method.shortName.contains(">") + } + + private func getVariadicParametersNames() -> [String] { + let pattern = "[\\(|,]( *[_|\\w]* )? *(\\w+) *\\: *(.+?\\.\\.\\.)" + let str = method.name + let range = NSRange(location: 0, length: (str as NSString).length) + + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + + var result: [String] = regex + .matches(in: str, options: [], range: range) + .compactMap { match -> String? in + guard let nameRange = Range(match.range(at: 2), in: str) else { return nil } + return String(str[nameRange]) + } + return result + } + + /// Returns list of generics used in method signature, without their constraints (like [T,U,V]) + /// + /// - Returns: Array of strings, where each strings represent generic name + private func getGenericsWithoutConstraints() -> [String] { + let name = method.shortName + guard let start = name.index(of: "<"), let end = name.index(of: ">") else { return [] } + + var genPart = name[start...end] + genPart.removeFirst() + genPart.removeLast() + + let parts = genPart.replacingOccurrences(of: " ", with: "").split(separator: ",").map(String.init) + return parts.map { stripGenPart(part: $0) } + } + + /// Returns list of generic constraintes from method signature. Does only contain stuff between '<' and '>' + /// + /// - Returns: Array of strings, like ["T: Codable", "U: Whatever"] + private func getGenericsConstraints(_ generics: [String], filterSingle: Bool = true) -> [String] { + let name = method.shortName + guard let start = name.index(of: "<"), let end = name.index(of: ">") else { return [] } + + var genPart = name[start...end] + genPart.removeFirst() + genPart.removeLast() + + let parts = genPart.replacingOccurrences(of: " ", with: "").split(separator: ",").map(String.init) + return parts.filter { + let components = $0.components(separatedBy: ":") + return (components.count == 2 || !filterSingle) && generics.contains(components[0]) + } + } + + private func getGenericsAmongParameters() -> [String] { + return getGenericsWithoutConstraints().filter { + for param in self.parameters { + if param.isGeneric([$0]) { return true } + } + return false + } + } + + private func wrapGenerics(_ generics: [String]) -> String { + guard !generics.isEmpty else { return "" } + return "<\(generics.joined(separator:","))>" + } + + private func stripGenPart(part: String) -> String { + return part.split(separator: ":").map(String.init).first! + } + + private func returnTypeStripped(_ method: SourceryRuntime.Method, type: Bool = false) -> String { + let returnTypeRaw = "\(method.returnTypeName)" + var stripped: String = { + guard let range = returnTypeRaw.range(of: "where") else { return returnTypeRaw } + var stripped = returnTypeRaw + stripped.removeSubrange((range.lowerBound)...) + return stripped + }() + stripped = stripped.trimmingCharacters(in: CharacterSet(charactersIn: " ")) + guard type else { return stripped } + return "(\(stripped)).Type" + } + + private func whereClauseConstraints() -> [String] { + let returnTypeRaw = method.returnTypeName.name + guard let range = returnTypeRaw.range(of: "where") else { return [] } + var whereClause = returnTypeRaw + whereClause.removeSubrange(...(range.upperBound)) + return whereClause + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + .components(separatedBy: ",") + } + + private func whereClauseExpression() -> String { + let constraints = whereClauseConstraints() + if constraints.isEmpty { + return "" + } + return " where " + constraints.joined(separator: ", ") + } + + private func methodInfo() -> (annotation: String, methodName: String, genericConstrains: String) { + let generics = getGenericsAmongParameters() + let methodName = returnTypeMatters() ? method.shortName : "\(method.callName)\(wrapGenerics(generics))" + let constraints: String = { + let constraints: [String] + if returnTypeMatters() { + constraints = whereClauseConstraints() + } else { + constraints = getGenericsConstraints(generics) + } + guard !constraints.isEmpty else { return "" } + + return " where \(constraints.joined(separator: ", "))" + }() + var attributes = self.methodAttributesNonObjc + attributes = attributes.condenseWhitespace() + attributes = attributes.isEmpty ? "" : "\(attributes)\n\t\t" + return (attributes, methodName, constraints) + } +} + +extension String { + func condenseWhitespace() -> String { + let components = self.components(separatedBy: .whitespacesAndNewlines) + return components.filter { !$0.isEmpty }.joined(separator: " ") + } +} +class SubscriptWrapper { + let wrapped: SourceryRuntime.Subscript + var readonly: Bool { return !wrapped.isMutable } + var wrappedParameters: [ParameterWrapper] { return wrapped.parameters.map { ParameterWrapper($0) } } + var casesCount: Int { return readonly ? 1 : 2 } + var nestedType: String { return "\(TypeWrapper(wrapped.returnTypeName).nestedParameter)" } + let associatedTypes: [String]? + let genericTypesList: [String] + let genericTypesModifier: String? + let whereClause: String + var hasAvailability: Bool { wrapped.attributes["available"]?.isEmpty == false } + + private var methodAttributes: String { + return Helpers.extractAttributes(from: self.wrapped.attributes, filterOutStartingWith: ["mutating", "@inlinable"]) + } + private var methodAttributesNonObjc: String { + return Helpers.extractAttributes(from: self.wrapped.attributes, filterOutStartingWith: ["mutating", "@inlinable", "@objc"]) + } + + private let noStubDefinedMessage = "Stub return value not specified for subscript. Use given first." + + private static var registered: [String: Int] = [:] + private static var namesWithoutReturnType: [String: Int] = [:] + private static var suffixes: [String: Int] = [:] + public static func clear() -> String { + SubscriptWrapper.registered = [:] + SubscriptWrapper.suffixes = [:] + namesWithoutReturnType = [:] + return "" + } + static func register(_ name: String, _ uniqueName: String) { + let count = SubscriptWrapper.registered[name] ?? 0 + SubscriptWrapper.registered[name] = count + 1 + SubscriptWrapper.suffixes[uniqueName] = count + 1 + } + static func register(short name: String) { + let count = SubscriptWrapper.namesWithoutReturnType[name] ?? 0 + SubscriptWrapper.namesWithoutReturnType[name] = count + 1 + } + + func register() { + SubscriptWrapper.register(registrationName("get"),uniqueName) + SubscriptWrapper.register(short: shortName) + guard !readonly else { return } + SubscriptWrapper.register(registrationName("set"),uniqueName) + } + + init(_ wrapped: SourceryRuntime.Subscript) { + self.wrapped = wrapped + associatedTypes = Helpers.extractAssociatedTypes(from: wrapped) + genericTypesList = Helpers.extractGenericsList(associatedTypes) + whereClause = Helpers.extractWhereClause(from: wrapped) ?? "" + if let types = associatedTypes { + genericTypesModifier = "<\(types.joined(separator: ","))>" + } else { + genericTypesModifier = nil + } + } + + func registrationName(_ accessor: String) -> String { + return "subscript_\(accessor)_\(wrappedParameters.map({ $0.sanitizedForEnumCaseName() }).joined(separator: "_"))" + } + var shortName: String { return "public subscript\(genericTypesModifier ?? " ")(\(wrappedParameters.map({ $0.asMethodArgument() }).joined(separator: ", ")))" } + var uniqueName: String { return "\(shortName) -> \(wrapped.returnTypeName)\(self.whereClause)" } + + private func nameSuffix(_ accessor: String) -> String { + guard let count = SubscriptWrapper.registered[registrationName(accessor)] else { return "" } + guard count > 1 else { return "" } + guard let index = SubscriptWrapper.suffixes[uniqueName] else { return "" } + return "_\(index)" + } + + // call + func subscriptCall() -> String { + let get = "\n\t\tget {\(getter())\n\t\t}" + let set = readonly ? "" : "\n\t\tset {\(setter())\n\t\t}" + var attributes = self.methodAttributesNonObjc + attributes = attributes.isEmpty ? "" : "\(attributes)\n\t" + return "\(attributes)\(uniqueName) {\(get)\(set)\n\t}" + } + private func getter() -> String { + let method = ".\(subscriptCasePrefix("get"))(\(parametersForMethodCall()))" + let optionalReturnWorkaround = "\(wrapped.returnTypeName)".hasSuffix("?") + let noStubDefined = (optionalReturnWorkaround || wrapped.returnTypeName.isOptional) ? "return nil" : "onFatalFailure(\"\(noStubDefinedMessage)\"); Failure(\"noStubDefinedMessage\")" + return + "\n\t\t\taddInvocation(\(method))" + + "\n\t\t\tdo {" + + "\n\t\t\t\treturn try methodReturnValue(\(method)).casted()" + + "\n\t\t\t} catch {" + + "\n\t\t\t\t\(noStubDefined)" + + "\n\t\t\t}" + } + private func setter() -> String { + let method = ".\(subscriptCasePrefix("set"))(\(parametersForMethodCall(set: true)))" + return "\n\t\t\taddInvocation(\(method))" + } + + var assertionName: String { + return readonly ? assertionName("get") : "\(assertionName("get"))\n\t\t\t\(assertionName("set"))" + } + private func assertionName(_ accessor: String) -> String { + return "case .\(subscriptCasePrefix(accessor)): return " + + "\"[\(accessor)] `subscript`\(genericTypesModifier ?? "")[\(parametersForAssertionName())]\"" + } + + // method type + func subscriptCasePrefix(_ accessor: String) -> String { + return "\(registrationName(accessor))\(nameSuffix(accessor))" + } + func subscriptCaseName(_ accessor: String, availability: Bool = false) -> String { + return "\(subscriptCasePrefix(accessor))(\(parametersForMethodTypeDeclaration(availability: availability, set: accessor == "set")))" + } + func subscriptCases() -> String { + if readonly { + return "case \(subscriptCaseName("get", availability: hasAvailability))" + } else { + return "case \(subscriptCaseName("get", availability: hasAvailability))\n\t\tcase \(subscriptCaseName("set", availability: hasAvailability))" + } + } + func equalCase(_ accessor: String) -> String { + var lhsParams = wrapped.parameters.map { "lhs\($0.name.capitalized)" }.joined(separator: ", ") + var rhsParams = wrapped.parameters.map { "rhs\($0.name.capitalized)" }.joined(separator: ", ") + var comparators = "\t\t\t\tvar results: [Matcher.ParameterComparisonResult] = []\n" + comparators += wrappedParameters.map { "\t\t\t\t\($0.comparatorResult())" }.joined(separator: "\n") + + if accessor == "set" { + lhsParams += ", lhsDidSet" + rhsParams += ", rhsDidSet" + comparators += "\n\t\t\t\tresults.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDidSet, rhs: rhsDidSet, with: matcher), lhsDidSet, rhsDidSet, \"newValue\"))" + } + + comparators += "\n\t\t\t\treturn Matcher.ComparisonResult(results)" + + // comparatorResult() + return "case (let .\(subscriptCasePrefix(accessor))(\(lhsParams)), let .\(subscriptCasePrefix(accessor))(\(rhsParams))):\n" + comparators + } + func equalCases() -> String { + return readonly ? equalCase("get") : "\(equalCase("get"))\n\t\t\t\(equalCase("set"))" + } + func intValueCase() -> String { + return readonly ? intValueCase("get") : "\(intValueCase("get"))\n\t\t\t\(intValueCase("set"))" + } + func intValueCase(_ accessor: String) -> String { + let params = wrappedParameters.enumerated().map { offset, _ in + return "p\(offset)" + } + let definitions = params.joined(separator: ", ") + (accessor == "set" ? ", _" : "") + let paramsSum = params.map({ "\($0).intValue" }).joined(separator: " + ") + return "case let .\(subscriptCasePrefix(accessor))(\(definitions)): return \(paramsSum)" + } + + // Given + func givenConstructorName() -> String { + let returnTypeString = returnsSelf ? replaceSelf : TypeWrapper(wrapped.returnTypeName).stripped + var attributes = self.methodAttributesNonObjc + attributes = attributes.isEmpty ? "" : "\(attributes)\n\t\t" + return "\(attributes)public static func `subscript`\(genericTypesModifier ?? "")(\(parametersForProxySignature()), willReturn: \(returnTypeString)...) -> SubscriptStub" + } + func givenConstructor() -> String { + return "return Given(method: .\(subscriptCasePrefix("get"))(\(parametersForProxyInit())), products: willReturn.map({ StubProduct.return($0 as Any) }))" + } + + // Verify + func verifyConstructorName(set: Bool = false) -> String { + let returnTypeString = returnsSelf ? replaceSelf : nestedType + let returning = set ? "" : returningParameter(true, true) + var attributes = self.methodAttributesNonObjc + attributes = attributes.isEmpty ? "" : "\(attributes)\n\t\t" + return "\(attributes)public static func `subscript`\(genericTypesModifier ?? "")(\(parametersForProxySignature())\(returning)\(set ? ", set newValue: \(returnTypeString)" : "")) -> Verify" + } + func verifyConstructor(set: Bool = false) -> String { + return "return Verify(method: .\(subscriptCasePrefix(set ? "set" : "get"))(\(parametersForProxyInit(set: set))))" + } + + // Generics + private func getGenerics() -> [String] { + return genericTypesList + } + + // Helpers + private var returnsSelf: Bool { return TypeWrapper(wrapped.returnTypeName).isSelfType } + private var replaceSelf: String { return Current.selfType } + private func returnTypeStripped(type: Bool = false) -> String { + let returnTypeRaw = "\(wrapped.returnTypeName)" + var stripped: String = { + guard let range = returnTypeRaw.range(of: "where") else { return returnTypeRaw } + var stripped = returnTypeRaw + stripped.removeSubrange((range.lowerBound)...) + return stripped + }() + stripped = stripped.trimmingCharacters(in: CharacterSet(charactersIn: " ")) + guard type else { return stripped } + return "(\(stripped)).Type" + } + private func returnTypeMatters() -> Bool { + let count = SubscriptWrapper.namesWithoutReturnType[shortName] ?? 0 + return count > 1 + } + + // params + private func returningParameter(_ multiple: Bool, _ front: Bool) -> String { + guard returnTypeMatters() else { return "" } + let returning: String = "returning: \(returnTypeStripped(type: true))" + guard multiple else { return returning } + return front ? ", \(returning)" : "\(returning), " + } + private func parametersForMethodTypeDeclaration(availability: Bool = false, set: Bool = false) -> String { + let generics: [String] = getGenerics() + let params = wrappedParameters.map { param in + if param.isGeneric(generics) { return param.genericType } + if availability { return param.typeErasedType } + return param.nestedType + }.joined(separator: ", ") + guard set else { return params } + let newValue = TypeWrapper(wrapped.returnTypeName).isGeneric(generics) ? "Parameter" : nestedType + return "\(params), \(newValue)" + } + private func parametersForProxyInit(set: Bool = false) -> String { + let generics = getGenerics() + let newValue = TypeWrapper(wrapped.returnTypeName).isGeneric(generics) ? "newValue.wrapAsGeneric()" : "newValue" + return wrappedParameters.map { "\($0.wrappedForProxy(generics, hasAvailability))" }.joined(separator: ", ") + (set ? ", \(newValue)" : "") + } + private func parametersForProxySignature(set: Bool = false) -> String { + return wrappedParameters.map { "\($0.labelAndName()): \($0.nestedType)" }.joined(separator: ", ") + (set ? ", set newValue: \(nestedType)" : "") + } + private func parametersForAssertionName() -> String { + return wrappedParameters.map { "\($0.labelAndName())" }.joined(separator: ", ") + } + private func parametersForMethodCall(set: Bool = false) -> String { + let generics = getGenerics() + let params = wrappedParameters.map { $0.wrappedForCalls(generics, hasAvailability) }.joined(separator: ", ") + let postfix = TypeWrapper(wrapped.returnTypeName).isGeneric(generics) ? ".wrapAsGeneric()" : "" + return !set ? params : "\(params), \(nestedType).value(newValue)\(postfix)" + } +} +class VariableWrapper { + let variable: SourceryRuntime.Variable + let scope: String + var readonly: Bool { return variable.writeAccess.isEmpty } + var privatePrototypeName: String { return "__p_\(variable.name)".replacingOccurrences(of: "`", with: "") } + var casesCount: Int { return readonly ? 1 : 2 } + + var accessModifier: String { + // TODO: Fix access levels for SwiftyPrototype + // guard variable.type?.accessLevel != "internal" else { return "" } + return "public " + } + var attributes: String { + let value = Helpers.extractAttributes(from: self.variable.attributes) + return value.isEmpty ? "\(accessModifier)" : "\(value)\n\t\t\(accessModifier)" + } + var noStubDefinedMessage: String { return "\(scope) - stub value for \(variable.name) was not defined" } + + var getter: String { + let staticModifier = variable.isStatic ? "\(scope)." : "" + let returnValue = variable.isOptional ? "optionalGivenGetterValue(.\(propertyCaseGetName), \"\(noStubDefinedMessage)\")" : "givenGetterValue(.\(propertyCaseGetName), \"\(noStubDefinedMessage)\")" + return "\n\t\tget {\t\(staticModifier)invocations.append(.\(propertyCaseGetName)); return \(staticModifier)\(privatePrototypeName) ?? \(returnValue) }" + } + var setter: String { + let staticModifier = variable.isStatic ? "\(scope)." : "" + if readonly { + return "" + } else { + return "\n\t\tset {\t\(staticModifier)invocations.append(.\(propertyCaseSetName)(.value(newValue))); \(variable.isStatic ? "\(scope)." : "")\(privatePrototypeName) = newValue }" + } + } + var prototype: String { + let staticModifier = variable.isStatic ? "static " : "" + + return "\(attributes)\(staticModifier)var \(variable.name): \(variable.typeName.name) {" + + "\(getter)" + + "\(setter)" + + "\n\t}" + } + var assertionName: String { + var result = "case .\(propertyCaseGetName): return \"[get] .\(variable.name)\"" + if !readonly { + result += "\n\t\t\tcase .\(propertyCaseSetName): return \"[set] .\(variable.name)\"" + } + return result + } + + var privatePrototype: String { + let staticModifier = variable.isStatic ? "static " : "" + var typeName = "\(variable.typeName.unwrappedTypeName)" + let isWrappedInBrackets = typeName.hasPrefix("(") && typeName.hasSuffix(")") + if !isWrappedInBrackets { + typeName = "(\(typeName))" + } + return "private \(staticModifier)var \(privatePrototypeName): \(typeName)?" + } + var nestedType: String { return "\(TypeWrapper(variable.typeName).nestedParameter)" } + + init(_ variable: SourceryRuntime.Variable, scope: String) { + self.variable = variable + self.scope = scope + } + + func compareCases() -> String { + var result = propertyCaseGetCompare() + if !readonly { + result += "\n\t\t\t\(propertyCaseSetCompare())" + } + return result + } + + func propertyGet() -> String { + let staticModifier = variable.isStatic ? "Static" : "" + return "public static var \(variable.name): \(staticModifier)Verify { return \(staticModifier)Verify(method: .\(propertyCaseGetName)) }" + } + + func propertySet() -> String { + let staticModifier = variable.isStatic ? "Static" : "" + return "public static func \(variable.name)(set newValue: \(nestedType)) -> \(staticModifier)Verify { return \(staticModifier)Verify(method: .\(propertyCaseSetName)(newValue)) }" + } + + var propertyCaseGetName: String { return "p_\(variable.name)_get".replacingOccurrences(of: "`", with: "") } + func propertyCaseGet() -> String { + return "case \(propertyCaseGetName)" + } + func propertyCaseGetCompare() -> String { + return "case (.\(propertyCaseGetName),.\(propertyCaseGetName)): return Matcher.ComparisonResult.match" + } + func propertyCaseGetIntValue() -> String { + return "case .\(propertyCaseGetName): return 0" + } + + var propertyCaseSetName: String { return "p_\(variable.name)_set".replacingOccurrences(of: "`", with: "") } + func propertyCaseSet() -> String { + return "case \(propertyCaseSetName)(\(nestedType))" + } + func propertyCaseSetCompare() -> String { + let lhsName = "left" + let rhsName = "right" + let comaprison = "Matcher.ParameterComparisonResult(\(nestedType).compare(lhs: \(lhsName), rhs: \(rhsName), with: matcher), \(lhsName), \(rhsName), \"newValue\")" + let result = "Matcher.ComparisonResult([\(comaprison)])" + return "case (.\(propertyCaseSetName)(let left),.\(propertyCaseSetName)(let right)): return \(result)" + } + func propertyCaseSetIntValue() -> String { + return "case .\(propertyCaseSetName)(let newValue): return newValue.intValue" + } + + // Given + func givenConstructorName(prefix: String = "") -> String { + return "\(attributes)static func \(variable.name)(getter defaultValue: \(TypeWrapper(variable.typeName).stripped)...) -> \(prefix)PropertyStub" + } + + func givenConstructor(prefix: String = "") -> String { + return "return \(prefix)Given(method: .\(propertyCaseGetName), products: defaultValue.map({ StubProduct.return($0 as Any) }))" + } +} +_%> +<%# ================================================== SETUP -%><%_ -%> +<%_ var all = types.all + all += types.protocols.map { $0 } + all += types.protocolCompositions.map { $0 } + var mockedCount = 0 +-%> + +<%_ for type in all { -%><%_ -%> +<%_ let autoMockable: Bool = type.inheritedTypes.contains("AutoMockable") || type.annotations["AutoMockable"] != nil + let protocolToDecorate = types.protocols.first(where: { $0.name == (type.annotations["mock"] as? String) }) + let inlineMockable = protocolToDecorate != nil + guard let aProtocol = autoMockable ? type : protocolToDecorate else { continue } + mockedCount += 1 + + let associatedTypes: [String]? = Helpers.extractAssociatedTypes(from: aProtocol) + let attributes: String = Helpers.extractAttributes(from: type.attributes) + let typeAliases: [String] = Helpers.extractTypealiases(from: aProtocol) + let genericTypesModifier: String = Helpers.extractGenericTypesModifier(associatedTypes) + let genericTypesConstraints: String = Helpers.extractGenericTypesConstraints(associatedTypes) + let allSubscripts = aProtocol.allSubscripts + let allVariables = uniques(variables: aProtocol.allVariables.filter({ !$0.isStatic })) + let containsVariables = !allVariables.isEmpty + let allStaticVariables = uniques(variables: aProtocol.allVariables.filter({ $0.isStatic })) + let containsStaticVariables = !allStaticVariables.isEmpty + let allMethods = uniques(methods: aProtocol.allMethods.filter({ !$0.isStatic || $0.isInitializer })) + let selfConstrained = allMethods.map(wrapMethod).contains(where: { $0.returnsGenericConstrainedToSelf || $0.parametersContainsSelf }) + let accessModifier: String = selfConstrained ? "public final" : "open" + Current.accessModifier = accessModifier // TODO: Temporary workaround for access modifiers + let inheritFromNSObject = type.annotations["ObjcProtocol"] != nil || attributes.contains("@objc") + let allMethodsForMethodType = uniquesWithoutGenericConstraints(methods: aProtocol.allMethods.filter({ !$0.isStatic })) + let allStaticMethods = uniques(methods: aProtocol.allMethods.filter({ $0.isStatic && !$0.isInitializer })) + let allStaticMethodsForMethodType = uniquesWithoutGenericConstraints(methods: aProtocol.allMethods.filter({ $0.isStatic })) + let conformsToStaticMock = !allStaticMethods.isEmpty || !allStaticVariables.isEmpty + let conformsToMock = !allMethods.isEmpty || !allVariables.isEmpty -%><%_ -%><%_ -%> +<%_ if autoMockable { -%> +// MARK: - <%= type.name %> +<%= attributes %> +<%= accessModifier %> class <%= type.name %><%= mockTypeName %><%= genericTypesModifier %>:<%= inheritFromNSObject ? " NSObject," : "" %> <%= type.name %>, Mock<%= conformsToStaticMock ? ", StaticMock" : "" %><%= genericTypesConstraints %> { + 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 + } + +<%_ } else { -%> +// sourcery:inline:auto:<%= type.name %>.autoMocked +<%_ } -%> +<%# ================================================== MAIN CLASS -%><%_ -%> + <%# ================================================== MOCK INTERNALS -%><%_ -%> + 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 + <%_ for typeAlias in typeAliases { -%> + public typealias <%= typeAlias %> + <%_ } %> <%_ -%> + + /// 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 = [] } + } + <%_ -%> + <%# ================================================== STATIC MOCK INTERNALS -%><%_ -%> + <%_ if conformsToStaticMock { -%> + static var matcher: Matcher = Matcher.default + static var stubbingPolicy: StubbingPolicy = .wrap + static var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + static private var queue = DispatchQueue(label: "com.swiftymocky.invocations.static", qos: .userInteractive) + static private var invocations: [StaticMethodType] = [] + static private var methodReturnValues: [StaticGiven] = [] + static private var methodPerformValues: [StaticPerform] = [] + public typealias StaticPropertyStub = StaticGiven + public typealias StaticMethodStub = StaticGiven + + /// 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 static 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 = [] } + } + <%_ } -%> + + <%# ================================================== VARIABLES -%><%_ -%> + <%_ for variable in allVariables { -%> + <%_ if autoMockable { -%> + <%= stubProperty(variable,"\(type.name)\(mockTypeName)") %> + <%_ } else { %> + <%= stubProperty(variable,"\(type.name)") %> + <%_ } %> + <%_ } %> <%_ -%> + + <%# ================================================== STATIC VARIABLES -%><%_ -%> + <%_ for variable in allStaticVariables { -%> + <%_ if autoMockable { -%> + <%= stubProperty(variable,"\(type.name)\(mockTypeName)") %> + <%_ } else { %> + <%= stubProperty(variable,"\(type.name)") %> + <%_ } %> + <%_ } %> <%_ -%> + + <%# ================================================== METHOD REGISTRATIONS -%><%_ -%> + <%_ MethodWrapper.clear() -%> + <%_ SubscriptWrapper.clear() -%> + <%_ if autoMockable { -%> + <%_ Current.selfType = "\(type.name)\(mockTypeName)\(genericTypesModifier)" -%> + <%_ } else { %> + <%_ Current.selfType = "\(type.name)\(mockTypeName)\(genericTypesModifier)" -%> + <%_ } %> + <%_ let wrappedSubscripts = allSubscripts.map(wrapSubscript) -%> + <%_ let wrappedMethods = allMethods.map(wrapMethod).filter({ $0.wrappedInMethodType() }) -%> + <%_ let wrappedVariables = allVariables.map(justWrap) -%> + <%_ let wrappedMethodsForMethodType = allMethodsForMethodType.map(wrapMethod).filter({ $0.wrappedInMethodType() }) -%> + <%_ let wrappedInitializers = allMethods.map(wrapMethod).filter({ $0.method.isInitializer }) -%> + <%_ let wrappedStaticMethods = allStaticMethods.map(wrapMethod).filter({ $0.wrappedInMethodType() }) -%> + <%_ let wrappedStaticVariables = allStaticVariables.map(justWrap) -%> + <%_ let wrappedStaticMethodsForMethodType = allStaticMethodsForMethodType.map(wrapMethod).filter({ $0.wrappedInMethodType() }) -%> + <%_ for variable in allVariables { propertyRegister(variable) } -%> + <%_ for variable in allStaticVariables { propertyRegister(variable) } -%> + <%_ for method in wrappedMethods { method.register() } -%> + <%_ for wrapped in wrappedSubscripts { wrapped.register() } -%> + <%_ for method in wrappedStaticMethods { method.register() } -%><%_ -%> + <%_ let variableCasesCount: Int = wrappedVariables.reduce(0) { return $0 + $1.casesCount } -%><%_ -%> + <%_ let subscriptsCasesCount: Int = wrappedSubscripts.reduce(0) { return $0 + $1.casesCount } -%><%_ -%> + <%_ let staticVariableCasesCount: Int = wrappedStaticVariables.reduce(0) { return $0 + $1.casesCount } -%><%_ -%> + + <%# ================================================== STATIC STUBS -%><%_ -%> + <%_ for method in wrappedStaticMethods { -%> + <%= method.functionPrototype _%> { + <%= method.stubBody() _%> + } + + <%_ } %><%_ -%> + <%_ -%> + <%# ================================================== INITIALIZERS -%><%_ -%> + <%_ for method in wrappedInitializers { -%> + <%= method.functionPrototype _%> { } + + <%_ } -%><%_ -%> + <%_ -%><%_ -%> + <%# ================================================== STUBS -%><%_ -%> + <%_ for method in wrappedMethods { -%> + <%= method.functionPrototype _%> { + <%= method.stubBody() _%> + } + + <%_ } -%> + <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.subscriptCall() _%> + + <%_ } -%> + <%# ================================================== STATIC METHOD TYPE -%><%_ -%> + <%_ if conformsToStaticMock { -%> + fileprivate enum StaticMethodType { + <%_ for method in wrappedStaticMethodsForMethodType { -%> + <%= method.methodTypeDeclarationWithParameters() _%> + <%_ } %> <%_ for variable in allStaticVariables { -%> + <%= propertyMethodTypes(variable) %> + <%_ } %> <%_ %> + <%_ -%> + static func compareParameters(lhs: StaticMethodType, rhs: StaticMethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { <%_ for method in wrappedStaticMethodsForMethodType { %> + <%= method.equalCases() %> + <%_ } %> <%_ for variable in wrappedStaticVariables { -%> + <%= variable.compareCases() %> + <%_ } %> <%_ -%> <%_ if wrappedStaticMethods.count + staticVariableCasesCount > 1 { -%> + default: return .none + <%_ } -%> + } + } + <%_ %> + func intValue() -> Int { + switch self { <%_ for method in wrappedStaticMethodsForMethodType { %> + <%= method.intValueCase -%><% } %> + <%_ for variable in allStaticVariables { -%> + <%= propertyMethodTypesIntValue(variable) %> + <%_ } %> <%_ -%> + } + } + func assertionName() -> String { + switch self { <%_ for method in wrappedStaticMethodsForMethodType { %> + <%= method.assertionName -%><% } %> + <%_ for variable in wrappedStaticVariables { -%> + <%= variable.assertionName %> + <%_ } %> + } + } + } + + open class StaticGiven: StubbedMethod { + fileprivate var method: StaticMethodType + + private init(method: StaticMethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + <%_ for variable in allStaticVariables { -%> + <%= wrapProperty(variable).givenConstructorName(prefix: "Static") -%> { + <%= wrapProperty(variable).givenConstructor(prefix: "Static") _%> + } + <%_ } %> <%_ %> + <%_ for method in wrappedStaticMethodsForMethodType.filter({ !$0.method.returnTypeName.isVoid && !$0.method.isInitializer }) { -%> + <%= method.givenConstructorName(prefix: "Static") -%> { + <%= method.givenConstructor(prefix: "Static") _%> + } + <%_ } -%> + <%_ for method in wrappedStaticMethodsForMethodType.filter({ !$0.method.throws && !$0.method.rethrows && !$0.method.returnTypeName.isVoid && !$0.method.isInitializer }) { -%> + <%= method.givenProduceConstructorName(prefix: "Static") -%> { + <%= method.givenProduceConstructor(prefix: "Static") _%> + } + <%_ } -%> + <%_ for method in wrappedStaticMethodsForMethodType.filter({ ($0.method.throws || $0.method.rethrows) && !$0.method.isInitializer }) { -%> + <%= method.givenConstructorNameThrows(prefix: "Static") -%> { + <%= method.givenConstructorThrows(prefix: "Static") _%> + } + <%= method.givenProduceConstructorNameThrows(prefix: "Static") -%> { + <%= method.givenProduceConstructorThrows(prefix: "Static") _%> + } + <%_ } %> <%_ -%> + } + + public struct StaticVerify { + fileprivate var method: StaticMethodType + + <%_ for method in wrappedStaticMethodsForMethodType { -%> + <%= method.verificationProxyConstructorName(prefix: "Static") -%> { <%= method.verificationProxyConstructor(prefix: "Static") _%> } + <%_ } %> <%_ -%> + <%_ for variable in allStaticVariables { -%> + <%= propertyTypes(variable) %> + <%_ } %> <%_ -%> + } + + public struct StaticPerform { + fileprivate var method: StaticMethodType + var performs: Any + + <%_ for method in wrappedStaticMethodsForMethodType { -%> + <%= method.performProxyConstructorName(prefix: "Static") -%> { + <%= method.performProxyConstructor(prefix: "Static") _%> + } + <%_ } %> <%_ -%> + } + + <% } -%> + <%# ================================================== METHOD TYPE -%><%_ -%> + <%_ if !wrappedMethods.isEmpty || !allVariables.isEmpty || !allSubscripts.isEmpty { -%> + + fileprivate enum MethodType { + <%_ for method in wrappedMethodsForMethodType { -%> + <%= method.methodTypeDeclarationWithParameters() _%> + <%_ } -%> <%_ for variable in allVariables { -%> + <%= propertyMethodTypes(variable) %> + <%_ } %> <%_ %> <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.subscriptCases() _%> + <%_ } %> <%_ %> + <%_ -%> + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { <%_ for method in wrappedMethodsForMethodType { %> + <%= method.equalCases() %> + <%_ } %> <%_ for variable in wrappedVariables { -%> + <%= variable.compareCases() %> + <%_ } %> <%_ -%> <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.equalCases() %> + <%_ } %> <%_ if wrappedMethods.count + variableCasesCount + subscriptsCasesCount > 1 { -%> + default: return .none + <%_ } -%> + } + } + <%_ %> + func intValue() -> Int { + switch self { <%_ for method in wrappedMethodsForMethodType { %> + <%= method.intValueCase -%><% } %> + <%_ for variable in allVariables { -%> + <%= propertyMethodTypesIntValue(variable) %> + <%_ } %> <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.intValueCase() %> + <%_ } -%> + } + } + func assertionName() -> String { + switch self { <%_ for method in wrappedMethodsForMethodType { %> + <%= method.assertionName -%><% } %> + <%_ for variable in wrappedVariables { -%> + <%= variable.assertionName %> + <%_ } %> <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.assertionName %> + <%_ } -%> + } + } + } + <%_ } else { %> + fileprivate struct MethodType { + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { return .match } + func intValue() -> Int { return 0 } + func assertionName() -> String { return "" } + } + <%_ } -%><%_ -%> + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + <%_ for variable in allVariables { -%> + <%= wrapProperty(variable).givenConstructorName() -%> { + <%= wrapProperty(variable).givenConstructor() _%> + } + <%_ } %> <%_ %> + <%_ for method in wrappedMethodsForMethodType.filter({ !$0.method.returnTypeName.isVoid && !$0.method.isInitializer }) { -%> + <%= method.givenConstructorName() -%> { + <%= method.givenConstructor() _%> + } + <%_ } -%> + <%_ for method in wrappedMethodsForMethodType.filter({ !$0.method.throws && !$0.method.rethrows && !$0.method.returnTypeName.isVoid && !$0.method.isInitializer }) { -%> + <%= method.givenProduceConstructorName() -%> { + <%= method.givenProduceConstructor() _%> + } + <%_ } -%> + <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.givenConstructorName() -%> { + <%= wrapped.givenConstructor() _%> + } + <%_ } -%> + <%_ for method in wrappedMethodsForMethodType.filter({ ($0.method.throws || $0.method.rethrows) && !$0.method.isInitializer }) { -%> + <%= method.givenConstructorNameThrows() -%> { + <%= method.givenConstructorThrows() _%> + } + <%= method.givenProduceConstructorNameThrows() -%> { + <%= method.givenProduceConstructorThrows() _%> + } + <%_ } %> <%_ -%> + } + + public struct Verify { + fileprivate var method: MethodType + + <%_ for method in wrappedMethodsForMethodType { -%> + <%= method.verificationProxyConstructorName() -%> { <%= method.verificationProxyConstructor() _%> } + <%_ } %> <%_ -%> + <%_ for variable in allVariables { -%> + <%= propertyTypes(variable) %> + <%_ } %> <%_ -%> + <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.verifyConstructorName() -%> { <%= wrapped.verifyConstructor() _%> } + <%_ if !wrapped.readonly { -%> + <%= wrapped.verifyConstructorName(set: true) -%> { <%= wrapped.verifyConstructor(set: true) _%> } + <%_ } -%> + <%_ } %> <%_ -%> + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + <%_ for method in wrappedMethodsForMethodType { -%> + <%= method.performProxyConstructorName() -%> { + <%= method.performProxyConstructor() _%> + } + <%_ } %> <%_ -%> + } + + <%# ================================================== MOCK METHODS -%><%_ -%> + 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) + } + <%# ================================================== STATIC MOCK METHODS -%><%_ -%> + <%_ if conformsToStaticMock { -%> + + static public func given(_ method: StaticGiven) { + methodReturnValues.append(method) + } + + static public func perform(_ method: StaticPerform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + static public func verify(_ method: StaticVerify, 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 StaticMethodType.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) + } + + static private func addInvocation(_ call: StaticMethodType) { + self.queue.sync { invocations.append(call) } + } + static private func methodReturnValue(_ method: StaticMethodType) throws -> StubProduct { + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && StaticMethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + static private func methodPerformValue(_ method: StaticMethodType) -> Any? { + let matched = methodPerformValues.reversed().first { StaticMethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + static private func matchingCalls(_ method: StaticMethodType, file: StaticString?, line: UInt?) -> [StaticMethodType] { + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return invocations.filter { StaticMethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + static private func matchingCalls(_ method: StaticVerify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + static private func givenGetterValue(_ method: StaticMethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + Failure(message) + } + } + static private func optionalGivenGetterValue(_ method: StaticMethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + <%_ } -%> +<%_ if autoMockable { -%> +} + +<%_ } else { -%> +// sourcery:end +<%_ } -%> +<% } -%> +<%_ if mockedCount == 0 { -%> +// SwiftyMocky: no AutoMockable found. +// Please define and inherit from AutoMockable, or annotate protocols to be mocked +<%_ } -%> diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 4fc81e931..d3b1013cc 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -12,11 +12,14 @@ 0218196528F734FA00202564 /* Discussion.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0218196328F734FA00202564 /* Discussion.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0219C67728F4347600D64452 /* Course.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0219C67628F4347600D64452 /* Course.framework */; }; 0219C67828F4347600D64452 /* Course.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0219C67628F4347600D64452 /* Course.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024E691F2AEFC3FB00FA0B59 /* MainScreenViewModel.swift */; }; 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025AD4AB2A6FB95C00AB8FA7 /* DatabaseManager.swift */; }; 025DE1A428DB4DAE0053E0F4 /* Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 025DE1A328DB4DAE0053E0F4 /* Profile.framework */; }; 025DE1A528DB4DAE0053E0F4 /* Profile.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 025DE1A328DB4DAE0053E0F4 /* Profile.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 027DB32F28D8A063002B6862 /* Dashboard.framework */; }; 027DB33128D8A063002B6862 /* Dashboard.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 027DB32F28D8A063002B6862 /* Dashboard.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 028A37362ADFF404008CA604 /* WhatsNew.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 028A37352ADFF404008CA604 /* WhatsNew.framework */; }; + 028A37372ADFF404008CA604 /* WhatsNew.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 028A37352ADFF404008CA604 /* WhatsNew.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0293A2032A6FCA590090A336 /* CorePersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0293A2022A6FCA590090A336 /* CorePersistence.swift */; }; 0293A2052A6FCD430090A336 /* CoursePersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0293A2042A6FCD430090A336 /* CoursePersistence.swift */; }; 0293A2072A6FCDA30090A336 /* DiscoveryPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0293A2062A6FCDA30090A336 /* DiscoveryPersistence.swift */; }; @@ -59,6 +62,7 @@ 0219C67828F4347600D64452 /* Course.framework in Embed Frameworks */, 025DE1A528DB4DAE0053E0F4 /* Profile.framework in Embed Frameworks */, 027DB33128D8A063002B6862 /* Dashboard.framework in Embed Frameworks */, + 028A37372ADFF404008CA604 /* WhatsNew.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -70,11 +74,13 @@ 0218196328F734FA00202564 /* Discussion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discussion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0219C67628F4347600D64452 /* Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 02450ABD29C35FF20094E2D0 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 024E691F2AEFC3FB00FA0B59 /* MainScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreenViewModel.swift; sourceTree = ""; }; 025AD4AB2A6FB95C00AB8FA7 /* DatabaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseManager.swift; sourceTree = ""; }; 025C77A028E463E900B3DFA3 /* CourseOutline.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CourseOutline.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 025DE1A328DB4DAE0053E0F4 /* Profile.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Profile.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 025EF2F7297177F300B838AB /* OpenEdX.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenEdX.entitlements; sourceTree = ""; }; 027DB32F28D8A063002B6862 /* Dashboard.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Dashboard.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 028A37352ADFF404008CA604 /* WhatsNew.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = WhatsNew.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0293A2022A6FCA590090A336 /* CorePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePersistence.swift; sourceTree = ""; }; 0293A2042A6FCD430090A336 /* CoursePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoursePersistence.swift; sourceTree = ""; }; 0293A2062A6FCDA30090A336 /* DiscoveryPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPersistence.swift; sourceTree = ""; }; @@ -118,6 +124,7 @@ buildActionMask = 2147483647; files = ( 07A7D78F28F5C9060000BE81 /* Core.framework in Frameworks */, + 028A37362ADFF404008CA604 /* WhatsNew.framework in Frameworks */, 025DE1A428DB4DAE0053E0F4 /* Profile.framework in Frameworks */, 0770DE4B28D0A462006D8A5D /* Authorization.framework in Frameworks */, 072787B128D34D83002E9142 /* Discovery.framework in Frameworks */, @@ -148,6 +155,7 @@ isa = PBXGroup; children = ( 0727878D28D347C7002E9142 /* MainScreenView.swift */, + 024E691F2AEFC3FB00FA0B59 /* MainScreenViewModel.swift */, ); path = View; sourceTree = ""; @@ -206,6 +214,7 @@ 4E6FB43543890E90BB88D64D /* Frameworks */ = { isa = PBXGroup; children = ( + 028A37352ADFF404008CA604 /* WhatsNew.framework */, 0218196328F734FA00202564 /* Discussion.framework */, 07A7D78E28F5C9060000BE81 /* Core.framework */, 0219C67628F4347600D64452 /* Course.framework */, @@ -384,6 +393,7 @@ 0293A2032A6FCA590090A336 /* CorePersistence.swift in Sources */, 0770DE1E28D084E8006D8A5D /* AppAssembly.swift in Sources */, 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */, + 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */, 0770DE2028D0858A006D8A5D /* Router.swift in Sources */, 0727876D28D23312002E9142 /* Environment.swift in Sources */, 0293A2092A6FCDE50090A336 /* DashboardPersistence.swift in Sources */, @@ -515,7 +525,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.stage; PRODUCT_NAME = "$(TARGET_NAME) Stage"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -603,7 +613,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.stage; PRODUCT_NAME = "$(TARGET_NAME) Stage"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -697,7 +707,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.dev; PRODUCT_NAME = "$(TARGET_NAME) Dev"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -785,7 +795,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.dev; PRODUCT_NAME = "$(TARGET_NAME) Dev"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -933,7 +943,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -967,7 +977,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/OpenEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate b/OpenEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index b59556e65..000000000 Binary files a/OpenEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme index c2f6ffa2b..55135d8fa 100644 --- a/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme +++ b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme @@ -97,13 +97,22 @@ ReferencedContainer = "container:Profile/Profile.xcodeproj"> + + + + + + diff --git a/OpenEdX/AnalyticsManager.swift b/OpenEdX/AnalyticsManager.swift index 04a11b640..598aac053 100644 --- a/OpenEdX/AnalyticsManager.swift +++ b/OpenEdX/AnalyticsManager.swift @@ -257,6 +257,14 @@ class AnalyticsManager: AuthorizationAnalytics, logEvent(.courseOutlineVideosTabClicked, parameters: parameters) } + public func courseOutlineDatesTabClicked(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.courseOutlineDatesTabClicked, parameters: parameters) + } + public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) { let parameters = [ Key.courseID: courseId, @@ -360,6 +368,7 @@ enum Event: String { case finishVerticalBackToOutlineClicked = "Finish_Vertical_Back_to_outline_Clicked" case courseOutlineCourseTabClicked = "Course_Outline_Course_tab_Clicked" case courseOutlineVideosTabClicked = "Course_Outline_Videos_tab_Clicked" + case courseOutlineDatesTabClicked = "Course_Outline_Dates_tab_Clicked" case courseOutlineDiscussionTabClicked = "Course_Outline_Discussion_tab_Clicked" case courseOutlineHandoutsTabClicked = "Course_Outline_Handouts_tab_Clicked" diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index edc74bd1f..9c2bff47e 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -15,6 +15,7 @@ import Course import Discussion import Authorization import Profile +import WhatsNew // swiftlint:disable function_body_length class AppAssembly: Assembly { @@ -112,6 +113,10 @@ class AppAssembly: Assembly { r.resolve(Router.self)! }.inObjectScope(.container) + container.register(WhatsNewRouter.self) { r in + r.resolve(Router.self)! + }.inObjectScope(.container) + container.register(Config.self) { _ in Config(baseURL: BuildConfiguration.shared.baseURL, oAuthClientId: BuildConfiguration.shared.clientId) }.inObjectScope(.container) @@ -139,6 +144,10 @@ class AppAssembly: Assembly { r.resolve(AppStorage.self)! }.inObjectScope(.container) + container.register(WhatsNewStorage.self) { r in + r.resolve(AppStorage.self)! + }.inObjectScope(.container) + container.register(ProfileStorage.self) { r in r.resolve(AppStorage.self)! }.inObjectScope(.container) diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index c5c52df4c..f826a23b6 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -33,11 +33,21 @@ class ScreenAssembly: Assembly { ) } + // MARK: MainScreenView + container.register(MainScreenViewModel.self) { r in + MainScreenViewModel( + analytics: r.resolve(MainScreenAnalytics.self)!, + config: r.resolve(Config.self)!, + profileInteractor: r.resolve(ProfileInteractorProtocol.self)! + ) + } + // MARK: SignIn container.register(SignInViewModel.self) { r in SignInViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(AuthorizationRouter.self)!, + config: r.resolve(Config.self)!, analytics: r.resolve(AuthorizationAnalytics.self)!, validator: r.resolve(Validator.self)! ) @@ -81,6 +91,8 @@ class ScreenAssembly: Assembly { } container.register(DiscoveryViewModel.self) { r in DiscoveryViewModel( + router: r.resolve(DiscoveryRouter.self)!, + config: r.resolve(Config.self)!, interactor: r.resolve(DiscoveryInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, analytics: r.resolve(DiscoveryAnalytics.self)! @@ -269,6 +281,7 @@ class ScreenAssembly: Assembly { playerStateSubject: playerStateSubject, interactor: r.resolve(CourseInteractorProtocol.self)!, router: r.resolve(CourseRouter.self)!, + appStorage: r.resolve(CoreStorage.self)!, connectivity: r.resolve(ConnectivityProtocol.self)! ) } @@ -283,7 +296,8 @@ class ScreenAssembly: Assembly { languages: languages, playerStateSubject: playerStateSubject, interactor: r.resolve(CourseInteractorProtocol.self)!, - router: r.resolve(CourseRouter.self)!, + router: r.resolve(CourseRouter.self)!, + appStorage: r.resolve(CoreStorage.self)!, connectivity: r.resolve(ConnectivityProtocol.self)! ) } @@ -298,6 +312,15 @@ class ScreenAssembly: Assembly { ) } + container.register(CourseDatesViewModel.self) { r, courseID in + CourseDatesViewModel( + interactor: r.resolve(CourseInteractorProtocol.self)!, + router: r.resolve(CourseRouter.self)!, + cssInjector: r.resolve(CSSInjector.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)!, + courseID: courseID) + } + // MARK: Discussion container.register(DiscussionRepositoryProtocol.self) { r in DiscussionRepository( diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index 99144be00..f674ebd9b 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -9,8 +9,9 @@ import Foundation import KeychainSwift import Core import Profile +import WhatsNew -public class AppStorage: CoreStorage, ProfileStorage { +public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage { private let keychain: KeychainSwift private let userDefaults: UserDefaults @@ -58,6 +59,19 @@ public class AppStorage: CoreStorage, ProfileStorage { } } } + + public var whatsNewVersion: String? { + get { + return userDefaults.string(forKey: KEY_WHATSNEW_VERSION) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_WHATSNEW_VERSION) + } else { + userDefaults.removeObject(forKey: KEY_WHATSNEW_VERSION) + } + } + } public var userProfile: DataLayer.UserProfile? { get { @@ -81,7 +95,7 @@ public class AppStorage: CoreStorage, ProfileStorage { public var userSettings: UserSettings? { get { guard let userSettings = userDefaults.data(forKey: KEY_SETTINGS) else { - let defaultSettings = UserSettings(wifiOnly: true, downloadQuality: .auto) + let defaultSettings = UserSettings(wifiOnly: true, streamingQuality: .auto) let encoder = JSONEncoder() if let encoded = try? encoder.encode(defaultSettings) { userDefaults.set(encoded, forKey: KEY_SETTINGS) @@ -134,4 +148,5 @@ public class AppStorage: CoreStorage, ProfileStorage { private let KEY_USER_PROFILE = "userProfile" private let KEY_USER = "refreshToken" private let KEY_SETTINGS = "userSettings" + private let KEY_WHATSNEW_VERSION = "whatsNewVersion" } diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index cd61e5e6e..6c7f5d83e 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -215,4 +215,12 @@ public class CoursePersistence: CoursePersistenceProtocol { } return nil } + + public func saveCourseDates(courseID: String, courseDates: CourseDates) { + + } + + public func loadCourseDates(courseID: String) throws -> CourseDates { + throw NoCachedDataError() + } } diff --git a/OpenEdX/MainScreenAnalytics.swift b/OpenEdX/MainScreenAnalytics.swift index 39dd9e484..fc7bdb38c 100644 --- a/OpenEdX/MainScreenAnalytics.swift +++ b/OpenEdX/MainScreenAnalytics.swift @@ -14,3 +14,12 @@ public protocol MainScreenAnalytics { func mainProgramsTabClicked() func mainProfileTabClicked() } + +#if DEBUG +public class MainScreenAnalyticsMock: MainScreenAnalytics { + public func mainDiscoveryTabClicked() {} + public func mainDashboardTabClicked() {} + public func mainProgramsTabClicked() {} + public func mainProfileTabClicked() {} +} +#endif diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index 1aa036dc1..6d7d0297b 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -9,6 +9,8 @@ import UIKit import SwiftUI import Core import Authorization +import WhatsNew +import Swinject class RouteController: UIViewController { @@ -30,7 +32,7 @@ class RouteController: UIViewController { if let user = appStorage.user, appStorage.accessToken != nil { analytics.setUserID("\(user.id)") DispatchQueue.main.async { - self.showMainScreen() + self.showMainOrWhatsNewScreen() } } else { DispatchQueue.main.async { @@ -47,9 +49,28 @@ class RouteController: UIViewController { present(navigation, animated: false) } - private func showMainScreen() { - let controller = UIHostingController(rootView: MainScreenView()) - navigation.viewControllers = [controller] + private func showMainOrWhatsNewScreen() { + var storage = Container.shared.resolve(WhatsNewStorage.self)! + let config = Container.shared.resolve(Config.self)! + + let viewModel = WhatsNewViewModel(storage: storage) + let shouldShowWhatsNew = viewModel.shouldShowWhatsNew() + + if shouldShowWhatsNew && config.whatsNewEnabled { + if let jsonVersion = viewModel.getVersion() { + storage.whatsNewVersion = jsonVersion + } + let whatsNewView = WhatsNewView( + router: Container.shared.resolve(WhatsNewRouter.self)!, + viewModel: viewModel + ) + let controller = UIHostingController(rootView: whatsNewView) + navigation.viewControllers = [controller] + } else { + let viewModel = Container.shared.resolve(MainScreenViewModel.self)! + let controller = UIHostingController(rootView: MainScreenView(viewModel: viewModel)) + navigation.viewControllers = [controller] + } present(navigation, animated: false) } } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 58413b862..062dfcea2 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -16,9 +16,11 @@ import Discussion import Discovery import Dashboard import Profile +import WhatsNew import Combine public class Router: AuthorizationRouter, + WhatsNewRouter, DiscoveryRouter, ProfileRouter, DashboardRouter, @@ -56,10 +58,28 @@ public class Router: AuthorizationRouter, navigationController.setViewControllers(viewControllers, animated: true) } - public func showMainScreen() { + public func showMainOrWhatsNewScreen() { showToolBar() - let controller = UIHostingController(rootView: MainScreenView()) - navigationController.setViewControllers([controller], animated: true) + var storage = Container.shared.resolve(WhatsNewStorage.self)! + let config = Container.shared.resolve(Config.self)! + + let viewModel = WhatsNewViewModel(storage: storage) + let whatsNew = WhatsNewView(router: Container.shared.resolve(WhatsNewRouter.self)!, viewModel: viewModel) + let shouldShowWhatsNew = viewModel.shouldShowWhatsNew() + + if shouldShowWhatsNew && config.whatsNewEnabled { + if let jsonVersion = viewModel.getVersion() { + storage.whatsNewVersion = jsonVersion + } + let controller = UIHostingController(rootView: whatsNew) + navigationController.viewControllers = [controller] + navigationController.setViewControllers([controller], animated: true) + } else { + let viewModel = Container.shared.resolve(MainScreenViewModel.self)! + let controller = UIHostingController(rootView: MainScreenView(viewModel: viewModel)) + navigationController.viewControllers = [controller] + navigationController.setViewControllers([controller], animated: true) + } } public func showLoginScreen() { @@ -406,6 +426,21 @@ public class Router: AuthorizationRouter, navigationController.pushViewController(controller, animated: true) } + public func showUpdateRequiredView(showAccountLink: Bool = true) { + let view = UpdateRequiredView( + router: self, + config: Container.shared.resolve(Config.self)!, + showAccountLink: showAccountLink + ) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: false) + } + + public func showUpdateRecomendedView() { + let view = UpdateRecommendedView(router: self, config: Container.shared.resolve(Config.self)!) + self.presentView(transitionStyle: .crossDissolve, view: view) + } + private func prepareToPresent (_ toPresent: ToPresent, transitionStyle: UIModalTransitionStyle) -> UIViewController { let hosting = UIHostingController(rootView: toPresent) diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index c27d7ea3f..2d19c0c8f 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -11,12 +11,15 @@ import Core import Swinject import Dashboard import Profile +import WhatsNew import SwiftUIIntrospect struct MainScreenView: View { @State private var selection: MainTab = .discovery @State private var settingsTapped: Bool = false + @State private var disableAllTabs: Bool = false + @State private var updateAvaliable: Bool = false enum MainTab { case discovery @@ -25,9 +28,10 @@ struct MainScreenView: View { case profile } - private let analytics = Container.shared.resolve(MainScreenAnalytics.self)! - - init() { + @ObservedObject private var viewModel: MainScreenViewModel + + init(viewModel: MainScreenViewModel) { + self.viewModel = viewModel UITabBar.appearance().isTranslucent = false UITabBar.appearance().barTintColor = UIColor(Theme.Colors.textInputUnfocusedBackground) UITabBar.appearance().backgroundColor = UIColor(Theme.Colors.textInputUnfocusedBackground) @@ -36,21 +40,26 @@ struct MainScreenView: View { var body: some View { TabView(selection: $selection) { - DiscoveryView( - viewModel: Container.shared.resolve(DiscoveryViewModel.self)!, - router: Container.shared.resolve(DiscoveryRouter.self)! - ) + ZStack { + DiscoveryView(viewModel: Container.shared.resolve(DiscoveryViewModel.self)!) + if updateAvaliable { + UpdateNotificationView(config: viewModel.config) + } + } .tabItem { CoreAssets.discovery.swiftUIImage.renderingMode(.template) Text(CoreLocalization.Mainscreen.discovery) } .tag(MainTab.discovery) - VStack { + ZStack { DashboardView( viewModel: Container.shared.resolve(DashboardViewModel.self)!, router: Container.shared.resolve(DashboardRouter.self)! ) + if updateAvaliable { + UpdateNotificationView(config: viewModel.config) + } } .tabItem { CoreAssets.dashboard.swiftUIImage.renderingMode(.template) @@ -58,8 +67,11 @@ struct MainScreenView: View { } .tag(MainTab.dashboard) - VStack { + ZStack { Text(CoreLocalization.Mainscreen.inDeveloping) + if updateAvaliable { + UpdateNotificationView(config: viewModel.config) + } } .tabItem { CoreAssets.programs.swiftUIImage.renderingMode(.template) @@ -95,18 +107,35 @@ struct MainScreenView: View { } }) } + .onReceive(NotificationCenter.default.publisher(for: .onAppUpgradeAccountSettingsTapped)) { _ in + selection = .profile + disableAllTabs = true + } + .onReceive(NotificationCenter.default.publisher(for: .onNewVersionAvaliable)) { _ in + updateAvaliable = true + } + .onChange(of: selection) { _ in + if disableAllTabs { + selection = .profile + } + } .onChange(of: selection, perform: { selection in switch selection { case .discovery: - analytics.mainDiscoveryTabClicked() + viewModel.trackMainDiscoveryTabClicked() case .dashboard: - analytics.mainDashboardTabClicked() + viewModel.trackMainDashboardTabClicked() case .programs: - analytics.mainProgramsTabClicked() + viewModel.trackMainProgramsTabClicked() case .profile: - analytics.mainProfileTabClicked() + viewModel.trackMainProfileTabClicked() } }) + .onFirstAppear { + Task { + await viewModel.prefetchDataForOffline() + } + } } private func titleBar() -> String { @@ -121,10 +150,4 @@ struct MainScreenView: View { return ProfileLocalization.title } } - - struct MainScreenView_Previews: PreviewProvider { - static var previews: some View { - MainScreenView() - } - } } diff --git a/OpenEdX/View/MainScreenViewModel.swift b/OpenEdX/View/MainScreenViewModel.swift new file mode 100644 index 000000000..0296a6509 --- /dev/null +++ b/OpenEdX/View/MainScreenViewModel.swift @@ -0,0 +1,44 @@ +// +// MainScreenViewModel.swift +// OpenEdX +// +// Created by  Stepanok Ivan on 30.10.2023. +// + +import Foundation +import Core +import Profile + +class MainScreenViewModel: ObservableObject { + + private let analytics: MainScreenAnalytics + let config: Config + let profileInteractor: ProfileInteractorProtocol + + init(analytics: MainScreenAnalytics, config: Config, profileInteractor: ProfileInteractorProtocol) { + self.analytics = analytics + self.config = config + self.profileInteractor = profileInteractor + } + + func trackMainDiscoveryTabClicked() { + analytics.mainDiscoveryTabClicked() + } + func trackMainDashboardTabClicked() { + analytics.mainDashboardTabClicked() + } + func trackMainProgramsTabClicked() { + analytics.mainProgramsTabClicked() + } + func trackMainProfileTabClicked() { + analytics.mainProfileTabClicked() + } + + @MainActor + func prefetchDataForOffline() async { + if profileInteractor.getMyProfileOffline() == nil { + _ = try? await profileInteractor.getMyProfile() + } + } + +} diff --git a/Podfile b/Podfile index 1d97fa467..c3658683d 100644 --- a/Podfile +++ b/Podfile @@ -48,6 +48,15 @@ abstract_target "App" do end end + target "WhatsNew" do + project './WhatsNew/WhatsNew.xcodeproj' + workspace './WhatsNew/WhatsNew.xcodeproj' + + target 'WhatsNewTests' do + pod 'SwiftyMocky', :git => 'https://github.com/MakeAWishFoundation/SwiftyMocky.git', :tag => '4.2.0' + end + end + target "Dashboard" do project './Dashboard/Dashboard.xcodeproj' workspace './Dashboard/Dashboard.xcodeproj' diff --git a/Podfile.lock b/Podfile.lock index 5b998cd5e..cf6c60e94 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -180,6 +180,6 @@ SPEC CHECKSUMS: SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 Swinject: 893c9a543000ac2f10ee4cbaf0933c6992c935d5 -PODFILE CHECKSUM: 1639b311802f5d36686512914067b7221ff97a64 +PODFILE CHECKSUM: a44d8de5a5803eb3e3c995134c79c3dad959dbf7 COCOAPODS: 1.12.1 diff --git a/Profile/Mockfile b/Profile/Mockfile index dd72a756d..408c90399 100644 --- a/Profile/Mockfile +++ b/Profile/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index bf1a02ec2..e445f5225 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -12,7 +12,7 @@ import Alamofire public protocol ProfileRepositoryProtocol { func getUserProfile(username: String) async throws -> UserProfile func getMyProfile() async throws -> UserProfile - func getMyProfileOffline() throws -> UserProfile + func getMyProfileOffline() -> UserProfile? func logOut() async throws func uploadProfilePicture(pictureData: Data) async throws func deleteProfilePicture() async throws -> Bool @@ -61,12 +61,8 @@ public class ProfileRepository: ProfileRepositoryProtocol { return user.domain } - public func getMyProfileOffline() throws -> UserProfile { - if let user = storage.userProfile { - return user.domain - } else { - throw NoCachedDataError() - } + public func getMyProfileOffline() -> UserProfile? { + return storage.userProfile?.domain } public func logOut() async throws { @@ -148,7 +144,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { if let userSettings = storage.userSettings { return userSettings } else { - return UserSettings(wifiOnly: true, downloadQuality: VideoQuality.auto) + return UserSettings(wifiOnly: true, streamingQuality: StreamingQuality.auto) } } @@ -173,7 +169,7 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { isFullProfile: false) } - func getMyProfileOffline() throws -> Core.UserProfile { + func getMyProfileOffline() -> Core.UserProfile? { return UserProfile( avatarUrl: "", name: "John Lennon", @@ -237,7 +233,7 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { public func deleteAccount(password: String) async throws -> Bool { return false } public func getSettings() -> UserSettings { - return UserSettings(wifiOnly: true, downloadQuality: .auto) + return UserSettings(wifiOnly: true, streamingQuality: .auto) } public func saveSettings(_ settings: UserSettings) {} } diff --git a/Profile/Profile/Domain/ProfileInteractor.swift b/Profile/Profile/Domain/ProfileInteractor.swift index 0f8fd4708..18e09aec2 100644 --- a/Profile/Profile/Domain/ProfileInteractor.swift +++ b/Profile/Profile/Domain/ProfileInteractor.swift @@ -13,7 +13,7 @@ import UIKit public protocol ProfileInteractorProtocol { func getUserProfile(username: String) async throws -> UserProfile func getMyProfile() async throws -> UserProfile - func getMyProfileOffline() throws -> UserProfile + func getMyProfileOffline() -> UserProfile? func logOut() async throws func getSpokenLanguages() -> [PickerFields.Option] func getCountries() -> [PickerFields.Option] @@ -41,8 +41,8 @@ public class ProfileInteractor: ProfileInteractorProtocol { return try await repository.getMyProfile() } - public func getMyProfileOffline() throws -> UserProfile { - return try repository.getMyProfileOffline() + public func getMyProfileOffline() -> UserProfile? { + return repository.getMyProfileOffline() } public func logOut() async throws { diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 03e8fd932..8df18256e 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -69,10 +69,10 @@ public struct ProfileView: View { .accessibilityElement(children: .ignore) .accessibilityLabel( (viewModel.userModel?.yearOfBirth != 0 ? - ProfileLocalization.Edit.Fields.yearOfBirth + String(viewModel.userModel?.yearOfBirth ?? 0) : + ProfileLocalization.Edit.Fields.yearOfBirth + String(viewModel.userModel?.yearOfBirth ?? 0) : "") + (viewModel.userModel?.shortBiography != nil ? - ProfileLocalization.bio + (viewModel.userModel?.shortBiography ?? "") : + ProfileLocalization.bio + (viewModel.userModel?.shortBiography ?? "") : "") ) .cardStyle( @@ -88,16 +88,16 @@ public struct ProfileView: View { .padding(.horizontal, 24) .font(Theme.Fonts.labelLarge) VStack(alignment: .leading, spacing: 27) { - Button(action: { - viewModel.trackProfileVideoSettingsClicked() - viewModel.router.showSettings() - }, label: { - HStack { + Button(action: { + viewModel.trackProfileVideoSettingsClicked() + viewModel.router.showSettings() + }, label: { + HStack { Text(ProfileLocalization.settingsVideo) Spacer() Image(systemName: "chevron.right") - } - }) + } + }) } .accessibilityElement(children: .ignore) @@ -168,6 +168,55 @@ public struct ProfileView: View { .accessibilityElement(children: .ignore) .accessibilityLabel(ProfileLocalization.privacy) } + + // MARK: Version + Rectangle() + .frame(height: 1) + .foregroundColor(Theme.Colors.textSecondary) + Button(action: { + viewModel.openAppStore() + }, label: { + HStack { + VStack(alignment: .leading, spacing: 0) { + HStack { + if viewModel.versionState == .updateRequired { + CoreAssets.warningFilled.swiftUIImage + .resizable() + .frame(width: 24, height: 24) + } + Text("\(ProfileLocalization.Settings.version) \(viewModel.currentVersion)") + } + switch viewModel.versionState { + case .actual: + HStack { + CoreAssets.checkmark.swiftUIImage + .renderingMode(.template) + .foregroundColor(.green) + Text(ProfileLocalization.Settings.upToDate) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textSecondary) + } + case .updateNeeded: + Text("\(ProfileLocalization.Settings.tapToUpdate) \(viewModel.latestVersion)") + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.accentColor) + case .updateRequired: + Text(ProfileLocalization.Settings.tapToInstall) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.accentColor) + } + } + Spacer() + if viewModel.versionState != .actual { + Image(systemName: "arrow.up.circle") + .resizable() + .frame(width: 24, height: 24) + .foregroundStyle(Theme.Colors.accentColor) + } + + } + }).disabled(viewModel.versionState == .actual) + }.cardStyle( bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear @@ -175,62 +224,60 @@ public struct ProfileView: View { // MARK: - Log out VStack { - Button(action: { - viewModel.router.presentView(transitionStyle: .crossDissolve) { - AlertView( - alertTitle: ProfileLocalization.LogoutAlert.title, - alertMessage: ProfileLocalization.LogoutAlert.text, - positiveAction: CoreLocalization.Alert.accept, - onCloseTapped: { - viewModel.router.dismiss(animated: true) - }, - okTapped: { - viewModel.router.dismiss(animated: true) - Task { - await viewModel.logOut() - } - }, type: .logOut - ) - } - }, label: { - HStack { + Button(action: { + viewModel.router.presentView(transitionStyle: .crossDissolve) { + AlertView( + alertTitle: ProfileLocalization.LogoutAlert.title, + alertMessage: ProfileLocalization.LogoutAlert.text, + positiveAction: CoreLocalization.Alert.accept, + onCloseTapped: { + viewModel.router.dismiss(animated: true) + }, + okTapped: { + viewModel.router.dismiss(animated: true) + Task { + await viewModel.logOut() + } + }, type: .logOut + ) + } + }, label: { + HStack { Text(ProfileLocalization.logout) Spacer() Image(systemName: "rectangle.portrait.and.arrow.right") - } - }) - .accessibilityElement(children: .ignore) - .accessibilityLabel(ProfileLocalization.logout) - + } + }) + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.logout) } .foregroundColor(Theme.Colors.alert) - .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, - strokeColor: .clear) - .padding(.top, 24) - .padding(.bottom, 60) + .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear) + .padding(.top, 24) + .padding(.bottom, 60) } Spacer() } } }.accessibilityAction {} - .frameLimit(sizePortrait: 420) + .frameLimit(sizePortrait: 420) .padding(.top, 8) .onChange(of: settingsTapped, perform: { _ in - if let userModel = viewModel.userModel { - viewModel.trackProfileEditClicked() - viewModel.router.showEditProfile( - userModel: userModel, - avatar: viewModel.updatedAvatar, - profileDidEdit: { updatedProfile, updatedImage in - if let updatedProfile { - self.viewModel.userModel = updatedProfile - } - if let updatedImage { - self.viewModel.updatedAvatar = updatedImage - } + let userModel = viewModel.userModel ?? UserProfile() + viewModel.trackProfileEditClicked() + viewModel.router.showEditProfile( + userModel: userModel, + avatar: viewModel.updatedAvatar, + profileDidEdit: { updatedProfile, updatedImage in + if let updatedProfile { + self.viewModel.userModel = updatedProfile } - ) - } + if let updatedImage { + self.viewModel.updatedAvatar = updatedImage + } + } + ) }) .navigationBarHidden(false) .navigationBarBackButtonHidden(false) diff --git a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift index 49e5dd254..e31d837be 100644 --- a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift @@ -5,7 +5,7 @@ // Created by  Stepanok Ivan on 22.09.2022. // -import Foundation +import Combine import Core import SwiftUI @@ -22,7 +22,17 @@ public class ProfileViewModel: ObservableObject { } } } + private var cancellables = Set() + enum VersionState { + case actual + case updateNeeded + case updateRequired + } + + @Published var versionState: VersionState = .actual + @Published var currentVersion: String = "" + @Published var latestVersion: String = "" let router: ProfileRouter let config: Config @@ -43,6 +53,29 @@ public class ProfileViewModel: ObservableObject { self.analytics = analytics self.config = config self.connectivity = connectivity + generateVersionState() + } + + func openAppStore() { + guard let appStoreURL = URL(string: config.appStoreLink) else { return } + UIApplication.shared.open(appStoreURL) + } + + func generateVersionState() { + guard let info = Bundle.main.infoDictionary else { return } + guard let currentVersion = info["CFBundleShortVersionString"] as? String else { return } + self.currentVersion = currentVersion + NotificationCenter.default.publisher(for: .onActualVersionReceived) + .sink { [weak self] notification in + guard let latestVersion = notification.object as? String else { return } + DispatchQueue.main.async { [weak self] in + self?.latestVersion = latestVersion + + if latestVersion != currentVersion { + self?.versionState = .updateNeeded + } + } + }.store(in: &cancellables) } func contactSupport() -> URL? { @@ -60,39 +93,34 @@ public class ProfileViewModel: ObservableObject { @MainActor func getMyProfile(withProgress: Bool = true) async { - isShowProgress = withProgress do { - if connectivity.isInternetAvaliable { - userModel = try await interactor.getMyProfile() - isShowProgress = false + let userModel = interactor.getMyProfileOffline() + if userModel == nil && connectivity.isInternetAvaliable { + isShowProgress = withProgress } else { - userModel = try interactor.getMyProfileOffline() - isShowProgress = false + self.userModel = userModel + } + if connectivity.isInternetAvaliable { + self.userModel = try await interactor.getMyProfile() } + isShowProgress = false } catch let error { isShowProgress = false - if error.isInternetError || error is NoCachedDataError { + if error.isUpdateRequeiredError { + self.versionState = .updateRequired + } else if error.isInternetError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection } else { errorMessage = CoreLocalization.Error.unknownError } - } } @MainActor func logOut() async { - do { - try await interactor.logOut() - router.showLoginScreen() - analytics.userLogout(force: false) - } catch let error { - if error.isInternetError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } - } + try? await interactor.logOut() + router.showLoginScreen() + analytics.userLogout(force: false) } func trackProfileVideoSettingsClicked() { diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift index da5a7f9dc..17fc43bae 100644 --- a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift @@ -19,6 +19,8 @@ public struct UserProfileView: View { public var body: some View { ZStack(alignment: .top) { + Theme.Colors.background + .ignoresSafeArea() // MARK: - Page Body RefreshableScrollViewCompat(action: { await viewModel.getUserProfile(withProgress: false) @@ -97,10 +99,6 @@ public struct UserProfileView: View { await viewModel.getUserProfile() } } - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } } diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index 99e11ba38..3126c0a00 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -22,15 +22,15 @@ public class SettingsViewModel: ObservableObject { } } - @Published var selectedQuality: VideoQuality { + @Published var selectedQuality: StreamingQuality { willSet { if newValue != selectedQuality { - userSettings.downloadQuality = newValue + userSettings.streamingQuality = newValue interactor.saveSettings(userSettings) } } } - let quality = Array([VideoQuality.auto, VideoQuality.low, VideoQuality.medium, VideoQuality.high].enumerated()) + let quality = Array([StreamingQuality.auto, StreamingQuality.low, StreamingQuality.medium, StreamingQuality.high].enumerated()) var errorMessage: String? { didSet { @@ -51,11 +51,11 @@ public class SettingsViewModel: ObservableObject { self.userSettings = interactor.getSettings() self.wifiOnly = userSettings.wifiOnly - self.selectedQuality = userSettings.downloadQuality + self.selectedQuality = userSettings.streamingQuality } } -extension VideoQuality { +extension StreamingQuality { func title() -> String { switch self { diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index 7f57c3ee7..8ff79c726 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -104,8 +104,8 @@ public enum ProfileLocalization { public static let title = ProfileLocalization.tr("Localizable", "LOGOUT_ALERT.TITLE", fallback: "Comfirm log out") } public enum Settings { - /// Smallest video quality - public static let quality360Description = ProfileLocalization.tr("Localizable", "SETTINGS.QUALITY_360_DESCRIPTION", fallback: "Smallest video quality") + /// Lower data usage + public static let quality360Description = ProfileLocalization.tr("Localizable", "SETTINGS.QUALITY_360_DESCRIPTION", fallback: "Lower data usage") /// 360p public static let quality360Title = ProfileLocalization.tr("Localizable", "SETTINGS.QUALITY_360_TITLE", fallback: "360p") /// 540p @@ -118,10 +118,18 @@ public enum ProfileLocalization { public static let qualityAutoDescription = ProfileLocalization.tr("Localizable", "SETTINGS.QUALITY_AUTO_DESCRIPTION", fallback: "Recommended") /// Auto public static let qualityAutoTitle = ProfileLocalization.tr("Localizable", "SETTINGS.QUALITY_AUTO_TITLE", fallback: "Auto") + /// Tap to install required app update + public static let tapToInstall = ProfileLocalization.tr("Localizable", "SETTINGS.TAP_TO_INSTALL", fallback: "Tap to install required app update") + /// Tap to update to version + public static let tapToUpdate = ProfileLocalization.tr("Localizable", "SETTINGS.TAP_TO_UPDATE", fallback: "Tap to update to version") + /// Up-to-date + public static let upToDate = ProfileLocalization.tr("Localizable", "SETTINGS.UP_TO_DATE", fallback: "Up-to-date") + /// Version: + public static let version = ProfileLocalization.tr("Localizable", "SETTINGS.VERSION", fallback: "Version:") /// Auto (Recommended) public static let videoQualityDescription = ProfileLocalization.tr("Localizable", "SETTINGS.VIDEO_QUALITY_DESCRIPTION", fallback: "Auto (Recommended)") - /// Video download quality - public static let videoQualityTitle = ProfileLocalization.tr("Localizable", "SETTINGS.VIDEO_QUALITY_TITLE", fallback: "Video download quality") + /// Video streaming quality + public static let videoQualityTitle = ProfileLocalization.tr("Localizable", "SETTINGS.VIDEO_QUALITY_TITLE", fallback: "Video streaming quality") /// Video settings public static let videoSettingsTitle = ProfileLocalization.tr("Localizable", "SETTINGS.VIDEO_SETTINGS_TITLE", fallback: "Video settings") /// Only download content when wi-fi is turned on diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index 9eef8b47f..c626255de 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -58,13 +58,18 @@ "SETTINGS.VIDEO_SETTINGS_TITLE" = "Video settings"; "SETTINGS.WIFI_TITLE" = "Wi-fi only download"; "SETTINGS.WIFI_DESCRIPTION" = "Only download content when wi-fi is turned on"; -"SETTINGS.VIDEO_QUALITY_TITLE" = "Video download quality"; +"SETTINGS.VIDEO_QUALITY_TITLE" = "Video streaming quality"; "SETTINGS.VIDEO_QUALITY_DESCRIPTION" = "Auto (Recommended)"; "SETTINGS.QUALITY_AUTO_TITLE" = "Auto"; "SETTINGS.QUALITY_AUTO_DESCRIPTION" = "Recommended"; "SETTINGS.QUALITY_360_TITLE" = "360p"; -"SETTINGS.QUALITY_360_DESCRIPTION" = "Smallest video quality"; +"SETTINGS.QUALITY_360_DESCRIPTION" = "Lower data usage"; "SETTINGS.QUALITY_540_TITLE" = "540p"; "SETTINGS.QUALITY_720_TITLE" = "720p"; "SETTINGS.QUALITY_720_DESCRIPTION" = "Best quality"; + +"SETTINGS.VERSION" = "Version:"; +"SETTINGS.UP_TO_DATE" = "Up-to-date"; +"SETTINGS.TAP_TO_UPDATE" = "Tap to update to version"; +"SETTINGS.TAP_TO_INSTALL" = "Tap to install required app update"; diff --git a/Profile/Profile/uk.lproj/Localizable.strings b/Profile/Profile/uk.lproj/Localizable.strings index 575f9dca8..8b5df15fd 100644 --- a/Profile/Profile/uk.lproj/Localizable.strings +++ b/Profile/Profile/uk.lproj/Localizable.strings @@ -58,13 +58,18 @@ "SETTINGS.VIDEO_SETTINGS_TITLE" = "Налаштування відео"; "SETTINGS.WIFI_TITLE" = "Тільки Wi-fi"; "SETTINGS.WIFI_DESCRIPTION" = "Завантажувати відео, лише коли Wi-Fi увімкнено"; -"SETTINGS.VIDEO_QUALITY_TITLE" = "Якість відео"; +"SETTINGS.VIDEO_QUALITY_TITLE" = "Якість потокового відео"; "SETTINGS.VIDEO_QUALITY_DESCRIPTION" = "Авто (Рекомендовано)"; "SETTINGS.QUALITY_AUTO_TITLE" = "Авто"; "SETTINGS.QUALITY_AUTO_DESCRIPTION" = "Рекомендовано"; "SETTINGS.QUALITY_360_TITLE" = "360p"; -"SETTINGS.QUALITY_360_DESCRIPTION" = "Найменша якість відео"; +"SETTINGS.QUALITY_360_DESCRIPTION" = "економія трафіку"; "SETTINGS.QUALITY_540_TITLE" = "540p"; "SETTINGS.QUALITY_720_TITLE" = "720p"; "SETTINGS.QUALITY_AUTO_DESCRIPTION" = "Найкраща якість"; + +"SETTINGS.VERSION" = "Версія:"; +"SETTINGS.UP_TO_DATE" = "Оновлено"; +"SETTINGS.TAP_TO_UPDATE" = "Клацніть, щоб оновити до версії"; +"SETTINGS.TAP_TO_INSTALL" = "Клацніть, щоб встановити обов'язкове оновлення програми"; diff --git a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift index 3c9a7d0f4..a66d1929a 100644 --- a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift @@ -110,6 +110,7 @@ final class ProfileViewModelTests: XCTestCase { ) Given(connectivity, .isInternetAvaliable(getter: true)) + Given(interactor, .getMyProfileOffline(willReturn: user)) Given(interactor, .getMyProfile(willReturn: user)) await viewModel.getMyProfile() @@ -172,35 +173,22 @@ final class ProfileViewModelTests: XCTestCase { connectivity: connectivity ) - let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) - - Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .getMyProfile(willThrow: noInternetError) ) - - await viewModel.getMyProfile() - - Verify(interactor, 1, .getMyProfile()) - - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) - XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - } - - 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 + let user = UserProfile( + avatarUrl: "", + name: "Steve", + username: "Steve", + dateJoined: Date(), + yearOfBirth: 2000, + country: "Ua", + shortBiography: "Bio", + isFullProfile: false ) + let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) + Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .getMyProfile(willThrow: NoCachedDataError())) + Given(interactor, .getMyProfileOffline(willReturn: user)) + Given(interactor, .getMyProfile(willThrow: noInternetError)) await viewModel.getMyProfile() @@ -250,7 +238,6 @@ final class ProfileViewModelTests: XCTestCase { ) Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .logOut(willProduce: {_ in})) await viewModel.logOut() @@ -258,52 +245,6 @@ final class ProfileViewModelTests: XCTestCase { XCTAssertFalse(viewModel.showError) } - 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 - ) - - let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) - - Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .logOut(willThrow: noInternetError)) - - await viewModel.logOut() - - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) - } - - 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 - ) - - Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .logOut(willThrow: NSError())) - - await viewModel.logOut() - - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) - } - func testTrackProfileVideoSettingsClicked() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index e8f39508d..1321a7d0f 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT @@ -490,9 +490,9 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -545,7 +545,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -578,7 +578,7 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -630,7 +630,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -647,7 +647,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -678,7 +678,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -707,8 +707,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) @@ -1770,18 +1770,15 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { return __value } - open func getMyProfileOffline() throws -> UserProfile { + open func getMyProfileOffline() -> UserProfile? { addInvocation(.m_getMyProfileOffline) let perform = methodPerformValue(.m_getMyProfileOffline) as? () -> Void perform?() - var __value: UserProfile + var __value: UserProfile? = nil do { __value = try methodReturnValue(.m_getMyProfileOffline).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for getMyProfileOffline(). Use given") - Failure("Stub return value not specified for getMyProfileOffline(). Use given") } catch { - throw error + // do nothing } return __value } @@ -2016,7 +2013,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public static func getMyProfile(willReturn: UserProfile...) -> MethodStub { return Given(method: .m_getMyProfile, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getMyProfileOffline(willReturn: UserProfile...) -> MethodStub { + public static func getMyProfileOffline(willReturn: UserProfile?...) -> MethodStub { return Given(method: .m_getMyProfileOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func getSpokenLanguages(willReturn: [PickerFields.Option]...) -> MethodStub { @@ -2037,6 +2034,13 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public static func getSettings(willReturn: UserSettings...) -> MethodStub { return Given(method: .m_getSettings, products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func getMyProfileOffline(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [UserProfile?] = [] + let given: Given = { return Given(method: .m_getMyProfileOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (UserProfile?).self) + willProduce(stubber) + return given + } public static func getSpokenLanguages(willProduce: (Stubber<[PickerFields.Option]>) -> Void) -> MethodStub { let willReturn: [[PickerFields.Option]] = [] let given: Given = { return Given(method: .m_getSpokenLanguages, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2078,16 +2082,6 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { willProduce(stubber) return given } - public static func getMyProfileOffline(willThrow: Error...) -> MethodStub { - return Given(method: .m_getMyProfileOffline, products: willThrow.map({ StubProduct.throw($0) })) - } - public static func getMyProfileOffline(willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_getMyProfileOffline, products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (UserProfile).self) - willProduce(stubber) - return given - } public static func logOut(willThrow: Error...) -> MethodStub { return Given(method: .m_logOut, products: willThrow.map({ StubProduct.throw($0) })) } @@ -2370,9 +2364,9 @@ open class ProfileRouterMock: ProfileRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -2429,7 +2423,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -2478,7 +2472,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -2534,7 +2528,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -2555,7 +2549,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -2590,7 +2584,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -2631,8 +2625,8 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) diff --git a/WhatsNew/.gitignore b/WhatsNew/.gitignore new file mode 100644 index 000000000..9c22c8b85 --- /dev/null +++ b/WhatsNew/.gitignore @@ -0,0 +1,99 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/* +/WhatsNew.xcodeproj/xcuserdata/ +/WhatsNew.xcworkspace/xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## R.swift +R.generated.swift + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +.DS_Store +.idea +xcode-frameworks diff --git a/WhatsNew/Mockfile b/WhatsNew/Mockfile new file mode 100644 index 000000000..3fee3de2b --- /dev/null +++ b/WhatsNew/Mockfile @@ -0,0 +1,17 @@ +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate +unit.tests.mock: + sources: + include: + - ./../WhatsNew + - ./WhatsNew + exclude: [] + output: ./WhatsNewTests/WhatsNewMock.generated.swift + targets: + - MyAppUnitTests + import: + - Core + - WhatsNew + - Foundation + - SwiftUI + - Combine \ No newline at end of file diff --git a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj new file mode 100644 index 000000000..dad830243 --- /dev/null +++ b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj @@ -0,0 +1,1499 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + 020A7B5F2AE131A9000BAF70 /* WhatsNewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020A7B5E2AE131A9000BAF70 /* WhatsNewModel.swift */; }; + 020A7B612AE136D2000BAF70 /* WhatsNew.json in Resources */ = {isa = PBXBuildFile; fileRef = 020A7B602AE136D2000BAF70 /* WhatsNew.json */; }; + 020AC2692AEBB69E0086E975 /* WhatsNewMock.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020AC2682AEBB69E0086E975 /* WhatsNewMock.generated.swift */; }; + 028A37262ADFF3F8008CA604 /* WhatsNew.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 028A371D2ADFF3F7008CA604 /* WhatsNew.framework */; }; + 028A372B2ADFF3F8008CA604 /* WhatsNewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028A372A2ADFF3F8008CA604 /* WhatsNewTests.swift */; }; + 028A373A2ADFF425008CA604 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 028A37392ADFF425008CA604 /* Core.framework */; }; + 02B54E0D2AE0331F00C56962 /* WhatsNewNavigationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B54E0C2AE0331F00C56962 /* WhatsNewNavigationButton.swift */; }; + 02B54E0F2AE0337800C56962 /* PageControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B54E0E2AE0337800C56962 /* PageControl.swift */; }; + 02B54E112AE061C100C56962 /* WhatsNewRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B54E102AE061C100C56962 /* WhatsNewRouter.swift */; }; + 02E640792ADFF5920079AEDA /* WhatsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E640782ADFF5920079AEDA /* WhatsNewView.swift */; }; + 02E6407C2ADFF6250079AEDA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 02E6407E2ADFF6250079AEDA /* Localizable.strings */; }; + 02E640812ADFFE440079AEDA /* WhatsNewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E640802ADFFE440079AEDA /* WhatsNewViewModel.swift */; }; + 02E640862ADFFF380079AEDA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 02E640852ADFFF380079AEDA /* Assets.xcassets */; }; + 02E6408A2AE004300079AEDA /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E640892AE004300079AEDA /* Strings.swift */; }; + 02E6408C2AE006680079AEDA /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 02E6408B2AE006680079AEDA /* swiftgen.yml */; }; + 02EC90AA2AE904E1007DE1E0 /* WhatsNewStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EC90A92AE904E1007DE1E0 /* WhatsNewStorage.swift */; }; + 02EC90AC2AE90C64007DE1E0 /* WhatsNewPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EC90AB2AE90C64007DE1E0 /* WhatsNewPage.swift */; }; + B3BB9B06B226989A619C6440 /* Pods_App_WhatsNew.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05AC45C7050E30F8394E0C76 /* Pods_App_WhatsNew.framework */; }; + EF5CA11A55CB49F2DA030D25 /* Pods_App_WhatsNew_WhatsNewTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8D1A5DF016EC4630637336C /* Pods_App_WhatsNew_WhatsNewTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 028A37272ADFF3F8008CA604 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 028A37142ADFF3F7008CA604 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 028A371C2ADFF3F7008CA604; + remoteInfo = WhatsNew; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 020A7B5E2AE131A9000BAF70 /* WhatsNewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewModel.swift; sourceTree = ""; }; + 020A7B602AE136D2000BAF70 /* WhatsNew.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; name = WhatsNew.json; path = WhatsNew/Data/WhatsNew.json; sourceTree = SOURCE_ROOT; }; + 020AC2682AEBB69E0086E975 /* WhatsNewMock.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = WhatsNewMock.generated.swift; path = WhatsNewTests/WhatsNewMock.generated.swift; sourceTree = SOURCE_ROOT; }; + 028A371D2ADFF3F7008CA604 /* WhatsNew.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = WhatsNew.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 028A37252ADFF3F7008CA604 /* WhatsNewTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WhatsNewTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 028A372A2ADFF3F8008CA604 /* WhatsNewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewTests.swift; sourceTree = ""; }; + 028A37392ADFF425008CA604 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 02B54E0C2AE0331F00C56962 /* WhatsNewNavigationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewNavigationButton.swift; sourceTree = ""; }; + 02B54E0E2AE0337800C56962 /* PageControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageControl.swift; sourceTree = ""; }; + 02B54E102AE061C100C56962 /* WhatsNewRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewRouter.swift; sourceTree = ""; }; + 02E640782ADFF5920079AEDA /* WhatsNewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewView.swift; sourceTree = ""; }; + 02E6407D2ADFF6250079AEDA /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 02E6407F2ADFF6270079AEDA /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; + 02E640802ADFFE440079AEDA /* WhatsNewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewViewModel.swift; sourceTree = ""; }; + 02E640852ADFFF380079AEDA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 02E640892AE004300079AEDA /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; + 02E6408B2AE006680079AEDA /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; + 02EC90A92AE904E1007DE1E0 /* WhatsNewStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewStorage.swift; sourceTree = ""; }; + 02EC90AB2AE90C64007DE1E0 /* WhatsNewPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewPage.swift; sourceTree = ""; }; + 02EC90B12AE91BF1007DE1E0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 05AC45C7050E30F8394E0C76 /* Pods_App_WhatsNew.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_WhatsNew.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0C01007F0E8CEDCD293E0A68 /* Pods-App-WhatsNew.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.debug.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.debug.xcconfig"; sourceTree = ""; }; + 1E3F4487E7D3A48F5FD12DDA /* Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig"; sourceTree = ""; }; + 34C1F2BEAF7F0DCB8E630F33 /* Pods-App-WhatsNew.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.releasestage.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.releasestage.xcconfig"; sourceTree = ""; }; + 365FD817D70DFBCBDE2EAE5F /* Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig"; sourceTree = ""; }; + 4CB92C9DBA730A1B06B076BA /* Pods-App-WhatsNew.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.release.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.release.xcconfig"; sourceTree = ""; }; + 58DF8140E3B3436F58C4C8B9 /* Pods-App-WhatsNew-WhatsNewTests.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.releasedev.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.releasedev.xcconfig"; sourceTree = ""; }; + 6F50A409FBCCC7C08712A25E /* Pods-App-WhatsNew.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.debugdev.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.debugdev.xcconfig"; sourceTree = ""; }; + 9176CDC000731B73D2F10372 /* Pods-App-WhatsNew.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.debugstage.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.debugstage.xcconfig"; sourceTree = ""; }; + 9844714991FA40ECDC228CC9 /* Pods-App-WhatsNew.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.releasedev.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.releasedev.xcconfig"; sourceTree = ""; }; + A2CABA11F5E7F89EFD9A05AC /* Pods-App-WhatsNew-WhatsNewTests.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.debugstage.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.debugstage.xcconfig"; sourceTree = ""; }; + A557A3CED4D6327AAE6AA02C /* Pods-App-WhatsNew-WhatsNewTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.release.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.release.xcconfig"; sourceTree = ""; }; + A76975E21FF282D59CEC4452 /* Pods-App-WhatsNew.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.debugprod.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.debugprod.xcconfig"; sourceTree = ""; }; + AB8156676C9C771D691ADE07 /* Pods-App-WhatsNew.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.releaseprod.xcconfig"; sourceTree = ""; }; + B7EAC5E8F0ED2F1F81050C30 /* Pods-App-WhatsNew-WhatsNewTests.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.debugprod.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.debugprod.xcconfig"; sourceTree = ""; }; + E905D28DCAA1940E08C96896 /* Pods-App-WhatsNew-WhatsNewTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.debug.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.debug.xcconfig"; sourceTree = ""; }; + F71207762CEF1763A08C7151 /* Pods-App-WhatsNew-WhatsNewTests.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.debugdev.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.debugdev.xcconfig"; sourceTree = ""; }; + F8D1A5DF016EC4630637336C /* Pods_App_WhatsNew_WhatsNewTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_WhatsNew_WhatsNewTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 028A371A2ADFF3F7008CA604 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 028A373A2ADFF425008CA604 /* Core.framework in Frameworks */, + B3BB9B06B226989A619C6440 /* Pods_App_WhatsNew.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 028A37222ADFF3F7008CA604 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 028A37262ADFF3F8008CA604 /* WhatsNew.framework in Frameworks */, + EF5CA11A55CB49F2DA030D25 /* Pods_App_WhatsNew_WhatsNewTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 020A7B5D2AE1317E000BAF70 /* Domain */ = { + isa = PBXGroup; + children = ( + 02EC90AB2AE90C64007DE1E0 /* WhatsNewPage.swift */, + ); + path = Domain; + sourceTree = ""; + }; + 028A37132ADFF3F7008CA604 = { + isa = PBXGroup; + children = ( + 02E6408B2AE006680079AEDA /* swiftgen.yml */, + 028A371F2ADFF3F7008CA604 /* WhatsNew */, + 028A37292ADFF3F8008CA604 /* WhatsNewTests */, + 028A371E2ADFF3F7008CA604 /* Products */, + 028A37382ADFF425008CA604 /* Frameworks */, + 3397DFC72A3A62728BCA5367 /* Pods */, + ); + sourceTree = ""; + }; + 028A371E2ADFF3F7008CA604 /* Products */ = { + isa = PBXGroup; + children = ( + 028A371D2ADFF3F7008CA604 /* WhatsNew.framework */, + 028A37252ADFF3F7008CA604 /* WhatsNewTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 028A371F2ADFF3F7008CA604 /* WhatsNew */ = { + isa = PBXGroup; + children = ( + 02E640722ADFF54E0079AEDA /* SwiftGen */, + 02E640822ADFFEB00079AEDA /* Data */, + 020A7B5D2AE1317E000BAF70 /* Domain */, + 02E640752ADFF5700079AEDA /* Presentation */, + 02E6407E2ADFF6250079AEDA /* Localizable.strings */, + 02E640852ADFFF380079AEDA /* Assets.xcassets */, + 02EC90B12AE91BF1007DE1E0 /* Info.plist */, + ); + path = WhatsNew; + sourceTree = ""; + }; + 028A37292ADFF3F8008CA604 /* WhatsNewTests */ = { + isa = PBXGroup; + children = ( + 02EC90B52AE92AEB007DE1E0 /* Presentation */, + 020AC2682AEBB69E0086E975 /* WhatsNewMock.generated.swift */, + ); + path = WhatsNewTests; + sourceTree = ""; + }; + 028A37382ADFF425008CA604 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 028A37392ADFF425008CA604 /* Core.framework */, + 05AC45C7050E30F8394E0C76 /* Pods_App_WhatsNew.framework */, + F8D1A5DF016EC4630637336C /* Pods_App_WhatsNew_WhatsNewTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 02B54E0B2AE0330F00C56962 /* Elements */ = { + isa = PBXGroup; + children = ( + 02B54E0C2AE0331F00C56962 /* WhatsNewNavigationButton.swift */, + 02B54E0E2AE0337800C56962 /* PageControl.swift */, + ); + path = Elements; + sourceTree = ""; + }; + 02E640722ADFF54E0079AEDA /* SwiftGen */ = { + isa = PBXGroup; + children = ( + 02E640892AE004300079AEDA /* Strings.swift */, + ); + path = SwiftGen; + sourceTree = ""; + }; + 02E640752ADFF5700079AEDA /* Presentation */ = { + isa = PBXGroup; + children = ( + 02B54E0B2AE0330F00C56962 /* Elements */, + 02E640782ADFF5920079AEDA /* WhatsNewView.swift */, + 02E640802ADFFE440079AEDA /* WhatsNewViewModel.swift */, + 02B54E102AE061C100C56962 /* WhatsNewRouter.swift */, + ); + path = Presentation; + sourceTree = ""; + }; + 02E640822ADFFEB00079AEDA /* Data */ = { + isa = PBXGroup; + children = ( + 020A7B5E2AE131A9000BAF70 /* WhatsNewModel.swift */, + 020A7B602AE136D2000BAF70 /* WhatsNew.json */, + 02EC90A92AE904E1007DE1E0 /* WhatsNewStorage.swift */, + ); + path = Data; + sourceTree = ""; + }; + 02EC90B52AE92AEB007DE1E0 /* Presentation */ = { + isa = PBXGroup; + children = ( + 028A372A2ADFF3F8008CA604 /* WhatsNewTests.swift */, + ); + path = Presentation; + sourceTree = ""; + }; + 3397DFC72A3A62728BCA5367 /* Pods */ = { + isa = PBXGroup; + children = ( + 0C01007F0E8CEDCD293E0A68 /* Pods-App-WhatsNew.debug.xcconfig */, + 4CB92C9DBA730A1B06B076BA /* Pods-App-WhatsNew.release.xcconfig */, + 9176CDC000731B73D2F10372 /* Pods-App-WhatsNew.debugstage.xcconfig */, + A76975E21FF282D59CEC4452 /* Pods-App-WhatsNew.debugprod.xcconfig */, + 6F50A409FBCCC7C08712A25E /* Pods-App-WhatsNew.debugdev.xcconfig */, + 34C1F2BEAF7F0DCB8E630F33 /* Pods-App-WhatsNew.releasestage.xcconfig */, + AB8156676C9C771D691ADE07 /* Pods-App-WhatsNew.releaseprod.xcconfig */, + 9844714991FA40ECDC228CC9 /* Pods-App-WhatsNew.releasedev.xcconfig */, + E905D28DCAA1940E08C96896 /* Pods-App-WhatsNew-WhatsNewTests.debug.xcconfig */, + A2CABA11F5E7F89EFD9A05AC /* Pods-App-WhatsNew-WhatsNewTests.debugstage.xcconfig */, + B7EAC5E8F0ED2F1F81050C30 /* Pods-App-WhatsNew-WhatsNewTests.debugprod.xcconfig */, + F71207762CEF1763A08C7151 /* Pods-App-WhatsNew-WhatsNewTests.debugdev.xcconfig */, + A557A3CED4D6327AAE6AA02C /* Pods-App-WhatsNew-WhatsNewTests.release.xcconfig */, + 1E3F4487E7D3A48F5FD12DDA /* Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig */, + 365FD817D70DFBCBDE2EAE5F /* Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig */, + 58DF8140E3B3436F58C4C8B9 /* Pods-App-WhatsNew-WhatsNewTests.releasedev.xcconfig */, + ); + name = Pods; + path = ../Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 028A37182ADFF3F7008CA604 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 028A371C2ADFF3F7008CA604 /* WhatsNew */ = { + isa = PBXNativeTarget; + buildConfigurationList = 028A372F2ADFF3F8008CA604 /* Build configuration list for PBXNativeTarget "WhatsNew" */; + buildPhases = ( + E5055BD989FEEC50EF87C814 /* [CP] Check Pods Manifest.lock */, + 02E6408E2AE007090079AEDA /* SwiftGen */, + 028A37182ADFF3F7008CA604 /* Headers */, + 028A37192ADFF3F7008CA604 /* Sources */, + 028A371A2ADFF3F7008CA604 /* Frameworks */, + 028A371B2ADFF3F7008CA604 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WhatsNew; + productName = WhatsNew; + productReference = 028A371D2ADFF3F7008CA604 /* WhatsNew.framework */; + productType = "com.apple.product-type.framework"; + }; + 028A37242ADFF3F7008CA604 /* WhatsNewTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 028A37322ADFF3F8008CA604 /* Build configuration list for PBXNativeTarget "WhatsNewTests" */; + buildPhases = ( + 8685C1ADA448B11AB167C40E /* [CP] Check Pods Manifest.lock */, + 028A37212ADFF3F7008CA604 /* Sources */, + 028A37222ADFF3F7008CA604 /* Frameworks */, + 028A37232ADFF3F7008CA604 /* Resources */, + 8A74692D666D8FF13F7BA64F /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + 028A37282ADFF3F8008CA604 /* PBXTargetDependency */, + ); + name = WhatsNewTests; + productName = WhatsNewTests; + productReference = 028A37252ADFF3F7008CA604 /* WhatsNewTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 028A37142ADFF3F7008CA604 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + 028A371C2ADFF3F7008CA604 = { + CreatedOnToolsVersion = 15.0; + LastSwiftMigration = 1500; + }; + 028A37242ADFF3F7008CA604 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = 028A37172ADFF3F7008CA604 /* Build configuration list for PBXProject "WhatsNew" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + uk, + ); + mainGroup = 028A37132ADFF3F7008CA604; + productRefGroup = 028A371E2ADFF3F7008CA604 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 028A371C2ADFF3F7008CA604 /* WhatsNew */, + 028A37242ADFF3F7008CA604 /* WhatsNewTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 028A371B2ADFF3F7008CA604 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 02E640862ADFFF380079AEDA /* Assets.xcassets in Resources */, + 02E6407C2ADFF6250079AEDA /* Localizable.strings in Resources */, + 020A7B612AE136D2000BAF70 /* WhatsNew.json in Resources */, + 02E6408C2AE006680079AEDA /* swiftgen.yml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 028A37232ADFF3F7008CA604 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 02E6408E2AE007090079AEDA /* SwiftGen */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = SwiftGen; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f \"${PODS_ROOT}/SwiftGen/bin/swiftgen\" ]]; then\n \"${PODS_ROOT}/SwiftGen/bin/swiftgen\"\nelse\n echo \"warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it.\"\nfi\n"; + }; + 8685C1ADA448B11AB167C40E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-App-WhatsNew-WhatsNewTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 8A74692D666D8FF13F7BA64F /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + E5055BD989FEEC50EF87C814 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-App-WhatsNew-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 028A37192ADFF3F7008CA604 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 02E640812ADFFE440079AEDA /* WhatsNewViewModel.swift in Sources */, + 02B54E112AE061C100C56962 /* WhatsNewRouter.swift in Sources */, + 020A7B5F2AE131A9000BAF70 /* WhatsNewModel.swift in Sources */, + 02B54E0F2AE0337800C56962 /* PageControl.swift in Sources */, + 02EC90AC2AE90C64007DE1E0 /* WhatsNewPage.swift in Sources */, + 02EC90AA2AE904E1007DE1E0 /* WhatsNewStorage.swift in Sources */, + 02E6408A2AE004300079AEDA /* Strings.swift in Sources */, + 02E640792ADFF5920079AEDA /* WhatsNewView.swift in Sources */, + 02B54E0D2AE0331F00C56962 /* WhatsNewNavigationButton.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 028A37212ADFF3F7008CA604 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 028A372B2ADFF3F8008CA604 /* WhatsNewTests.swift in Sources */, + 020AC2692AEBB69E0086E975 /* WhatsNewMock.generated.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 028A37282ADFF3F8008CA604 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 028A371C2ADFF3F7008CA604 /* WhatsNew */; + targetProxy = 028A37272ADFF3F8008CA604 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 02E6407E2ADFF6250079AEDA /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 02E6407D2ADFF6250079AEDA /* en */, + 02E6407F2ADFF6270079AEDA /* uk */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 028A372D2ADFF3F8008CA604 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 028A372E2ADFF3F8008CA604 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 028A37302ADFF3F8008CA604 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0C01007F0E8CEDCD293E0A68 /* Pods-App-WhatsNew.debug.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 028A37312ADFF3F8008CA604 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4CB92C9DBA730A1B06B076BA /* Pods-App-WhatsNew.release.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 028A37332ADFF3F8008CA604 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E905D28DCAA1940E08C96896 /* Pods-App-WhatsNew-WhatsNewTests.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 028A37342ADFF3F8008CA604 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A557A3CED4D6327AAE6AA02C /* Pods-App-WhatsNew-WhatsNewTests.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 02E6405E2ADFF4DE0079AEDA /* DebugDev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = DebugDev; + }; + 02E6405F2ADFF4DE0079AEDA /* DebugDev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6F50A409FBCCC7C08712A25E /* Pods-App-WhatsNew.debugdev.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugDev; + }; + 02E640602ADFF4DE0079AEDA /* DebugDev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F71207762CEF1763A08C7151 /* Pods-App-WhatsNew-WhatsNewTests.debugdev.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugDev; + }; + 02E640612ADFF4E50079AEDA /* DebugProd */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = DebugProd; + }; + 02E640622ADFF4E50079AEDA /* DebugProd */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A76975E21FF282D59CEC4452 /* Pods-App-WhatsNew.debugprod.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugProd; + }; + 02E640632ADFF4E50079AEDA /* DebugProd */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B7EAC5E8F0ED2F1F81050C30 /* Pods-App-WhatsNew-WhatsNewTests.debugprod.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugProd; + }; + 02E640642ADFF4EA0079AEDA /* DebugStage */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = DebugStage; + }; + 02E640652ADFF4EA0079AEDA /* DebugStage */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9176CDC000731B73D2F10372 /* Pods-App-WhatsNew.debugstage.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugStage; + }; + 02E640662ADFF4EA0079AEDA /* DebugStage */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A2CABA11F5E7F89EFD9A05AC /* Pods-App-WhatsNew-WhatsNewTests.debugstage.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugStage; + }; + 02E640672ADFF4F10079AEDA /* ReleaseDev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = ReleaseDev; + }; + 02E640682ADFF4F10079AEDA /* ReleaseDev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9844714991FA40ECDC228CC9 /* Pods-App-WhatsNew.releasedev.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = ReleaseDev; + }; + 02E640692ADFF4F10079AEDA /* ReleaseDev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 58DF8140E3B3436F58C4C8B9 /* Pods-App-WhatsNew-WhatsNewTests.releasedev.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = ReleaseDev; + }; + 02E6406A2ADFF4F70079AEDA /* ReleaseProd */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = ReleaseProd; + }; + 02E6406B2ADFF4F70079AEDA /* ReleaseProd */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AB8156676C9C771D691ADE07 /* Pods-App-WhatsNew.releaseprod.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = ReleaseProd; + }; + 02E6406C2ADFF4F70079AEDA /* ReleaseProd */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 365FD817D70DFBCBDE2EAE5F /* Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = ReleaseProd; + }; + 02E6406D2ADFF4FD0079AEDA /* ReleaseStage */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = ReleaseStage; + }; + 02E6406E2ADFF4FD0079AEDA /* ReleaseStage */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 34C1F2BEAF7F0DCB8E630F33 /* Pods-App-WhatsNew.releasestage.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = ReleaseStage; + }; + 02E6406F2ADFF4FD0079AEDA /* ReleaseStage */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1E3F4487E7D3A48F5FD12DDA /* Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = ReleaseStage; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 028A37172ADFF3F7008CA604 /* Build configuration list for PBXProject "WhatsNew" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 028A372D2ADFF3F8008CA604 /* Debug */, + 02E640642ADFF4EA0079AEDA /* DebugStage */, + 02E640612ADFF4E50079AEDA /* DebugProd */, + 02E6405E2ADFF4DE0079AEDA /* DebugDev */, + 028A372E2ADFF3F8008CA604 /* Release */, + 02E6406D2ADFF4FD0079AEDA /* ReleaseStage */, + 02E6406A2ADFF4F70079AEDA /* ReleaseProd */, + 02E640672ADFF4F10079AEDA /* ReleaseDev */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 028A372F2ADFF3F8008CA604 /* Build configuration list for PBXNativeTarget "WhatsNew" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 028A37302ADFF3F8008CA604 /* Debug */, + 02E640652ADFF4EA0079AEDA /* DebugStage */, + 02E640622ADFF4E50079AEDA /* DebugProd */, + 02E6405F2ADFF4DE0079AEDA /* DebugDev */, + 028A37312ADFF3F8008CA604 /* Release */, + 02E6406E2ADFF4FD0079AEDA /* ReleaseStage */, + 02E6406B2ADFF4F70079AEDA /* ReleaseProd */, + 02E640682ADFF4F10079AEDA /* ReleaseDev */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 028A37322ADFF3F8008CA604 /* Build configuration list for PBXNativeTarget "WhatsNewTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 028A37332ADFF3F8008CA604 /* Debug */, + 02E640662ADFF4EA0079AEDA /* DebugStage */, + 02E640632ADFF4E50079AEDA /* DebugProd */, + 02E640602ADFF4DE0079AEDA /* DebugDev */, + 028A37342ADFF3F8008CA604 /* Release */, + 02E6406F2ADFF4FD0079AEDA /* ReleaseStage */, + 02E6406C2ADFF4F70079AEDA /* ReleaseProd */, + 02E640692ADFF4F10079AEDA /* ReleaseDev */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 028A37142ADFF3F7008CA604 /* Project object */; +} diff --git a/WhatsNew/WhatsNew.xcodeproj/xcshareddata/xcschemes/WhatsNew.xcscheme b/WhatsNew/WhatsNew.xcodeproj/xcshareddata/xcschemes/WhatsNew.xcscheme new file mode 100644 index 000000000..c7efb774a --- /dev/null +++ b/WhatsNew/WhatsNew.xcodeproj/xcshareddata/xcschemes/WhatsNew.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/Contents.json b/WhatsNew/WhatsNew/Assets.xcassets/1.0/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/WhatsNew/WhatsNew/Assets.xcassets/1.0/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Contents.json b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Contents.json new file mode 100644 index 000000000..52b4c6b95 --- /dev/null +++ b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Group 97.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : true + } +} diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Group 97.png b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Group 97.png new file mode 100644 index 000000000..1853bf8ab Binary files /dev/null and b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Group 97.png differ diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Contents.json b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Contents.json new file mode 100644 index 000000000..80d1a4ce5 --- /dev/null +++ b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group 96-2.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Group 96-2.png b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Group 96-2.png new file mode 100644 index 000000000..36b711417 Binary files /dev/null and b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Group 96-2.png differ diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/image3_1.0.imageset/Contents.json b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image3_1.0.imageset/Contents.json new file mode 100644 index 000000000..a58f59752 --- /dev/null +++ b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image3_1.0.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "globe.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/image3_1.0.imageset/globe.png b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image3_1.0.imageset/globe.png new file mode 100644 index 000000000..bbdbb3514 Binary files /dev/null and b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image3_1.0.imageset/globe.png differ diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/image4_1.0.imageset/Contents.json b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image4_1.0.imageset/Contents.json new file mode 100644 index 000000000..5b0cf9a6b --- /dev/null +++ b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image4_1.0.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "feature screenshot.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/image4_1.0.imageset/feature screenshot.jpg b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image4_1.0.imageset/feature screenshot.jpg new file mode 100644 index 000000000..5ca9740d9 Binary files /dev/null and b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image4_1.0.imageset/feature screenshot.jpg differ diff --git a/WhatsNew/WhatsNew/Assets.xcassets/Contents.json b/WhatsNew/WhatsNew/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/WhatsNew/WhatsNew/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhatsNew/WhatsNew/Data/WhatsNew.json b/WhatsNew/WhatsNew/Data/WhatsNew.json new file mode 100644 index 000000000..355c720b6 --- /dev/null +++ b/WhatsNew/WhatsNew/Data/WhatsNew.json @@ -0,0 +1,52 @@ +[ + { + "version": "1.0", + "messages": [ + { + "image": "image1_1.0", + "title": "Improved language support", + "message": "We have added more translations throughout the app so you can learn on edX your way!" + }, + { + "image": "image2_1.0", + "title": "Download videos offline", + "message": "Easily download videos without having an internet connection, so you can keep learning when there isn’t a network around" + }, + { + "image": "image3_1.0", + "title": "Reduced Network Usage", + "message": "Now you can download your content faster to get right into your next lesson!" + }, + { + "image": "image4_1.0", + "title": "Learning Site Switching", + "message": "Switch more easily between multiple learning sites. Find the new options within account settings and easily manage your accounts" + } + ] + }, + { + "version": "0.9", + "messages": [ + { + "image": "image1_1.0", + "title": "1.3 Improved language support", + "message": "We have added more translations throughout the app so you can learn on edX your way!" + }, + { + "image": "image2_1.0", + "title": "Download videos offline", + "message": "Easily download videos without having an internet connection, so you can keep learning when there isn’t a network around" + }, + { + "image": "image3_1.0", + "title": "Reduced Network Usage", + "message": "Now you can download your content faster to get right into your next lesson!" + }, + { + "image": "image4_1.0", + "title": "Learning Site Switching", + "message": "Switch more easily between multiple learning sites. Find the new options within account settings and easily manage your accounts" + } + ] + } +] diff --git a/WhatsNew/WhatsNew/Data/WhatsNewModel.swift b/WhatsNew/WhatsNew/Data/WhatsNewModel.swift new file mode 100644 index 000000000..48302fb62 --- /dev/null +++ b/WhatsNew/WhatsNew/Data/WhatsNewModel.swift @@ -0,0 +1,69 @@ +// +// WhatsNewModel.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 19.10.2023. +// + +import Foundation + +// MARK: - WhatsNewModelElement +public struct WhatsNewModelElement: Codable { + public let version: String + public let messages: [Message] + + public init(version: String, messages: [Message]) { + self.version = version + self.messages = messages + } +} + +// MARK: - Message +public struct Message: Codable { + public let image: String + public let title: String + public let message: String + + public init(image: String, title: String, message: String) { + self.image = image + self.title = title + self.message = message + } +} + +public typealias WhatsNewModel = [WhatsNewModelElement] + +extension WhatsNewModel { + + private func compareVersions(_ version1: String, _ version2: String) -> ComparisonResult { + let v1 = version1.split(separator: ".").compactMap { Int($0) } + let v2 = version2.split(separator: ".").compactMap { Int($0) } + + for (a, b) in zip(v1, v2) { + if a != b { + return a < b ? .orderedAscending : .orderedDescending + } + } + + return v1.count < v2.count ? .orderedAscending : (v1.count > v2.count ? .orderedDescending : .orderedSame) + } + + private func findLatestVersion(_ versions: [String]) -> String? { + guard let latestVersion = versions.max(by: { compareVersions($0, $1) == .orderedAscending }) else { + return nil + } + return latestVersion + } + + + var domain: [WhatsNewPage] { + guard let latestVersion = findLatestVersion(self.map { $0.version }) else { return [] } + return self.first(where: { $0.version == latestVersion })?.messages.map { + WhatsNewPage( + image: $0.image, + title: $0.title, + description: $0.message + ) + } ?? [] + } +} diff --git a/WhatsNew/WhatsNew/Data/WhatsNewStorage.swift b/WhatsNew/WhatsNew/Data/WhatsNewStorage.swift new file mode 100644 index 000000000..35d35d998 --- /dev/null +++ b/WhatsNew/WhatsNew/Data/WhatsNewStorage.swift @@ -0,0 +1,21 @@ +// +// WhatsNewStorage.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 25.10.2023. +// + +import Foundation + +public protocol WhatsNewStorage { + var whatsNewVersion: String? {get set} +} + +#if DEBUG +public class WhatsNewStorageMock: WhatsNewStorage { + + public var whatsNewVersion: String? + + public init() {} +} +#endif diff --git a/WhatsNew/WhatsNew/Domain/WhatsNewPage.swift b/WhatsNew/WhatsNew/Domain/WhatsNewPage.swift new file mode 100644 index 000000000..8172ee037 --- /dev/null +++ b/WhatsNew/WhatsNew/Domain/WhatsNewPage.swift @@ -0,0 +1,14 @@ +// +// WhatsNewPage.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 25.10.2023. +// + +import Foundation + +struct WhatsNewPage { + let image: String + let title: String + let description: String +} diff --git a/WhatsNew/WhatsNew/Info.plist b/WhatsNew/WhatsNew/Info.plist new file mode 100644 index 000000000..f72a0f657 --- /dev/null +++ b/WhatsNew/WhatsNew/Info.plist @@ -0,0 +1,12 @@ + + + + + + diff --git a/WhatsNew/WhatsNew/Presentation/Elements/PageControl.swift b/WhatsNew/WhatsNew/Presentation/Elements/PageControl.swift new file mode 100644 index 000000000..d6050b6ec --- /dev/null +++ b/WhatsNew/WhatsNew/Presentation/Elements/PageControl.swift @@ -0,0 +1,32 @@ +// +// PageControl.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 18.10.2023. +// + +import SwiftUI +import Core + +struct PageControl: View { + let numberOfPages: Int + var currentPage: Int + + private var dots: some View { + HStack(spacing: 8) { + ForEach(0 ..< numberOfPages) { page in + RoundedRectangle(cornerRadius: 4) + .frame(width: page == currentPage ? 24 : 8, height: 8) + .foregroundColor(page == currentPage ? Theme.Colors.accentColor : Theme.Colors.textSecondary) + } + } + } + + var body: some View { + VStack { + Spacer() + dots + Spacer() + } + } +} diff --git a/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift b/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift new file mode 100644 index 000000000..03206348e --- /dev/null +++ b/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift @@ -0,0 +1,63 @@ +// +// CustomButton.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 18.10.2023. +// + +import SwiftUI +import Core + +struct WhatsNewNavigationButton: View { + let type: ButtonType + let action: () -> Void + + enum ButtonType { + case previous, next, done + } + + var body: some View { + Group { + HStack(spacing: 4) { + if type == .previous { + CoreAssets.arrowLeft.swiftUIImage + .renderingMode(.template) + .foregroundColor(Theme.Colors.accentColor) + } + + Text(type == .previous ? WhatsNewLocalization.buttonPrevious + : (type == .next ? WhatsNewLocalization.buttonNext : WhatsNewLocalization.buttonDone )) + .foregroundColor(type == .previous ? Theme.Colors.accentColor : Color.white) + .font(Theme.Fonts.labelLarge) + + if type == .next { + CoreAssets.arrowLeft.swiftUIImage + .renderingMode(.template) + .rotationEffect(Angle(degrees: 180)) + .foregroundColor(Color.white) + } + + if type == .done { + CoreAssets.checkmark.swiftUIImage + .renderingMode(.template) + .foregroundColor(Color.white) + } + }.padding(.horizontal, 20) + .padding(.vertical, 9) + }.fixedSize() + .background(type == .previous + ? Theme.Colors.background + : Theme.Colors.accentColor) + .accessibilityElement(children: .ignore) + .accessibilityLabel(type == .previous ? WhatsNewLocalization.buttonPrevious + : (type == .next ? WhatsNewLocalization.buttonNext : WhatsNewLocalization.buttonDone )) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(type == .previous + ? Theme.Colors.accentColor + : Theme.Colors.background, lineWidth: 1) + ) + .onTapGesture { action() } + } +} diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewRouter.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewRouter.swift new file mode 100644 index 000000000..4416e24fd --- /dev/null +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewRouter.swift @@ -0,0 +1,19 @@ +// +// WhatsNewRouter.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 18.10.2023. +// + +import Foundation +import Core + +public protocol WhatsNewRouter: BaseRouter { +} + +// Mark - For testing and SwiftUI preview +#if DEBUG +public class WhatsNewRouterMock: BaseRouterMock, WhatsNewRouter { + public override init() {} +} +#endif diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift new file mode 100644 index 000000000..bc419cb0a --- /dev/null +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift @@ -0,0 +1,160 @@ +// +// WhatsNewView.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 18.10.2023. +// + +import SwiftUI +import Core + +public struct WhatsNewView: View { + + private let router: WhatsNewRouter + + @ObservedObject + private var viewModel: WhatsNewViewModel + + @Environment (\.isHorizontal) + private var isHorizontal + + @State var index = 0 + + public init(router: WhatsNewRouter, viewModel: WhatsNewViewModel) { + self.router = router + self.viewModel = viewModel + } + + public var body: some View { + GeometryReader { reader in + ZStack(alignment: isHorizontal ? .center : .bottom) { + Theme.Colors.background + .ignoresSafeArea() + adaptiveStack(isHorizontal: isHorizontal) { + TabView(selection: $index) { + ForEach(Array(viewModel.newItems.enumerated()), id: \.offset) { _, new in + adaptiveStack(isHorizontal: isHorizontal) { + ZStack(alignment: .center) { + Image(new.image, bundle: Bundle(for: BundleToken.self)) + .resizable() + .scaledToFit() + .frame(minWidth: 250, maxWidth: 300) + .padding(24) + }.frame(minHeight: 250, maxHeight: 416) + Spacer() + } + } + }.tabViewStyle(.page(indexDisplayMode: .never)) + } + if isHorizontal { + HStack { + Spacer() + + Rectangle() + .foregroundColor(Theme.Colors.background) + .frame(width: reader.size.width / 1.9) + .ignoresSafeArea() + .mask( + LinearGradient( + gradient: Gradient(colors: [ + .clear, + .black, + .black, + .black, + .black, + .black, + .black, + .black, + .black]), + startPoint: .leading, + endPoint: .trailing + ) + ) + } .allowsHitTesting(false) + } + HStack { + if isHorizontal { + Spacer() + } + VStack(spacing: 16) { + VStack { + if !viewModel.newItems.isEmpty { + Text(viewModel.newItems[viewModel.index].title) + .font(Theme.Fonts.titleMedium) + Text(viewModel.newItems[viewModel.index].description) + .font(Theme.Fonts.bodyMedium) + .multilineTextAlignment(.center) + } + }.frame(height: 100) + .allowsHitTesting(false) + + HStack(spacing: 36) { + WhatsNewNavigationButton(type: .previous, action: { + if index != 0 { + withAnimation(.linear(duration: 0.3)) { + index -= 1 + } + } + }).opacity(viewModel.index != 0 ? 1 : 0) + WhatsNewNavigationButton( + type: viewModel.index < viewModel.newItems.count - 1 ? .next : .done, + action: { + if index < viewModel.newItems.count - 1 { + withAnimation(.linear(duration: 0.3)) { + index += 1 + } + } else { + router.showMainOrWhatsNewScreen() + } + } + ) + } + } + .padding(.bottom, isHorizontal ? 0 : 52) + .padding(.horizontal, 24) + .frame(width: isHorizontal ? reader.size.width / 1.9 : nil) + } + VStack { + if isHorizontal { + Spacer() + } + PageControl(numberOfPages: viewModel.newItems.count, currentPage: viewModel.index) + .frame(height: isHorizontal ? 8 : nil) + .allowsHitTesting(false) + .padding(.top, isHorizontal ? 0 : 170) + .padding(.bottom, 8) + } + + }.onChange(of: index) { ind in + withAnimation(.linear(duration: 0.3)) { + viewModel.index = ind + } + } + .navigationTitle(WhatsNewLocalization.title) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing, content: { + Button(action: { + router.showMainOrWhatsNewScreen() + }, label: { + Image(systemName: "xmark") + .foregroundColor(Theme.Colors.accentColor) + }) + }) + } + } + } + + class BundleToken {} +} + +#if DEBUG +struct WhatsNewView_Previews: PreviewProvider { + static var previews: some View { + WhatsNewView( + router: WhatsNewRouterMock(), + viewModel: WhatsNewViewModel(storage: WhatsNewStorageMock()) + ) + .loadFonts() + } +} +#endif diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift new file mode 100644 index 000000000..170472088 --- /dev/null +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift @@ -0,0 +1,73 @@ +// +// WhatsNewViewModel.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 18.10.2023. +// + +import SwiftUI +import Core +import Swinject + +public class WhatsNewViewModel: ObservableObject { + @Published var index: Int = 0 + @Published var newItems: [WhatsNewPage] = [] + private let storage: WhatsNewStorage + + public init(storage: WhatsNewStorage) { + self.storage = storage + newItems = loadWhatsNew() + } + + public func getVersion() -> String? { + guard let model = loadWhatsNewModel() else { return nil } + return model.first?.version + } + + public func shouldShowWhatsNew() -> Bool { + guard let currentVersion = getVersion() else { return false } + + // If there is no saved version in storage, we always show WhatsNew + guard let savedVersion = storage.whatsNewVersion else { return true } + + // We break down the versions into components major, minor, patch + let savedComponents = savedVersion.components(separatedBy: ".") + let currentComponents = currentVersion.components(separatedBy: ".") + + // Checking major and minor components + if savedComponents.count >= 2 && currentComponents.count >= 2 { + let savedMajor = savedComponents[0] + let savedMinor = savedComponents[1] + + let currentMajor = currentComponents[0] + let currentMinor = currentComponents[1] + + // If major or minor are different, show WhatsNew + if savedMajor != currentMajor || savedMinor != currentMinor { + return true + } + } + return false + } + + func loadWhatsNew() -> [WhatsNewPage] { + guard let domain = loadWhatsNewModel()?.domain else { return [] } + return domain + } + + private func loadWhatsNewModel() -> WhatsNewModel? { + guard let fileUrl = Bundle(for: Self.self).url(forResource: "WhatsNew", withExtension: "json") else { + print("Unable to locate WhatsNew.json") + return nil + } + + do { + let data = try Data(contentsOf: fileUrl) + let decoder = JSONDecoder() + return try decoder.decode(WhatsNewModel.self, from: data) + } catch { + print("Error decoding WhatsNew.json: \(error)") + return nil + } + } +} diff --git a/WhatsNew/WhatsNew/SwiftGen/Strings.swift b/WhatsNew/WhatsNew/SwiftGen/Strings.swift new file mode 100644 index 000000000..8b483b7b4 --- /dev/null +++ b/WhatsNew/WhatsNew/SwiftGen/Strings.swift @@ -0,0 +1,47 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +import Foundation + +// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references + +// MARK: - Strings + +// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces +public enum WhatsNewLocalization { + /// Done + public static let buttonDone = WhatsNewLocalization.tr("Localizable", "BUTTON_DONE", fallback: "Done") + /// Next + public static let buttonNext = WhatsNewLocalization.tr("Localizable", "BUTTON_NEXT", fallback: "Next") + /// Previous + public static let buttonPrevious = WhatsNewLocalization.tr("Localizable", "BUTTON_PREVIOUS", fallback: "Previous") + /// Localizable.strings + /// WhatsNew + /// + /// Created by  Stepanok Ivan on 18.10.2023. + public static let title = WhatsNewLocalization.tr("Localizable", "TITLE", fallback: "What's New") +} +// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces + +// MARK: - Implementation Details + +extension WhatsNewLocalization { + private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { + let format = BundleToken.bundle.localizedString(forKey: key, value: value, table: table) + return String(format: format, locale: Locale.current, arguments: args) + } +} + +// swiftlint:disable convenience_type +private final class BundleToken { + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() +} +// swiftlint:enable convenience_type diff --git a/WhatsNew/WhatsNew/en.lproj/Localizable.strings b/WhatsNew/WhatsNew/en.lproj/Localizable.strings new file mode 100644 index 000000000..08fffcb7b --- /dev/null +++ b/WhatsNew/WhatsNew/en.lproj/Localizable.strings @@ -0,0 +1,13 @@ +/* + Localizable.strings + WhatsNew + + Created by  Stepanok Ivan on 18.10.2023. + +*/ + +"TITLE" = "What's New"; +"BUTTON_PREVIOUS" = "Previous"; +"BUTTON_NEXT" = "Next"; +"BUTTON_DONE" = "Done"; + diff --git a/WhatsNew/WhatsNew/uk.lproj/Localizable.strings b/WhatsNew/WhatsNew/uk.lproj/Localizable.strings new file mode 100644 index 000000000..a0194425c --- /dev/null +++ b/WhatsNew/WhatsNew/uk.lproj/Localizable.strings @@ -0,0 +1,12 @@ +/* + Localizable.strings + WhatsNew + + Created by  Stepanok Ivan on 18.10.2023. + +*/ + +"TITLE" = "Що нового"; +"BUTTON_PREVIOUS" = "Назад"; +"BUTTON_NEXT" = "Далі"; +"BUTTON_DONE" = "Завершити"; diff --git a/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift b/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift new file mode 100644 index 000000000..4f43a51dd --- /dev/null +++ b/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift @@ -0,0 +1,27 @@ +// +// WhatsNewTests.swift +// WhatsNewTests +// +// Created by  Stepanok Ivan on 18.10.2023. +// + +import XCTest +@testable import WhatsNew + +final class WhatsNewTests: XCTestCase { + + func testGetVersion() throws { + let viewModel = WhatsNewViewModel(storage: WhatsNewStorageMock()) + let version = viewModel.getVersion() + XCTAssertNotNil(version) + XCTAssertTrue(version == "1.0") + } + + func testshouldShowWhatsNew() throws { + let viewModel = WhatsNewViewModel(storage: WhatsNewStorageMock()) + let version = viewModel.getVersion() + + XCTAssertNotNil(version) + XCTAssertTrue(viewModel.shouldShowWhatsNew()) + } +} diff --git a/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift b/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift new file mode 100644 index 000000000..999f7cc25 --- /dev/null +++ b/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift @@ -0,0 +1,19 @@ +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT + + +// Generated with SwiftyMocky 4.2.0 +// Required Sourcery: 1.8.0 + + +import SwiftyMocky +import XCTest +import Core +import WhatsNew +import Foundation +import SwiftUI +import Combine + + +// SwiftyMocky: no AutoMockable found. +// Please define and inherit from AutoMockable, or annotate protocols to be mocked diff --git a/WhatsNew/swiftgen.yml b/WhatsNew/swiftgen.yml new file mode 100644 index 000000000..9aa2d1c3b --- /dev/null +++ b/WhatsNew/swiftgen.yml @@ -0,0 +1,18 @@ +strings: + inputs: + - WhatsNew/en.lproj + outputs: + - templateName: structured-swift5 + params: + publicAccess: true + enumName: WhatsNewLocalization + output: WhatsNew/SwiftGen/Strings.swift +#xcassets: +# inputs: +# - WhatsNew/Assets.xcassets +# outputs: +# templateName: swift5 +# params: +# publicAccess: true +# enumName: WhatsNewAssets +# output: WhatsNew/SwiftGen/Assets.swift diff --git a/generateAllMocks.sh b/generateAllMocks.sh index ac1fe0dd6..0c4b5cfc7 100755 --- a/generateAllMocks.sh +++ b/generateAllMocks.sh @@ -12,4 +12,6 @@ cd ../Discovery cd ../Discussion ./../Pods/SwiftyMocky/bin/swiftymocky generate cd ../Profile +./../Pods/SwiftyMocky/bin/swiftymocky generate +cd ../WhatsNew ./../Pods/SwiftyMocky/bin/swiftymocky generate \ No newline at end of file